From 3764b62206899ac660eaf37f69b477c385847bf9 Mon Sep 17 00:00:00 2001 From: giles Date: Sat, 6 Jun 2026 18:48:44 +0000 Subject: [PATCH] =?UTF-8?q?mod:=20Ext=209=20=E2=80=94=20policy=20dry-run?= =?UTF-8?q?=20trace=20diagnostics,=20247/247?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mod/trace-rules evaluates a report against every rule, returning each rule's proved/unproved status + goal-by-goal derivation (an unproved rule shows which goal failed). mod/first-proved = winner (matches engine precedence, cross-checked), mod/proved-rules the firing set, mod/trace-report a [fires]/[ - ] rendering. Answers 'why didn't my rule fire?' without instrumenting the engine. +15 tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/mod/conformance.conf | 2 + lib/mod/scoreboard.json | 9 +-- lib/mod/scoreboard.md | 3 +- lib/mod/tests/trace.sx | 116 +++++++++++++++++++++++++++++++++++++++ lib/mod/trace.sx | 56 +++++++++++++++++++ plans/mod-on-sx.md | 13 ++++- 6 files changed, 193 insertions(+), 6 deletions(-) create mode 100644 lib/mod/tests/trace.sx create mode 100644 lib/mod/trace.sx diff --git a/lib/mod/conformance.conf b/lib/mod/conformance.conf index 70b99efc..75243257 100644 --- a/lib/mod/conformance.conf +++ b/lib/mod/conformance.conf @@ -17,6 +17,7 @@ PRELOADS=( lib/mod/severity.sx lib/mod/offenders.sx lib/mod/quorum.sx + lib/mod/trace.sx lib/mod/lifecycle.sx lib/mod/audit.sx lib/mod/api.sx @@ -36,4 +37,5 @@ SUITES=( "severity:lib/mod/tests/severity.sx:(mod-severity-tests-run!)" "offenders:lib/mod/tests/offenders.sx:(mod-offenders-tests-run!)" "quorum:lib/mod/tests/quorum.sx:(mod-quorum-tests-run!)" + "trace:lib/mod/tests/trace.sx:(mod-trace-tests-run!)" ) diff --git a/lib/mod/scoreboard.json b/lib/mod/scoreboard.json index 3ce936ee..6282142f 100644 --- a/lib/mod/scoreboard.json +++ b/lib/mod/scoreboard.json @@ -1,8 +1,8 @@ { "lang": "mod", - "total_passed": 232, + "total_passed": 247, "total_failed": 0, - "total": 232, + "total": 247, "suites": [ {"name":"decide","passed":31,"failed":0,"total":31}, {"name":"audit","passed":29,"failed":0,"total":29}, @@ -13,7 +13,8 @@ {"name":"lint","passed":14,"failed":0,"total":14}, {"name":"severity","passed":14,"failed":0,"total":14}, {"name":"offenders","passed":19,"failed":0,"total":19}, - {"name":"quorum","passed":9,"failed":0,"total":9} + {"name":"quorum","passed":9,"failed":0,"total":9}, + {"name":"trace","passed":15,"failed":0,"total":15} ], - "generated": "2026-06-06T18:44:47+00:00" + "generated": "2026-06-06T18:48:10+00:00" } diff --git a/lib/mod/scoreboard.md b/lib/mod/scoreboard.md index 9b12db6a..04d8ae2d 100644 --- a/lib/mod/scoreboard.md +++ b/lib/mod/scoreboard.md @@ -1,6 +1,6 @@ # mod scoreboard -**232 / 232 passing** (0 failure(s)). +**247 / 247 passing** (0 failure(s)). | Suite | Passed | Total | Status | |-------|--------|-------|--------| @@ -14,3 +14,4 @@ | severity | 14 | 14 | ok | | offenders | 19 | 19 | ok | | quorum | 9 | 9 | ok | +| trace | 15 | 15 | ok | diff --git a/lib/mod/tests/trace.sx b/lib/mod/tests/trace.sx new file mode 100644 index 00000000..de4e58d7 --- /dev/null +++ b/lib/mod/tests/trace.sx @@ -0,0 +1,116 @@ +;; lib/mod/tests/trace.sx — Ext 9: policy dry-run diagnostics. + +(define mod-tr-count 0) +(define mod-tr-pass 0) +(define mod-tr-fail 0) +(define mod-tr-failures (list)) + +(define + mod-tr-test! + (fn + (name got expected) + (begin + (set! mod-tr-count (+ mod-tr-count 1)) + (if + (= got expected) + (set! mod-tr-pass (+ mod-tr-pass 1)) + (begin + (set! mod-tr-fail (+ mod-tr-fail 1)) + (append! + mod-tr-failures + (str name "\n expected: " expected "\n got: " got))))))) + +(define + mod-tr-find + (fn + (trace nm) + (reduce (fn (acc t) (if (= (get t :rule) nm) t acc)) nil trace))) + +;; ── trace a spam report against the default rules ── + +(define mod-tr-spam (mod/mk-report "r1" "alice" "bob" "this is spam")) +(define + mod-tr-t + (mod/trace-rules mod-tr-spam (list mod-tr-spam) mod/default-rules)) + +(mod-tr-test! "trace covers every rule" (len mod-tr-t) 6) +(mod-tr-test! + "spam-hide fires" + (get (mod-tr-find mod-tr-t "spam-hide") :proved) + true) +(mod-tr-test! + "default-keep always fires" + (get (mod-tr-find mod-tr-t "default-keep") :proved) + true) +(mod-tr-test! + "reviewer-remove does not fire (no evidence)" + (get (mod-tr-find mod-tr-t "reviewer-remove") :proved) + false) +(mod-tr-test! + "exonerated-keep does not fire" + (get (mod-tr-find mod-tr-t "exonerated-keep") :proved) + false) +(mod-tr-test! + "abuse-remove does not fire" + (get (mod-tr-find mod-tr-t "abuse-remove") :proved) + false) + +;; ── winner matches the engine ── + +(mod-tr-test! + "first-proved is spam-hide" + (get (mod/first-proved mod-tr-t) :rule) + "spam-hide") +(mod-tr-test! + "winner action matches decide-report" + (get (mod/first-proved mod-tr-t) :action) + (get + (mod/decide-report mod-tr-spam (list mod-tr-spam) mod/default-rules) + :action)) + +;; ── an unproved rule shows which goal failed ── + +(define + mod-tr-rev-goals + (get (mod-tr-find mod-tr-t "reviewer-remove") :goals)) +(mod-tr-test! + "reviewer-remove goal is unsolved" + (get (first mod-tr-rev-goals) :solved) + false) +(define mod-tr-spam-goals (get (mod-tr-find mod-tr-t "spam-hide") :goals)) +(mod-tr-test! + "spam-hide goal is solved" + (get (first mod-tr-spam-goals) :solved) + true) + +;; ── proved-rules list + rendering ── + +(mod-tr-test! + "proved-rules lists fired rules in order" + (mod/proved-rules mod-tr-t) + (list "spam-hide" "default-keep")) +(mod-tr-test! + "trace-report marks a firing rule" + (mod/str-contains? (mod/trace-report mod-tr-t) "[fires] spam-hide") + true) +(mod-tr-test! + "trace-report marks a non-firing rule" + (mod/str-contains? (mod/trace-report mod-tr-t) "[ - ] reviewer-remove") + true) + +;; ── clean report: only default-keep fires ── + +(define mod-tr-clean (mod/mk-report "r2" "a" "b" "a fine post")) +(define + mod-tr-tc + (mod/trace-rules mod-tr-clean (list mod-tr-clean) mod/default-rules)) +(mod-tr-test! + "clean report: only default-keep proves" + (mod/proved-rules mod-tr-tc) + (list "default-keep")) +(mod-tr-test! + "clean report winner is default-keep" + (get (mod/first-proved mod-tr-tc) :rule) + "default-keep") + +(define mod-trace-tests-run! (fn () {:failures mod-tr-failures :total mod-tr-count :passed mod-tr-pass :failed mod-tr-fail})) diff --git a/lib/mod/trace.sx b/lib/mod/trace.sx new file mode 100644 index 00000000..2e920949 --- /dev/null +++ b/lib/mod/trace.sx @@ -0,0 +1,56 @@ +;; lib/mod/trace.sx — policy dry-run diagnostics. +;; +;; decide-report returns the winning rule; a policy author debugging "why didn't +;; my rule fire?" needs the whole picture. mod/trace-rules evaluates a report +;; against every rule and reports each rule's proved/unproved status plus its +;; goal-by-goal derivation — so an unproved rule shows exactly which goal failed. +;; The winner is the first proved rule (same precedence as the engine). + +(define + mod/trace-rules + (fn + (r reports rules) + (let + ((count (mod/report-count (mod/report-about r) reports)) + (id (mod/report-id r))) + (let + ((db (pl-load (mod/build-program r count rules)))) + (let + ((proved-names (map (fn (s) (dict-get s "Rule")) (pl-query-all db (str "policy_action(" id ", _, Rule)"))))) + (map + (fn (rule) (let ((nm (mod/rule-name rule))) {:proved (mod/member? nm proved-names) :goals (mod/proof-goals db id (mod/rule-when rule)) :action (mod/rule-action rule) :rule nm})) + rules)))))) + +(define + mod/first-proved + (fn + (trace) + (reduce + (fn (acc t) (if (nil? acc) (if (get t :proved) t acc) acc)) + nil + trace))) + +(define + mod/proved-rules + (fn + (trace) + (reduce + (fn + (acc t) + (if (get t :proved) (append acc (list (get t :rule))) acc)) + (list) + trace))) + +(define + mod/trace-row + (fn + (t) + (str + (if (get t :proved) "[fires] " "[ - ] ") + (get t :rule) + " → " + (get t :action)))) + +(define + mod/trace-report + (fn (trace) (mod/join-with "\n" (map mod/trace-row trace)))) diff --git a/plans/mod-on-sx.md b/plans/mod-on-sx.md index 103de69c..7ff76ea3 100644 --- a/plans/mod-on-sx.md +++ b/plans/mod-on-sx.md @@ -16,7 +16,7 @@ federation extension. ## Status (rolling) -`bash lib/mod/conformance.sh` → **232/232** (roadmap + 8 extensions complete) +`bash lib/mod/conformance.sh` → **247/247** (roadmap + 9 extensions complete) ## Ground rules @@ -147,6 +147,12 @@ lib/mod/fed.sx derivation goal-by-goal with `[proved]`/`[unproved]` marks and unification bindings. E.g. `Report rc: escalate (rule: repeated-escalate)` … `[proved] report(rc, B, S), report_count(S, N), N >= 3 {B=ann, N=3, S=dave}`. +- [x] **Ext 9 — policy dry-run trace** (`lib/mod/trace.sx`, +15). `mod/trace-rules` + evaluates a report against every rule and returns each rule's proved/unproved + status + its goal-by-goal derivation, so an unproved rule shows which goal + failed. `mod/first-proved` = the winner (engine precedence), `mod/proved-rules` + the full firing set, `mod/trace-report` a `[fires]`/`[ - ]` rendering. Answers + "why didn't my rule fire?" without instrumenting the engine. - [x] **Ext 8 — quorum over distinct reporters** (`lib/mod/quorum.sx`, +9). Anti- brigade: `(:reporters-at-least N)` compiles to `setof(Br, report(_, Br, Sr), Bsr), length(Bsr, Nr), Nr >= N` — distinct reporters, not raw report count. @@ -180,6 +186,11 @@ lib/mod/fed.sx ## Progress log +- **Ext 9 — policy dry-run trace, 247/247** (+15). Whole-rule-set diagnostics over + the proof machinery: every rule's fire/no-fire and the goal that decided it. The + winner agrees with `decide-report` by construction (first proved = pl-query-one), + cross-checked in a test. Turns the proof tree from a per-decision artifact into a + policy-debugging tool. - **Ext 8 — quorum over distinct reporters, 232/232** (+9). Distinct-reporter consensus via Prolog `setof`/`length`, requiring a second engine variant that asserts all reports (the base engine deliberately scopes facts to the decided