From 72174941aad3914772a4227b49dd621fdb23557c Mon Sep 17 00:00:00 2001 From: giles Date: Sat, 6 Jun 2026 17:18:02 +0000 Subject: [PATCH 01/26] briefings: add mod-on-sx loop briefing Co-Authored-By: Claude Opus 4.8 (1M context) --- plans/agent-briefings/mod-loop.md | 109 ++++++++++++++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 plans/agent-briefings/mod-loop.md diff --git a/plans/agent-briefings/mod-loop.md b/plans/agent-briefings/mod-loop.md new file mode 100644 index 00000000..2353de14 --- /dev/null +++ b/plans/agent-briefings/mod-loop.md @@ -0,0 +1,109 @@ +# mod-on-sx loop agent (single agent, queue-driven) + +Role: iterates `plans/mod-on-sx.md` forever. **Moderation on Prolog** — reports, +policy rules, decisions as backtracking proof search, audit trails, escalation +state machine, federation. Where acl-sx asks "may this happen?", mod-sx asks +"should this stay?" Sits on `lib/prolog/` (its test suite already green); adds a +moderation-shaped vocabulary on top. + +``` +description: mod-on-sx queue loop +subagent_type: general-purpose +run_in_background: true +isolation: worktree +``` + +## Prompt + +You are the sole background agent working `plans/mod-on-sx.md`. Isolated worktree +`/root/rose-ash-loops/mod` on branch `loops/mod`, forever, one commit per feature. +Push to `origin/loops/mod` after every commit. Never touch `main` or `architecture`. + +## Restart baseline — check before iterating + +1. Read `plans/mod-on-sx.md` — roadmap + Progress log. +2. `ls lib/mod/` — pick up from the most advanced file. +3. If `lib/mod/tests/*.sx` exist, run them via `bash lib/mod/conformance.sh`. Green + before new work. +4. If `lib/mod/scoreboard.md` exists, that's your baseline. +5. Read the `lib/prolog/` public API once — that's your substrate. The plan cites + `lib/prolog/prolog.sx` but that file does **not** exist; the real entry points + are `lib/prolog/runtime.sx`, `query.sx`, `compiler.sx`, `parser.sx`. Investigate + them (sx_find_all / grep for `(define ` heads) to learn how to assert facts and + run queries before writing any policy code. + +## The queue + +Phase order per `plans/mod-on-sx.md`: + +- **Phase 1** — report representation + simple policy (schema, defrule→clause, + `(decide id)` query, api). Tests: spam keyword → hide, repeated reports → + escalate, no rule → keep. +- **Phase 2** — evidence accumulation + audit trail (proof tree from derivation, + append-only decision log, retrieval). +- **Phase 3** — escalation + lifecycle state machine + (`:open → :triaged → :decided → :appealed → :final`), auto/human tiers, appeal. +- **Phase 4** — federation (cross-instance reports, decision sharing, trust model, + revocation; mock fed-sx in tests). + +Within a phase, pick the checkbox that unlocks the most tests per effort. + +Every iteration: implement → test → commit → tick `[ ]` → Progress log → next. + +## Ground rules (hard) + +- **Scope:** only `lib/mod/**` and `plans/mod-on-sx.md`. Do **not** edit `spec/`, + `hosts/`, `shared/`, other `lib//` dirs, `lib/stdlib.sx`, or `lib/` root. + May **import** from `lib/prolog/` only (its public API). Do **not** modify Prolog. +- **NEVER call `sx_build`.** 600s watchdog. If the sx_server binary is broken → + Blockers entry, stop. Run tests by invoking the sx_server binary directly from a + conformance.sh (see how `lib/prolog/conformance.sh` drives it), pointing + `SX_SERVER` at `/root/rose-ash/hosts/ocaml/_build/default/bin/sx_server.exe` + (fresh worktrees have no `_build/`). +- **Shared-file issues** → plan's Blockers with minimal repro; don't fix here. +- **SX files:** `sx-tree` MCP tools ONLY. **They take `file:` not `path:`** — a + wrong key yields `Yojson Type_error("Expected string, got null")`, which looks + like a broken binary but is just a param mismatch. `sx_validate` after edits. + Path-based edits (`sx_replace_node`) count comment headers in their indices and + can clobber the wrong node — re-read after, or prefer `sx_write_file` for small + files. +- **Unicode in `.sx`:** raw UTF-8 only, never `\uXXXX` escapes. +- **Commit granularity:** one feature per commit. Short factual messages + (`mod: spam-keyword policy rule → :hide + 6 tests`). Push to `origin/loops/mod`. +- **Plan file:** update Progress log (newest first) + tick boxes every commit. + +## mod-specific gotchas + +- **Decisions are proofs, not booleans.** A decision should carry *why* — the + matching rule / derivation — so Phase 2's audit trail can persist it. Design the + Phase-1 `decide` return shape with that in mind (don't return a bare keyword you + later have to retrofit). +- **Policy chains backtrack.** Order matters: first matching rule wins. Make rule + precedence explicit and deterministic (tests will depend on it). A "no rule + matched" outcome must be a real, testable result (`:keep`), not a query failure + you forget to handle. +- **Negative decisions need closed-world care.** "No evidence of violation" vs + "evidence absent" differ. Be explicit about negation-as-failure where you use it. +- **Lifecycle state is separate from policy.** Keep the state machine (Phase 3) as + an SX module over the engine, not tangled into Prolog rules. +- **Federation trust is advisory by default.** A peer's decision only binds locally + when `(trust peer :mod)` holds; otherwise it's a suggestion. Don't auto-apply. + +## General gotchas (all loops) + +- SX `do` = R7RS iteration. Use `begin` for multi-expr sequences. +- `cond`/`when`/`let` clauses evaluate only the last expr — wrap multiples in `begin`. +- `let` is parallel, not sequential — nest `let`s when a binding references an earlier one. +- `env-bind!` creates a binding; `env-set!` mutates an existing one (walks scope chain). +- `sx_validate` after every structural edit. +- Namespace-prefix all guest helpers (`mod/...`) — short/host-colliding names + (`bind`, `conj`, `name`) get silently shadowed or hang the runtime. + +## Style + +- No comments in `.sx` unless non-obvious. +- No new planning docs — update `plans/mod-on-sx.md` inline. +- Short, factual commit messages. +- One feature per iteration. Commit. Log. Push. Next. + +Go. Start by reading the plan; find the first unchecked `[ ]`; implement it. From 8dfc987095af950d06f15608ce697c86343db119 Mon Sep 17 00:00:00 2001 From: giles Date: Sat, 6 Jun 2026 17:30:50 +0000 Subject: [PATCH 02/26] =?UTF-8?q?mod:=20Phase=201=20=E2=80=94=20report=20s?= =?UTF-8?q?chema=20+=20policy=20engine=20on=20Prolog,=2031/31?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reports → Prolog facts (report/3, classification/2, report_count/2); ordered policy rules compile to policy_action/3 clauses, first match wins via pl-query-one. Decisions carry their proof (matching rule + conditions + evidence). Spam/abuse keyword classification, repeated-report escalation via Prolog join+arithmetic, no-rule→keep default. Registry api + conformance harness. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/mod/api.sx | 44 ++++++++ lib/mod/conformance.conf | 21 ++++ lib/mod/conformance.sh | 3 + lib/mod/engine.sx | 45 ++++++++ lib/mod/policy.sx | 71 +++++++++++++ lib/mod/schema.sx | 120 ++++++++++++++++++++++ lib/mod/scoreboard.json | 10 ++ lib/mod/scoreboard.md | 7 ++ lib/mod/tests/decide.sx | 215 +++++++++++++++++++++++++++++++++++++++ plans/mod-on-sx.md | 46 ++++++--- 10 files changed, 568 insertions(+), 14 deletions(-) create mode 100644 lib/mod/api.sx create mode 100644 lib/mod/conformance.conf create mode 100755 lib/mod/conformance.sh create mode 100644 lib/mod/engine.sx create mode 100644 lib/mod/policy.sx create mode 100644 lib/mod/schema.sx create mode 100644 lib/mod/scoreboard.json create mode 100644 lib/mod/scoreboard.md create mode 100644 lib/mod/tests/decide.sx diff --git a/lib/mod/api.sx b/lib/mod/api.sx new file mode 100644 index 00000000..929c39ea --- /dev/null +++ b/lib/mod/api.sx @@ -0,0 +1,44 @@ +;; lib/mod/api.sx — report registry + public entry points. +;; +;; mod/report files a report (assigning a sequential id) into the in-memory +;; registry; mod/decide resolves an id and runs the policy engine against the +;; current registry and rule set. + +(define mod/*reports* (list)) +(define mod/*counter* 0) +(define mod/*rules* mod/default-rules) + +(define + mod/reset! + (fn + () + (begin (set! mod/*reports* (list)) (set! mod/*counter* 0)))) + +(define + mod/report + (fn + (by about reason) + (begin + (set! mod/*counter* (+ mod/*counter* 1)) + (let + ((id (str "r" mod/*counter*))) + (let + ((r (mod/mk-report id by about reason))) + (begin (append! mod/*reports* r) r)))))) + +(define + mod/get-report + (fn + (id) + (reduce + (fn (acc r) (if (= (mod/report-id r) id) r acc)) + nil + mod/*reports*))) + +(define + mod/decide + (fn + (id) + (let + ((r (mod/get-report id))) + (if (nil? r) nil (mod/decide-report r mod/*reports* mod/*rules*))))) diff --git a/lib/mod/conformance.conf b/lib/mod/conformance.conf new file mode 100644 index 00000000..b2c0b751 --- /dev/null +++ b/lib/mod/conformance.conf @@ -0,0 +1,21 @@ +# Mod conformance config — sourced by lib/guest/conformance.sh. + +LANG_NAME=mod +MODE=dict + +PRELOADS=( + lib/guest/pratt.sx + lib/prolog/tokenizer.sx + lib/prolog/parser.sx + lib/prolog/runtime.sx + lib/prolog/query.sx + lib/prolog/compiler.sx + lib/mod/schema.sx + lib/mod/policy.sx + lib/mod/engine.sx + lib/mod/api.sx +) + +SUITES=( + "decide:lib/mod/tests/decide.sx:(mod-decide-tests-run!)" +) diff --git a/lib/mod/conformance.sh b/lib/mod/conformance.sh new file mode 100755 index 00000000..79c1452b --- /dev/null +++ b/lib/mod/conformance.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +# Thin wrapper — see lib/guest/conformance.sh and lib/mod/conformance.conf. +exec bash "$(dirname "$0")/../guest/conformance.sh" "$(dirname "$0")/conformance.conf" "$@" diff --git a/lib/mod/engine.sx b/lib/mod/engine.sx new file mode 100644 index 00000000..20e7de5b --- /dev/null +++ b/lib/mod/engine.sx @@ -0,0 +1,45 @@ +;; lib/mod/engine.sx — decide a report by querying the policy program. +;; +;; build-program assembles the report's facts plus the compiled policy clauses; +;; decide-report runs the Prolog query and returns a decision. A decision is a +;; proof, not a bare keyword: it carries the matching rule, the conditions it +;; required, the evidence that satisfied them, and the report count — everything +;; Phase 2's audit trail needs to persist a "why". + +(define + mod/find-rule + (fn + (rules name) + (reduce + (fn + (acc r) + (if (nil? acc) (if (= (mod/rule-name r) name) r acc) acc)) + nil + rules))) + +(define + mod/build-program + (fn + (r count rules) + (str (mod/report-facts r count) "\n" (mod/rules->program rules)))) + +(define + mod/decide-report + (fn + (r reports rules) + (let + ((count (mod/report-count (mod/report-about r) reports)) + (kinds (mod/classify-keywords r)) + (id (mod/report-id r))) + (let + ((program (mod/build-program r count rules))) + (let + ((db (pl-load program))) + (let + ((sol (pl-query-one db (str "policy_action(" id ", Action, Rule)")))) + (if + (nil? sol) + {:action "keep" :proof {:evidence kinds :conditions (list) :rule "none" :count count} :report-id id :rule "none"} + (let + ((rname (dict-get sol "Rule"))) + (let ((rule (mod/find-rule rules rname))) {:action (mod/rule-action rule) :proof {:evidence kinds :conditions (mod/rule-when rule) :rule rname :count count} :report-id id :rule rname}))))))))) diff --git a/lib/mod/policy.sx b/lib/mod/policy.sx new file mode 100644 index 00000000..2649864e --- /dev/null +++ b/lib/mod/policy.sx @@ -0,0 +1,71 @@ +;; lib/mod/policy.sx — moderation rules → Prolog clauses. +;; +;; A rule is {:name :action :when}. :when is a list of condition forms; each +;; compiles to a Prolog goal. Rule order is precedence: the engine queries with +;; pl-query-one, so the first clause that proves wins. The final default rule has +;; an empty body (true) so every report yields at least :keep — "no rule matched" +;; is a real result, not a query failure. + +(define mod/mk-rule (fn (name action conds) {:when conds :name name :action action})) + +(define mod/rule-name (fn (r) (get r :name))) +(define mod/rule-action (fn (r) (get r :action))) +(define mod/rule-when (fn (r) (get r :when))) + +(define + mod/default-rules + (list + (mod/mk-rule "spam-hide" :hide (list (list :classification "spam"))) + (mod/mk-rule + "abuse-remove" + :remove (list (list :classification "abuse"))) + (mod/mk-rule + "repeated-escalate" + :escalate (list (list :count-at-least 3))) + (mod/mk-rule "default-keep" :keep (list)))) + +;; ── condition → Prolog goal ── +;; +;; (:classification "spam") → classification(Id, spam) +;; (:count-at-least 3) → report(Id, B, S), report_count(S, N), N >= 3 + +(define + mod/cond->goal + (fn + (c) + (let + ((tag (first c))) + (cond + ((= tag :classification) + (str "classification(Id, " (nth c 1) ")")) + ((= tag :count-at-least) + (str + "report(Id, B, S), report_count(S, N), N >= " + (nth c 1))) + (true "true"))))) + +(define + mod/conds->body + (fn + (conds) + (if + (empty? conds) + "true" + (mod/join-with ", " (map mod/cond->goal conds))))) + +(define + mod/rule->clause + (fn + (r) + (str + "policy_action(Id, " + (mod/rule-action r) + ", '" + (mod/rule-name r) + "') :- " + (mod/conds->body (mod/rule-when r)) + "."))) + +(define + mod/rules->program + (fn (rules) (mod/join-with "\n" (map mod/rule->clause rules)))) diff --git a/lib/mod/schema.sx b/lib/mod/schema.sx new file mode 100644 index 00000000..c675a583 --- /dev/null +++ b/lib/mod/schema.sx @@ -0,0 +1,120 @@ +;; lib/mod/schema.sx — report representation + Prolog fact generation. +;; +;; A report is a dict {:id :by :about :reason}. The engine derives evidence +;; (classification kinds) from the reason text, then projects the report and its +;; evidence into Prolog facts that policy clauses can match against. + +(define mod/mk-report (fn (id by about reason) {:id id :by by :about about :reason reason})) + +(define mod/report-id (fn (r) (get r :id))) +(define mod/report-by (fn (r) (get r :by))) +(define mod/report-about (fn (r) (get r :about))) +(define mod/report-reason (fn (r) (get r :reason))) + +;; ── substring search (the prolog-loaded env lacks includes?; slice/len do work) ── + +(define + mod/contains-at? + (fn + (hay needle hl nl pos) + (if + (< hl (+ pos nl)) + false + (if + (= (slice hay pos (+ pos nl)) needle) + true + (mod/contains-at? hay needle hl nl (+ pos 1)))))) + +(define + mod/str-contains? + (fn + (hay needle) + (let + ((hl (len hay)) (nl (len needle))) + (if + (= nl 0) + true + (mod/contains-at? hay needle hl nl 0))))) + +;; ── evidence derivation (keyword classification) ── + +(define + mod/spam-keywords + (list "spam" "buy now" "click here" "free money" "viagra" "limited offer")) + +(define + mod/abuse-keywords + (list "abuse" "harassment" "threat" "slur" "hate speech")) + +(define + mod/any? + (fn (pred coll) (reduce (fn (acc x) (if acc acc (pred x))) false coll))) + +(define + mod/reason-matches? + (fn + (reason kws) + (let + ((low (downcase reason))) + (mod/any? (fn (k) (mod/str-contains? low k)) kws)))) + +(define + mod/classify-keywords + (fn + (r) + (let + ((reason (mod/report-reason r)) (kinds (list))) + (begin + (when + (mod/reason-matches? reason mod/spam-keywords) + (append! kinds "spam")) + (when + (mod/reason-matches? reason mod/abuse-keywords) + (append! kinds "abuse")) + kinds)))) + +(define + mod/report-count + (fn + (about reports) + (reduce + (fn + (acc r) + (if (= (mod/report-about r) about) (+ acc 1) acc)) + 0 + reports))) + +;; ── Prolog fact projection ── + +(define + mod/join-with + (fn + (sep items) + (reduce (fn (acc x) (if (= acc "") x (str acc sep x))) "" items))) + +(define mod/pl-quote (fn (s) (str "'" s "'"))) + +(define + mod/classification-facts + (fn + (id kinds) + (mod/join-with + "\n" + (map (fn (k) (str "classification(" id ", " k ").")) kinds)))) + +(define + mod/report-facts + (fn + (r count) + (let + ((id (mod/report-id r)) + (by (mod/pl-quote (mod/report-by r))) + (about (mod/pl-quote (mod/report-about r)))) + (let + ((cls (mod/classification-facts id (mod/classify-keywords r)))) + (mod/join-with + "\n" + (list + (str "report(" id ", " by ", " about ").") + (str "report_count(" about ", " count ").") + cls)))))) diff --git a/lib/mod/scoreboard.json b/lib/mod/scoreboard.json new file mode 100644 index 00000000..d4374712 --- /dev/null +++ b/lib/mod/scoreboard.json @@ -0,0 +1,10 @@ +{ + "lang": "mod", + "total_passed": 31, + "total_failed": 0, + "total": 31, + "suites": [ + {"name":"decide","passed":31,"failed":0,"total":31} + ], + "generated": "2026-06-06T17:30:06+00:00" +} diff --git a/lib/mod/scoreboard.md b/lib/mod/scoreboard.md new file mode 100644 index 00000000..36d7bba3 --- /dev/null +++ b/lib/mod/scoreboard.md @@ -0,0 +1,7 @@ +# mod scoreboard + +**31 / 31 passing** (0 failure(s)). + +| Suite | Passed | Total | Status | +|-------|--------|-------|--------| +| decide | 31 | 31 | ok | diff --git a/lib/mod/tests/decide.sx b/lib/mod/tests/decide.sx new file mode 100644 index 00000000..d903a10e --- /dev/null +++ b/lib/mod/tests/decide.sx @@ -0,0 +1,215 @@ +;; lib/mod/tests/decide.sx — Phase 1: report representation + simple policy. + +(define mod-dec-count 0) +(define mod-dec-pass 0) +(define mod-dec-fail 0) +(define mod-dec-failures (list)) + +(define + mod-dec-test! + (fn + (name got expected) + (begin + (set! mod-dec-count (+ mod-dec-count 1)) + (if + (= got expected) + (set! mod-dec-pass (+ mod-dec-pass 1)) + (begin + (set! mod-dec-fail (+ mod-dec-fail 1)) + (append! + mod-dec-failures + (str name "\n expected: " expected "\n got: " got))))))) + +;; decide a single report (count over a 1-element registry) +(define + mod-dec-one + (fn + (reason) + (let + ((r (mod/mk-report "r1" "alice" "bob" reason))) + (mod/decide-report r (list r) mod/default-rules)))) + +(define mod-dec-action (fn (reason) (get (mod-dec-one reason) :action))) + +;; ── spam keyword → :hide ── + +(mod-dec-test! + "spam keyword 'spam' → hide" + (mod-dec-action "this is spam") + "hide") +(mod-dec-test! + "spam keyword 'buy now' → hide" + (mod-dec-action "buy now while stocks last") + "hide") +(mod-dec-test! + "spam keyword case-insensitive 'CLICK HERE' → hide" + (mod-dec-action "CLICK HERE now") + "hide") +(mod-dec-test! + "spam keyword 'free money' → hide" + (mod-dec-action "win free money fast") + "hide") + +;; ── abuse keyword → :remove ── + +(mod-dec-test! + "abuse keyword 'harassment' → remove" + (mod-dec-action "ongoing harassment of users") + "remove") +(mod-dec-test! + "abuse keyword 'threat' → remove" + (mod-dec-action "this is a threat") + "remove") +(mod-dec-test! + "abuse keyword 'slur' → remove" + (mod-dec-action "contains a slur") + "remove") + +;; ── no rule → :keep ── + +(mod-dec-test! + "neutral reason → keep" + (mod-dec-action "I disagree with this post") + "keep") +(mod-dec-test! "empty reason → keep" (mod-dec-action "") "keep") + +;; ── decision carries the matching rule (proof, not bare keyword) ── + +(mod-dec-test! + "spam decision rule name" + (get (mod-dec-one "this is spam") :rule) + "spam-hide") +(mod-dec-test! + "keep decision rule name" + (get (mod-dec-one "fine post") :rule) + "default-keep") +(mod-dec-test! + "abuse decision rule name" + (get (mod-dec-one "harassment here") :rule) + "abuse-remove") +(mod-dec-test! + "spam proof :rule" + (get (get (mod-dec-one "spam!") :proof) :rule) + "spam-hide") +(mod-dec-test! + "spam proof :evidence" + (get (get (mod-dec-one "spam!") :proof) :evidence) + (list "spam")) +(mod-dec-test! + "spam proof :count" + (get (get (mod-dec-one "spam!") :proof) :count) + 1) + +;; ── classification (evidence derivation) ── + +(mod-dec-test! + "classify spam" + (mod/classify-keywords (mod/mk-report "r1" "a" "b" "spam!")) + (list "spam")) +(mod-dec-test! + "classify abuse" + (mod/classify-keywords (mod/mk-report "r1" "a" "b" "abuse")) + (list "abuse")) +(mod-dec-test! + "classify neutral → empty" + (mod/classify-keywords (mod/mk-report "r1" "a" "b" "hello")) + (list)) +(mod-dec-test! + "classify both spam+abuse" + (mod/classify-keywords (mod/mk-report "r1" "a" "b" "spam and abuse")) + (list "spam" "abuse")) + +;; ── report-count + repeated → :escalate ── + +(define + mod-dec-three + (list + (mod/mk-report "r1" "a" "bob" "x") + (mod/mk-report "r2" "c" "bob" "y") + (mod/mk-report "r3" "d" "bob" "z"))) + +(mod-dec-test! + "report-count counts subject" + (mod/report-count "bob" mod-dec-three) + 3) +(mod-dec-test! + "3 reports about subject → escalate" + (get + (mod/decide-report (first mod-dec-three) mod-dec-three mod/default-rules) + :action) + "escalate") +(mod-dec-test! + "escalate rule name" + (get + (mod/decide-report (first mod-dec-three) mod-dec-three mod/default-rules) + :rule) + "repeated-escalate") + +(define + mod-dec-two + (list + (mod/mk-report "r1" "a" "carol" "x") + (mod/mk-report "r2" "c" "carol" "y"))) + +(mod-dec-test! + "2 reports about subject → keep (below threshold)" + (get + (mod/decide-report (first mod-dec-two) mod-dec-two mod/default-rules) + :action) + "keep") + +;; ── precedence: spam beats repeated ── + +(define + mod-dec-spam-among-many + (list + (mod/mk-report "r1" "a" "dave" "buy now spam") + (mod/mk-report "r2" "c" "dave" "y") + (mod/mk-report "r3" "d" "dave" "z"))) + +(mod-dec-test! + "spam wins over repeated (precedence)" + (get + (mod/decide-report + (first mod-dec-spam-among-many) + mod-dec-spam-among-many + mod/default-rules) + :action) + "hide") + +;; ── accessors ── + +(mod-dec-test! + "report-about accessor" + (mod/report-about (mod/mk-report "r1" "a" "bob" "x")) + "bob") +(mod-dec-test! + "report-by accessor" + (mod/report-by (mod/mk-report "r1" "alice" "bob" "x")) + "alice") + +;; ── api registry ── + +(mod/reset!) +(define mod-dec-r1 (mod/report "alice" "bob" "this is spam")) +(define mod-dec-r2 (mod/report "carol" "eve" "fine post")) + +(mod-dec-test! + "mod/report assigns sequential id r1" + (mod/report-id mod-dec-r1) + "r1") +(mod-dec-test! + "mod/report assigns sequential id r2" + (mod/report-id mod-dec-r2) + "r2") +(mod-dec-test! + "mod/decide via registry → hide" + (get (mod/decide "r1") :action) + "hide") +(mod-dec-test! + "mod/decide via registry → keep" + (get (mod/decide "r2") :action) + "keep") +(mod-dec-test! "mod/decide unknown id → nil" (mod/decide "r99") nil) + +(define mod-decide-tests-run! (fn () {:failures mod-dec-failures :total mod-dec-count :passed mod-dec-pass :failed mod-dec-fail})) diff --git a/plans/mod-on-sx.md b/plans/mod-on-sx.md index 30887b50..9f03ed8b 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` → **0/0** (not yet started) +`bash lib/mod/conformance.sh` → **31/31** (Phase 1 complete) ## Ground rules @@ -66,17 +66,19 @@ lib/mod/fed.sx ## Phase 1 — Report representation + simple policy -- [ ] `lib/mod/schema.sx` — `report(id, by, about, reason)`, `evidence(id, kind, val)`, - `policy-action(report, action)` predicates as Prolog facts/rules -- [ ] `lib/mod/policy.sx` — rule declarations: `(defrule action :when conditions)` - desugars to Prolog clause -- [ ] `lib/mod/engine.sx` — `(decide report-id)` runs Prolog query, returns first - matching action -- [ ] `lib/mod/api.sx` — `(mod/report by about reason)`, `(mod/decide id)` -- [ ] `lib/mod/tests/decide.sx` — 15+ cases: spam keyword → hide, repeated reports → - escalate, no rule matches → keep -- [ ] `lib/mod/scoreboard.{json,md}` -- [ ] `lib/mod/conformance.sh` +- [x] `lib/mod/schema.sx` — `report(id, by, about)`, `classification(id, kind)`, + `report_count(subject, n)` Prolog facts; keyword classifier derives evidence +- [x] `lib/mod/policy.sx` — `mod/mk-rule` + ordered `mod/default-rules`; conditions + (`:classification`, `:count-at-least`) compile to Prolog goals; `policy_action/3` + clauses, last clause `true` so every report yields at least `:keep` +- [x] `lib/mod/engine.sx` — `(mod/decide-report r reports rules)` queries + `policy_action(Id, Action, Rule)` with `pl-query-one` (clause order = precedence); + returns a decision dict `{:action :rule :report-id :proof}` carrying the why +- [x] `lib/mod/api.sx` — registry + `(mod/report by about reason)`, `(mod/decide id)` +- [x] `lib/mod/tests/decide.sx` — 31 cases: spam/abuse keyword, repeated→escalate, + no-rule→keep, precedence (spam beats repeated), proof shape, registry ids +- [x] `lib/mod/scoreboard.{json,md}` +- [x] `lib/mod/conformance.sh` ## Phase 2 — Evidence + audit trail @@ -105,8 +107,24 @@ lib/mod/fed.sx ## Progress log -(loop fills this in) +- **Phase 1 complete — 31/31.** Report schema, keyword classifier, policy DSL, + engine, registry api, conformance harness. Decisions are proofs: each carries + `:rule` (matching clause), `:proof {:rule :conditions :evidence :count}`. + Precedence is Prolog clause order resolved by `pl-query-one`; a trailing + `true`-bodied default rule makes "no rule matched" a real `:keep`, not a query + failure. Evidence (spam/abuse classification) derived in SX and asserted as + `classification/2` facts; repeated-report escalation uses a genuine Prolog + join + arithmetic (`report(Id,_,S), report_count(S,N), N >= 3`). + - **Gotcha (env):** loading the prolog libs strips `includes?` (and other + high-level string prims) from the eval env — only the set the prolog + tokenizer itself uses survives (`slice`, `len`, `nth`, `=`, `join`, + `downcase`, `map`, `reduce`, `append!`). Implemented `mod/str-contains?` over + `slice`/`len` rather than relying on `includes?`. Watch for this in later + phases — stick to the blessed primitive set. + - **Liftable (acl-sx watch):** `mod/join-with`, `mod/str-contains?`, `mod/any?`, + and the rule→clause compilation shape are generic rule-engine plumbing. Do not + extract to `lib/guest/` until both mod-sx and acl-sx are past Phase 2. ## Blockers -(loop fills this in) +(none) From 6e825e1283a53a5e00051f3423369f7dd22d7cdd Mon Sep 17 00:00:00 2001 From: giles Date: Sat, 6 Jun 2026 17:37:02 +0000 Subject: [PATCH 03/26] =?UTF-8?q?mod:=20Phase=202=20=E2=80=94=20evidence?= =?UTF-8?q?=20accumulation=20+=20proof=20trees=20+=20audit=20log,=2060/60?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reports carry an :evidence list, asserted as evidence/3 facts; reviewer-remove rule (highest precedence) lets human review override classification. Proof tree built constructively by re-querying each rule body goal against the same DB with the report id bound, so derivations carry real unification bindings. Append-only audit log records decision + proof + evidence snapshot per decide, monotonic seq, never mutates prior entries. +29 audit tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/mod/api.sx | 36 +++++++- lib/mod/audit.sx | 54 +++++++++++ lib/mod/conformance.conf | 2 + lib/mod/engine.sx | 27 +++++- lib/mod/policy.sx | 29 ++++-- lib/mod/schema.sx | 51 +++++++++-- lib/mod/scoreboard.json | 9 +- lib/mod/scoreboard.md | 3 +- lib/mod/tests/audit.sx | 187 +++++++++++++++++++++++++++++++++++++++ plans/mod-on-sx.md | 33 +++++-- 10 files changed, 400 insertions(+), 31 deletions(-) create mode 100644 lib/mod/audit.sx create mode 100644 lib/mod/tests/audit.sx diff --git a/lib/mod/api.sx b/lib/mod/api.sx index 929c39ea..88204426 100644 --- a/lib/mod/api.sx +++ b/lib/mod/api.sx @@ -1,8 +1,9 @@ ;; lib/mod/api.sx — report registry + public entry points. ;; ;; mod/report files a report (assigning a sequential id) into the in-memory -;; registry; mod/decide resolves an id and runs the policy engine against the -;; current registry and rule set. +;; registry; mod/add-evidence accumulates evidence onto a filed report; +;; mod/decide resolves an id, runs the policy engine against the current registry +;; and rule set, and commits the decision to the append-only audit log. (define mod/*reports* (list)) (define mod/*counter* 0) @@ -12,7 +13,10 @@ mod/reset! (fn () - (begin (set! mod/*reports* (list)) (set! mod/*counter* 0)))) + (begin + (set! mod/*reports* (list)) + (set! mod/*counter* 0) + (mod/audit-reset!)))) (define mod/report @@ -35,10 +39,34 @@ nil mod/*reports*))) +(define + mod/add-evidence + (fn + (id kind val) + (let + ((r (mod/get-report id))) + (if + (nil? r) + nil + (let + ((updated (mod/attach-evidence r (mod/mk-evidence kind val)))) + (begin + (set! + mod/*reports* + (map + (fn (x) (if (= (mod/report-id x) id) updated x)) + mod/*reports*)) + updated)))))) + (define mod/decide (fn (id) (let ((r (mod/get-report id))) - (if (nil? r) nil (mod/decide-report r mod/*reports* mod/*rules*))))) + (if + (nil? r) + nil + (let + ((d (mod/decide-report r mod/*reports* mod/*rules*))) + (begin (mod/log-decision! d (mod/report-evidence r)) d)))))) diff --git a/lib/mod/audit.sx b/lib/mod/audit.sx new file mode 100644 index 00000000..2cebc70e --- /dev/null +++ b/lib/mod/audit.sx @@ -0,0 +1,54 @@ +;; lib/mod/audit.sx — append-only decision log. +;; +;; Every decision the api commits is recorded as an immutable audit entry holding +;; the decision (action + matching rule), the proof tree (the derivation that +;; justified it), and a snapshot of the evidence in force at decision time. The +;; log is append-only: entries are never mutated or removed, only appended, each +;; with a monotonic sequence number. Retrieval is by report id (full history) or +;; by sequence. + +(define mod/*audit-log* (list)) +(define mod/*audit-seq* 0) + +(define + mod/audit-reset! + (fn + () + (begin (set! mod/*audit-log* (list)) (set! mod/*audit-seq* 0)))) + +(define mod/mk-audit-entry (fn (seq decision evidence-snapshot) {:action (get decision :action) :evidence evidence-snapshot :proof (get decision :proof) :rule (get decision :rule) :report-id (get decision :report-id) :seq seq})) + +(define + mod/log-decision! + (fn + (decision evidence-snapshot) + (begin + (set! mod/*audit-seq* (+ mod/*audit-seq* 1)) + (let + ((entry (mod/mk-audit-entry mod/*audit-seq* decision evidence-snapshot))) + (begin (append! mod/*audit-log* entry) entry))))) + +;; entries for one report, in chronological (sequence) order +(define + mod/audit + (fn + (id) + (reduce + (fn + (acc e) + (if (= (get e :report-id) id) (append acc (list e)) acc)) + (list) + mod/*audit-log*))) + +(define mod/audit-all (fn () mod/*audit-log*)) +(define mod/audit-count (fn () (len mod/*audit-log*))) + +;; most recent decision logged for a report (nil if none) +(define + mod/audit-latest + (fn + (id) + (reduce + (fn (acc e) (if (= (get e :report-id) id) e acc)) + nil + mod/*audit-log*))) diff --git a/lib/mod/conformance.conf b/lib/mod/conformance.conf index b2c0b751..7752fb8e 100644 --- a/lib/mod/conformance.conf +++ b/lib/mod/conformance.conf @@ -13,9 +13,11 @@ PRELOADS=( lib/mod/schema.sx lib/mod/policy.sx lib/mod/engine.sx + lib/mod/audit.sx lib/mod/api.sx ) SUITES=( "decide:lib/mod/tests/decide.sx:(mod-decide-tests-run!)" + "audit:lib/mod/tests/audit.sx:(mod-audit-tests-run!)" ) diff --git a/lib/mod/engine.sx b/lib/mod/engine.sx index 20e7de5b..866c0232 100644 --- a/lib/mod/engine.sx +++ b/lib/mod/engine.sx @@ -3,8 +3,12 @@ ;; build-program assembles the report's facts plus the compiled policy clauses; ;; decide-report runs the Prolog query and returns a decision. A decision is a ;; proof, not a bare keyword: it carries the matching rule, the conditions it -;; required, the evidence that satisfied them, and the report count — everything -;; Phase 2's audit trail needs to persist a "why". +;; required, the evidence that satisfied them, and a derivation — the proof tree. +;; +;; The proof tree is built constructively: for the matching rule, each body goal +;; is re-queried against the same DB with the report id bound, recording the goal +;; text, whether it was solved, and the bindings that satisfied it. That is a +;; genuine derivation drawn from the Prolog database, ready for the audit trail. (define mod/find-rule @@ -23,6 +27,21 @@ (r count rules) (str (mod/report-facts r count) "\n" (mod/rules->program rules)))) +(define + mod/proof-goals + (fn + (db id conds) + (if + (empty? conds) + (list {:solved true :goal "true" :bindings {}}) + (map + (fn + (c) + (let + ((g (mod/cond->goal c id))) + (let ((sols (pl-query-all db g))) {:solved (if (empty? sols) false true) :goal g :bindings (if (empty? sols) {} (first sols))}))) + conds)))) + (define mod/decide-report (fn @@ -39,7 +58,7 @@ ((sol (pl-query-one db (str "policy_action(" id ", Action, Rule)")))) (if (nil? sol) - {:action "keep" :proof {:evidence kinds :conditions (list) :rule "none" :count count} :report-id id :rule "none"} + {:action "keep" :proof {:goals (list) :evidence kinds :conditions (list) :rule "none" :count count} :report-id id :rule "none"} (let ((rname (dict-get sol "Rule"))) - (let ((rule (mod/find-rule rules rname))) {:action (mod/rule-action rule) :proof {:evidence kinds :conditions (mod/rule-when rule) :rule rname :count count} :report-id id :rule rname}))))))))) + (let ((rule (mod/find-rule rules rname))) {:action (mod/rule-action rule) :proof {:goals (mod/proof-goals db id (mod/rule-when rule)) :evidence kinds :conditions (mod/rule-when rule) :rule rname :count count} :report-id id :rule rname}))))))))) diff --git a/lib/mod/policy.sx b/lib/mod/policy.sx index 2649864e..4c3ab90c 100644 --- a/lib/mod/policy.sx +++ b/lib/mod/policy.sx @@ -5,6 +5,10 @@ ;; pl-query-one, so the first clause that proves wins. The final default rule has ;; an empty body (true) so every report yields at least :keep — "no rule matched" ;; is a real result, not a query failure. +;; +;; cond->goal takes an id-term so the same condition can be compiled with the +;; head variable "Id" (for clause bodies) or a concrete report id (for proof-tree +;; goal-by-goal re-querying in the engine). (define mod/mk-rule (fn (name action conds) {:when conds :name name :action action})) @@ -15,6 +19,9 @@ (define mod/default-rules (list + (mod/mk-rule + "reviewer-remove" + :remove (list (list :evidence "confirmed-abuse"))) (mod/mk-rule "spam-hide" :hide (list (list :classification "spam"))) (mod/mk-rule "abuse-remove" @@ -27,31 +34,41 @@ ;; ── condition → Prolog goal ── ;; ;; (:classification "spam") → classification(Id, spam) +;; (:evidence "kind") → evidence(Id, 'kind', _) ;; (:count-at-least 3) → report(Id, B, S), report_count(S, N), N >= 3 (define mod/cond->goal (fn - (c) + (c idterm) (let ((tag (first c))) (cond ((= tag :classification) - (str "classification(Id, " (nth c 1) ")")) + (str "classification(" idterm ", " (nth c 1) ")")) + ((= tag :evidence) + (str + "evidence(" + idterm + ", " + (mod/pl-quote (nth c 1)) + ", _)")) ((= tag :count-at-least) (str - "report(Id, B, S), report_count(S, N), N >= " + "report(" + idterm + ", B, S), report_count(S, N), N >= " (nth c 1))) (true "true"))))) (define mod/conds->body (fn - (conds) + (conds idterm) (if (empty? conds) "true" - (mod/join-with ", " (map mod/cond->goal conds))))) + (mod/join-with ", " (map (fn (c) (mod/cond->goal c idterm)) conds))))) (define mod/rule->clause @@ -63,7 +80,7 @@ ", '" (mod/rule-name r) "') :- " - (mod/conds->body (mod/rule-when r)) + (mod/conds->body (mod/rule-when r) "Id") "."))) (define diff --git a/lib/mod/schema.sx b/lib/mod/schema.sx index c675a583..5db13a1b 100644 --- a/lib/mod/schema.sx +++ b/lib/mod/schema.sx @@ -1,16 +1,34 @@ ;; lib/mod/schema.sx — report representation + Prolog fact generation. ;; -;; A report is a dict {:id :by :about :reason}. The engine derives evidence -;; (classification kinds) from the reason text, then projects the report and its -;; evidence into Prolog facts that policy clauses can match against. +;; A report is a dict {:id :by :about :reason :evidence}. :evidence is a list of +;; accumulated evidence entries {:kind :val} (human review, automated scanners, +;; etc.). The engine derives keyword classifications from the reason text and +;; projects the report, its classifications, and its accumulated evidence into +;; Prolog facts that policy clauses match against. -(define mod/mk-report (fn (id by about reason) {:id id :by by :about about :reason reason})) +(define mod/mk-report (fn (id by about reason) {:id id :by by :evidence (list) :about about :reason reason})) (define mod/report-id (fn (r) (get r :id))) (define mod/report-by (fn (r) (get r :by))) (define mod/report-about (fn (r) (get r :about))) (define mod/report-reason (fn (r) (get r :reason))) +(define + mod/report-evidence + (fn (r) (let ((e (get r :evidence))) (if (nil? e) (list) e)))) + +(define mod/mk-evidence (fn (kind val) {:val val :kind kind})) +(define mod/evidence-kind (fn (e) (get e :kind))) +(define mod/evidence-val (fn (e) (get e :val))) + +(define mod/with-evidence (fn (r evs) {:id (mod/report-id r) :by (mod/report-by r) :evidence evs :about (mod/report-about r) :reason (mod/report-reason r)})) + +(define + mod/attach-evidence + (fn + (r e) + (mod/with-evidence r (append (mod/report-evidence r) (list e))))) + ;; ── substring search (the prolog-loaded env lacks includes?; slice/len do work) ── (define @@ -102,6 +120,25 @@ "\n" (map (fn (k) (str "classification(" id ", " k ").")) kinds)))) +(define + mod/evidence-facts + (fn + (id evs) + (mod/join-with + "\n" + (map + (fn + (e) + (str + "evidence(" + id + ", " + (mod/pl-quote (mod/evidence-kind e)) + ", " + (mod/pl-quote (str (mod/evidence-val e))) + ").")) + evs)))) + (define mod/report-facts (fn @@ -111,10 +148,12 @@ (by (mod/pl-quote (mod/report-by r))) (about (mod/pl-quote (mod/report-about r)))) (let - ((cls (mod/classification-facts id (mod/classify-keywords r)))) + ((cls (mod/classification-facts id (mod/classify-keywords r))) + (evs (mod/evidence-facts id (mod/report-evidence r)))) (mod/join-with "\n" (list (str "report(" id ", " by ", " about ").") (str "report_count(" about ", " count ").") - cls)))))) + cls + evs)))))) diff --git a/lib/mod/scoreboard.json b/lib/mod/scoreboard.json index d4374712..ce3a15b8 100644 --- a/lib/mod/scoreboard.json +++ b/lib/mod/scoreboard.json @@ -1,10 +1,11 @@ { "lang": "mod", - "total_passed": 31, + "total_passed": 60, "total_failed": 0, - "total": 31, + "total": 60, "suites": [ - {"name":"decide","passed":31,"failed":0,"total":31} + {"name":"decide","passed":31,"failed":0,"total":31}, + {"name":"audit","passed":29,"failed":0,"total":29} ], - "generated": "2026-06-06T17:30:06+00:00" + "generated": "2026-06-06T17:36:32+00:00" } diff --git a/lib/mod/scoreboard.md b/lib/mod/scoreboard.md index 36d7bba3..83cd88b1 100644 --- a/lib/mod/scoreboard.md +++ b/lib/mod/scoreboard.md @@ -1,7 +1,8 @@ # mod scoreboard -**31 / 31 passing** (0 failure(s)). +**60 / 60 passing** (0 failure(s)). | Suite | Passed | Total | Status | |-------|--------|-------|--------| | decide | 31 | 31 | ok | +| audit | 29 | 29 | ok | diff --git a/lib/mod/tests/audit.sx b/lib/mod/tests/audit.sx new file mode 100644 index 00000000..f92b2c50 --- /dev/null +++ b/lib/mod/tests/audit.sx @@ -0,0 +1,187 @@ +;; lib/mod/tests/audit.sx — Phase 2: evidence accumulation + proof tree + audit. + +(define mod-aud-count 0) +(define mod-aud-pass 0) +(define mod-aud-fail 0) +(define mod-aud-failures (list)) + +(define + mod-aud-test! + (fn + (name got expected) + (begin + (set! mod-aud-count (+ mod-aud-count 1)) + (if + (= got expected) + (set! mod-aud-pass (+ mod-aud-pass 1)) + (begin + (set! mod-aud-fail (+ mod-aud-fail 1)) + (append! + mod-aud-failures + (str name "\n expected: " expected "\n got: " got))))))) + +(define + mod-aud-decide1 + (fn (r) (mod/decide-report r (list r) mod/default-rules))) + +;; ── proof tree: keyword classification ── + +(define + mod-aud-spam + (mod-aud-decide1 (mod/mk-report "r1" "alice" "bob" "this is spam"))) +(define mod-aud-spam-goals (get (get mod-aud-spam :proof) :goals)) + +(mod-aud-test! "spam proof has one goal" (len mod-aud-spam-goals) 1) +(mod-aud-test! + "spam proof goal text" + (get (first mod-aud-spam-goals) :goal) + "classification(r1, spam)") +(mod-aud-test! + "spam proof goal solved" + (get (first mod-aud-spam-goals) :solved) + true) + +;; ── proof tree: count rule with real bindings ── + +(define mod-aud-rep-r (mod/mk-report "r3" "ann" "dave" "x")) +(define + mod-aud-rep + (mod/decide-report + mod-aud-rep-r + (list mod-aud-rep-r mod-aud-rep-r mod-aud-rep-r) + mod/default-rules)) +(define mod-aud-rep-goals (get (get mod-aud-rep :proof) :goals)) +(define mod-aud-rep-binds (get (first mod-aud-rep-goals) :bindings)) + +(mod-aud-test! + "count proof goal solved" + (get (first mod-aud-rep-goals) :solved) + true) +(mod-aud-test! "count proof binding N" (dict-get mod-aud-rep-binds "N") "3") +(mod-aud-test! + "count proof binding S (subject)" + (dict-get mod-aud-rep-binds "S") + "dave") + +;; ── proof tree: default keep has a 'true' goal ── + +(define + mod-aud-keep + (mod-aud-decide1 (mod/mk-report "rk" "a" "b" "a fine post"))) +(define mod-aud-keep-goals (get (get mod-aud-keep :proof) :goals)) + +(mod-aud-test! + "keep proof goal text true" + (get (first mod-aud-keep-goals) :goal) + "true") +(mod-aud-test! + "keep proof goal solved" + (get (first mod-aud-keep-goals) :solved) + true) + +;; ── evidence accumulation drives a rule ── + +(define + mod-aud-rev-r + (mod/attach-evidence + (mod/mk-report "re" "a" "carol" "neutral") + (mod/mk-evidence "confirmed-abuse" "human"))) +(define mod-aud-rev (mod-aud-decide1 mod-aud-rev-r)) + +(mod-aud-test! + "evidence has length 1" + (len (mod/report-evidence mod-aud-rev-r)) + 1) +(mod-aud-test! + "evidence reviewer-remove → remove" + (get mod-aud-rev :action) + "remove") +(mod-aud-test! + "evidence reviewer-remove rule" + (get mod-aud-rev :rule) + "reviewer-remove") +(mod-aud-test! + "evidence proof goal solved" + (get (first (get (get mod-aud-rev :proof) :goals)) :solved) + true) +(mod-aud-test! + "no evidence → not reviewer-remove" + (get (mod-aud-decide1 (mod/mk-report "rn" "a" "b" "neutral")) :rule) + "default-keep") + +;; ── append-only audit log via the api ── + +(mod/reset!) +(mod/report "alice" "bob" "this is spam") +(mod/report "carol" "eve" "fine post") +(define mod-aud-d1 (mod/decide "r1")) +(define mod-aud-d2 (mod/decide "r2")) + +(mod-aud-test! "two decisions logged" (mod/audit-count) 2) +(mod-aud-test! + "first entry seq 1" + (get (first (mod/audit-all)) :seq) + 1) +(mod-aud-test! + "audit r1 returns one entry" + (len (mod/audit "r1")) + 1) +(mod-aud-test! + "audit r1 action matches decision" + (get (first (mod/audit "r1")) :action) + (get mod-aud-d1 :action)) +(mod-aud-test! + "audit r1 rule matches decision" + (get (first (mod/audit "r1")) :rule) + "spam-hide") +(mod-aud-test! + "audit r1 entry carries proof goals" + (len (get (get (first (mod/audit "r1")) :proof) :goals)) + 1) +(mod-aud-test! + "audit r2 keep" + (get (first (mod/audit "r2")) :action) + "keep") +(mod-aud-test! "audit unknown report → empty" (mod/audit "r99") (list)) + +;; ── append-only: re-deciding appends, never mutates ── + +(define mod-aud-d1b (mod/decide "r1")) + +(mod-aud-test! "re-decide appends (count 3)" (mod/audit-count) 3) +(mod-aud-test! + "audit r1 now has 2 entries" + (len (mod/audit "r1")) + 2) +(mod-aud-test! + "audit r1 seqs monotonic" + (get (nth (mod/audit "r1") 1) :seq) + 3) +(mod-aud-test! + "audit-latest r1 is seq 3" + (get (mod/audit-latest "r1") :seq) + 3) +(mod-aud-test! + "first r1 entry unchanged (still seq 1)" + (get (first (mod/audit "r1")) :seq) + 1) + +;; ── evidence snapshot captured at decision time ── + +(mod/add-evidence "r2" "confirmed-abuse" "human") +(define mod-aud-d2b (mod/decide "r2")) + +(mod-aud-test! + "post-evidence decision flips to remove" + (get mod-aud-d2b :action) + "remove") +(mod-aud-test! + "audit snapshot records evidence kind" + (mod/evidence-kind (first (get (mod/audit-latest "r2") :evidence))) + "confirmed-abuse") +(mod-aud-test! + "earlier r2 entry had empty evidence snapshot" + (len (get (first (mod/audit "r2")) :evidence)) + 0) + +(define mod-audit-tests-run! (fn () {:failures mod-aud-failures :total mod-aud-count :passed mod-aud-pass :failed mod-aud-fail})) diff --git a/plans/mod-on-sx.md b/plans/mod-on-sx.md index 9f03ed8b..2128b4c2 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` → **31/31** (Phase 1 complete) +`bash lib/mod/conformance.sh` → **60/60** (Phases 1–2 complete) ## Ground rules @@ -82,11 +82,17 @@ lib/mod/fed.sx ## Phase 2 — Evidence + audit trail -- [ ] evidence accumulation — additional facts asserted before query -- [ ] proof tree from Prolog derivation tree -- [ ] `lib/mod/audit.sx` — append-only log (decision + proof + evidence snapshot) -- [ ] `(mod/audit id)` retrieval -- [ ] `lib/mod/tests/audit.sx` — proof correctness, trail completeness +- [x] evidence accumulation — `report :evidence` list; `mod/attach-evidence` + + api `mod/add-evidence`; asserted as `evidence(Id, 'kind', 'val')` facts; + new `:evidence` condition + `reviewer-remove` rule consume it +- [x] proof tree from Prolog derivation — `mod/proof-goals` re-queries each body + goal (id bound) against the same DB, recording goal text, solved?, and the + bindings that satisfied it (e.g. count goal yields N=3, S=subject) +- [x] `lib/mod/audit.sx` — append-only log: monotonic `:seq`, decision + proof + + evidence snapshot; never mutates prior entries +- [x] `(mod/audit id)` retrieval (+ `mod/audit-latest`, `mod/audit-all`, count) +- [x] `lib/mod/tests/audit.sx` — 29 cases: proof goal text/bindings, evidence-driven + decisions, append-only ordering, per-report retrieval, snapshot-at-decision-time ## Phase 3 — Escalation + lifecycle state machine @@ -107,6 +113,21 @@ lib/mod/fed.sx ## Progress log +- **Phase 2 complete — 60/60** (+29 audit). Evidence accumulation, constructive + proof trees, append-only audit log. A decision's `:proof :goals` is a real + derivation: each body goal is re-queried against the same Prolog DB with the + report id bound, so the count rule's proof carries `N=3, S=` straight + from unification — not a reconstruction. Evidence is asserted as + `evidence(Id, 'kind', 'val')`; the new `reviewer-remove` rule (placed first = + highest precedence) lets human review override automated classification. + `mod/decide` now commits each decision to the audit log with the evidence + snapshot in force at decision time. Unknown predicates in this Prolog fail + gracefully (verified) — so an evidence-less report safely falls through the + reviewer rule without an existence error. + - **Liftable (acl-sx watch):** the proof-tree builder (`mod/proof-goals` — + re-query-each-goal) and the append-only log shape are both generic. Both + subsystems are now past Phase 2; next time either touches plumbing, evaluate + lifting `proof-goals` + the audit-log primitives into `lib/guest/`. - **Phase 1 complete — 31/31.** Report schema, keyword classifier, policy DSL, engine, registry api, conformance harness. Decisions are proofs: each carries `:rule` (matching clause), `:proof {:rule :conditions :evidence :count}`. From f4f34c1d331a8d2154bf87eaee86e86e29d963cd Mon Sep 17 00:00:00 2001 From: giles Date: Sat, 6 Jun 2026 17:50:05 +0000 Subject: [PATCH 04/26] =?UTF-8?q?mod:=20Phase=203=20=E2=80=94=20lifecycle?= =?UTF-8?q?=20state=20machine=20+=20escalation=20+=20appeal,=20106/106?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure SX state machine (lib/mod/lifecycle.sx) over the engine: open→triaged→decided→appealed→final, transition table guards illegal moves. Auto-tier resolves terminal actions; escalate parks at human-tier (resolve blocked until review supplies evidence). Appeal re-runs the engine — new exonerated-keep rule at top precedence lets exoneration override a prior hide. Api façade (mod/triage/resolve/review/appeal/finalize) over a case registry, logging committed decisions to the audit trail. +46 escalation tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/mod/api.sx | 103 ++++++++++++- lib/mod/conformance.conf | 2 + lib/mod/lifecycle.sx | 160 +++++++++++++++++++++ lib/mod/policy.sx | 7 + lib/mod/scoreboard.json | 9 +- lib/mod/scoreboard.md | 3 +- lib/mod/tests/escalation.sx | 279 ++++++++++++++++++++++++++++++++++++ plans/mod-on-sx.md | 38 ++++- 8 files changed, 583 insertions(+), 18 deletions(-) create mode 100644 lib/mod/lifecycle.sx create mode 100644 lib/mod/tests/escalation.sx diff --git a/lib/mod/api.sx b/lib/mod/api.sx index 88204426..245d9803 100644 --- a/lib/mod/api.sx +++ b/lib/mod/api.sx @@ -1,11 +1,13 @@ -;; lib/mod/api.sx — report registry + public entry points. +;; lib/mod/api.sx — report registry + lifecycle façade + public entry points. ;; -;; mod/report files a report (assigning a sequential id) into the in-memory -;; registry; mod/add-evidence accumulates evidence onto a filed report; -;; mod/decide resolves an id, runs the policy engine against the current registry -;; and rule set, and commits the decision to the append-only audit log. +;; mod/report files a report (assigning a sequential id) and opens a lifecycle +;; case for it; mod/add-evidence accumulates evidence; mod/decide runs the engine +;; and commits to the audit log. The lifecycle façade (mod/triage, mod/resolve, +;; mod/review, mod/appeal, mod/finalize) drives the per-report case through its +;; states, logging each committed decision to the audit trail. (define mod/*reports* (list)) +(define mod/*cases* (list)) (define mod/*counter* 0) (define mod/*rules* mod/default-rules) @@ -15,6 +17,7 @@ () (begin (set! mod/*reports* (list)) + (set! mod/*cases* (list)) (set! mod/*counter* 0) (mod/audit-reset!)))) @@ -28,7 +31,10 @@ ((id (str "r" mod/*counter*))) (let ((r (mod/mk-report id by about reason))) - (begin (append! mod/*reports* r) r)))))) + (begin + (append! mod/*reports* r) + (append! mod/*cases* {:id id :case (mod/mk-case r)}) + r)))))) (define mod/get-report @@ -70,3 +76,88 @@ (let ((d (mod/decide-report r mod/*reports* mod/*rules*))) (begin (mod/log-decision! d (mod/report-evidence r)) d)))))) + +;; ── lifecycle façade over the case registry ── + +(define + mod/case-of + (fn + (id) + (reduce + (fn (acc rec) (if (= (get rec :id) id) (get rec :case) acc)) + nil + mod/*cases*))) + +(define + mod/case-store! + (fn + (id c) + (set! + mod/*cases* + (map + (fn (rec) (if (= (get rec :id) id) {:id id :case c} rec)) + mod/*cases*)))) + +;; apply a lifecycle op to the stored case, persist it, and (when a decision was +;; committed cleanly) append it to the audit log; returns the updated case +(define + mod/case-apply! + (fn + (id op log?) + (let + ((c (mod/case-of id))) + (if + (nil? c) + nil + (let + ((c2 (op c))) + (begin + (mod/case-store! id c2) + (when + log? + (when + (nil? (mod/case-error c2)) + (let + ((d (mod/case-decision c2))) + (if + (nil? d) + nil + (mod/log-decision! + d + (mod/report-evidence (mod/case-report c2))))))) + c2)))))) + +(define + mod/triage + (fn + (id) + (mod/case-apply! + id + (fn (c) (mod/case-triage c mod/*reports* mod/*rules*)) + false))) + +(define + mod/resolve + (fn (id) (mod/case-apply! id (fn (c) (mod/case-resolve c)) true))) + +(define + mod/review + (fn + (id kind val) + (mod/case-apply! + id + (fn (c) (mod/case-review c kind val mod/*reports* mod/*rules*)) + true))) + +(define + mod/appeal + (fn + (id kind val) + (mod/case-apply! + id + (fn (c) (mod/case-appeal c kind val mod/*reports* mod/*rules*)) + true))) + +(define + mod/finalize + (fn (id) (mod/case-apply! id (fn (c) (mod/case-finalize c)) false))) diff --git a/lib/mod/conformance.conf b/lib/mod/conformance.conf index 7752fb8e..5e5e0921 100644 --- a/lib/mod/conformance.conf +++ b/lib/mod/conformance.conf @@ -13,6 +13,7 @@ PRELOADS=( lib/mod/schema.sx lib/mod/policy.sx lib/mod/engine.sx + lib/mod/lifecycle.sx lib/mod/audit.sx lib/mod/api.sx ) @@ -20,4 +21,5 @@ PRELOADS=( SUITES=( "decide:lib/mod/tests/decide.sx:(mod-decide-tests-run!)" "audit:lib/mod/tests/audit.sx:(mod-audit-tests-run!)" + "escalation:lib/mod/tests/escalation.sx:(mod-escalation-tests-run!)" ) diff --git a/lib/mod/lifecycle.sx b/lib/mod/lifecycle.sx new file mode 100644 index 00000000..d73601ed --- /dev/null +++ b/lib/mod/lifecycle.sx @@ -0,0 +1,160 @@ +;; lib/mod/lifecycle.sx — report lifecycle state machine (pure SX over the engine). +;; +;; Lifecycle state is deliberately separate from policy: the Prolog rules answer +;; "what action?", this module answers "where in the process is this report?". +;; +;; :open ──triage──▶ :triaged ──resolve/review──▶ :decided ──appeal──▶ :appealed +;; │ │ +;; └────finalize───▶ :final ◀┘ +;; +;; A case is an immutable value {:report :state :decision :tier :error :history}. +;; Every transition returns a NEW case; illegal transitions return the case +;; unchanged with :error set. Tiers: triage runs the engine (auto-tier); a +;; terminal action (hide/remove/keep) resolves immediately, an :escalate action +;; flags the case for human review (human-tier) before it can be resolved. + +(define mod/case* (fn (report state decision tier err history) {:history history :state state :report report :error err :tier tier :decision decision})) + +(define + mod/mk-case + (fn (report) (mod/case* report "open" nil nil nil (list)))) + +(define mod/case-report (fn (c) (get c :report))) +(define mod/case-state (fn (c) (get c :state))) +(define mod/case-decision (fn (c) (get c :decision))) +(define mod/case-tier (fn (c) (get c :tier))) +(define mod/case-error (fn (c) (get c :error))) +(define mod/case-history (fn (c) (get c :history))) + +;; ── transition table ── + +(define mod/lc-transitions {:final (list) :appealed (list "final") :decided (list "appealed" "final") :open (list "triaged") :triaged (list "decided")}) + +(define mod/member? (fn (x lst) (mod/any? (fn (y) (= y x)) lst))) + +(define + mod/lc-can-transition? + (fn + (from to) + (let + ((outs (get mod/lc-transitions from))) + (if (nil? outs) false (mod/member? to outs))))) + +;; ── core transition: validate, record history, or flag :error ── + +(define + mod/case-goto + (fn + (c to note report decision tier) + (let + ((from (mod/case-state c))) + (if + (mod/lc-can-transition? from to) + (mod/case* + report + to + decision + tier + nil + (append (mod/case-history c) (list {:note note :to to :from from}))) + (mod/case* + (mod/case-report c) + from + (mod/case-decision c) + (mod/case-tier c) + (str "illegal transition: " from " -> " to) + (mod/case-history c)))))) + +(define + mod/case-error-set + (fn + (c msg) + (mod/case* + (mod/case-report c) + (mod/case-state c) + (mod/case-decision c) + (mod/case-tier c) + msg + (mod/case-history c)))) + +;; ── lifecycle operations ── + +;; :open → :triaged — run the auto-tier first pass. +(define + mod/case-triage + (fn + (c reports rules) + (let + ((d (mod/decide-report (mod/case-report c) reports rules))) + (let + ((tier (if (= (get d :action) "escalate") "human" "auto"))) + (mod/case-goto + c + "triaged" + "auto-tier first pass" + (mod/case-report c) + d + tier))))) + +;; :triaged → :decided — auto-tier resolves; human-tier is blocked until review. +(define + mod/case-resolve + (fn + (c) + (if + (= (mod/case-tier c) "human") + (mod/case-error-set c "awaiting human review (escalated)") + (mod/case-goto + c + "decided" + "auto-tier resolved" + (mod/case-report c) + (mod/case-decision c) + (mod/case-tier c))))) + +;; :triaged → :decided — human review: attach evidence, re-decide, resolve. +(define + mod/case-review + (fn + (c kind val reports rules) + (let + ((nr (mod/attach-evidence (mod/case-report c) (mod/mk-evidence kind val)))) + (let + ((d (mod/decide-report nr reports rules))) + (mod/case-goto c "decided" (str "human review: " kind) nr d "human"))))) + +;; :decided → :appealed — appeal: attach evidence, re-decide (may override). +(define + mod/case-appeal + (fn + (c kind val reports rules) + (let + ((nr (mod/attach-evidence (mod/case-report c) (mod/mk-evidence kind val)))) + (let + ((d (mod/decide-report nr reports rules))) + (mod/case-goto + c + "appealed" + (str "appeal: " kind) + nr + d + (mod/case-tier c)))))) + +;; :decided | :appealed → :final +(define + mod/case-finalize + (fn + (c) + (mod/case-goto + c + "final" + "finalized" + (mod/case-report c) + (mod/case-decision c) + (mod/case-tier c)))) + +(define + mod/case-action + (fn + (c) + (let ((d (mod/case-decision c))) (if (nil? d) nil (get d :action))))) diff --git a/lib/mod/policy.sx b/lib/mod/policy.sx index 4c3ab90c..804e59f8 100644 --- a/lib/mod/policy.sx +++ b/lib/mod/policy.sx @@ -9,6 +9,10 @@ ;; cond->goal takes an id-term so the same condition can be compiled with the ;; head variable "Id" (for clause bodies) or a concrete report id (for proof-tree ;; goal-by-goal re-querying in the engine). +;; +;; Precedence (top wins): exoneration evidence (appeal override) > confirmed-abuse +;; evidence (human review) > spam/abuse classification > repeated-report count > +;; default keep. (define mod/mk-rule (fn (name action conds) {:when conds :name name :action action})) @@ -19,6 +23,9 @@ (define mod/default-rules (list + (mod/mk-rule + "exonerated-keep" + :keep (list (list :evidence "exonerated"))) (mod/mk-rule "reviewer-remove" :remove (list (list :evidence "confirmed-abuse"))) diff --git a/lib/mod/scoreboard.json b/lib/mod/scoreboard.json index ce3a15b8..50d894b5 100644 --- a/lib/mod/scoreboard.json +++ b/lib/mod/scoreboard.json @@ -1,11 +1,12 @@ { "lang": "mod", - "total_passed": 60, + "total_passed": 106, "total_failed": 0, - "total": 60, + "total": 106, "suites": [ {"name":"decide","passed":31,"failed":0,"total":31}, - {"name":"audit","passed":29,"failed":0,"total":29} + {"name":"audit","passed":29,"failed":0,"total":29}, + {"name":"escalation","passed":46,"failed":0,"total":46} ], - "generated": "2026-06-06T17:36:32+00:00" + "generated": "2026-06-06T17:49:32+00:00" } diff --git a/lib/mod/scoreboard.md b/lib/mod/scoreboard.md index 83cd88b1..c063d904 100644 --- a/lib/mod/scoreboard.md +++ b/lib/mod/scoreboard.md @@ -1,8 +1,9 @@ # mod scoreboard -**60 / 60 passing** (0 failure(s)). +**106 / 106 passing** (0 failure(s)). | Suite | Passed | Total | Status | |-------|--------|-------|--------| | decide | 31 | 31 | ok | | audit | 29 | 29 | ok | +| escalation | 46 | 46 | ok | diff --git a/lib/mod/tests/escalation.sx b/lib/mod/tests/escalation.sx new file mode 100644 index 00000000..5d6e442e --- /dev/null +++ b/lib/mod/tests/escalation.sx @@ -0,0 +1,279 @@ +;; lib/mod/tests/escalation.sx — Phase 3: lifecycle state machine + escalation. + +(define mod-esc-count 0) +(define mod-esc-pass 0) +(define mod-esc-fail 0) +(define mod-esc-failures (list)) + +(define + mod-esc-test! + (fn + (name got expected) + (begin + (set! mod-esc-count (+ mod-esc-count 1)) + (if + (= got expected) + (set! mod-esc-pass (+ mod-esc-pass 1)) + (begin + (set! mod-esc-fail (+ mod-esc-fail 1)) + (append! + mod-esc-failures + (str name "\n expected: " expected "\n got: " got))))))) + +;; ── transition table guard ── + +(mod-esc-test! + "open → triaged allowed" + (mod/lc-can-transition? "open" "triaged") + true) +(mod-esc-test! + "triaged → decided allowed" + (mod/lc-can-transition? "triaged" "decided") + true) +(mod-esc-test! + "decided → appealed allowed" + (mod/lc-can-transition? "decided" "appealed") + true) +(mod-esc-test! + "appealed → final allowed" + (mod/lc-can-transition? "appealed" "final") + true) +(mod-esc-test! + "open → decided rejected" + (mod/lc-can-transition? "open" "decided") + false) +(mod-esc-test! + "triaged → final rejected" + (mod/lc-can-transition? "triaged" "final") + false) +(mod-esc-test! + "final is terminal" + (mod/lc-can-transition? "final" "open") + false) + +;; ── initial state ── + +(define + mod-esc-c0 + (mod/mk-case (mod/mk-report "r1" "alice" "bob" "this is spam"))) +(mod-esc-test! "new case is open" (mod/case-state mod-esc-c0) "open") +(mod-esc-test! "new case has no decision" (mod/case-decision mod-esc-c0) nil) + +;; ── auto-tier: spam triages + resolves to decided/hide ── + +(define + mod-esc-spam-rep + (list (mod/mk-report "r1" "alice" "bob" "this is spam"))) +(define + mod-esc-t1 + (mod/case-triage mod-esc-c0 mod-esc-spam-rep mod/default-rules)) +(mod-esc-test! "spam triaged" (mod/case-state mod-esc-t1) "triaged") +(mod-esc-test! "spam triage tier auto" (mod/case-tier mod-esc-t1) "auto") +(mod-esc-test! "spam triage action hide" (mod/case-action mod-esc-t1) "hide") + +(define mod-esc-r1 (mod/case-resolve mod-esc-t1)) +(mod-esc-test! + "auto resolve → decided" + (mod/case-state mod-esc-r1) + "decided") +(mod-esc-test! + "decision preserved through resolve" + (mod/case-action mod-esc-r1) + "hide") + +;; ── illegal transition flags :error, leaves state ── + +(define mod-esc-bad (mod/case-finalize mod-esc-c0)) +(mod-esc-test! + "finalize from open is illegal" + (mod/case-state mod-esc-bad) + "open") +(mod-esc-test! + "illegal transition sets error" + (nil? (mod/case-error mod-esc-bad)) + false) + +;; ── human-tier: repeated report escalates, resolve blocked, review decides ── + +(define mod-esc-rep-r (mod/mk-report "r3" "ann" "dave" "off-topic")) +(define mod-esc-rep-reports (list mod-esc-rep-r mod-esc-rep-r mod-esc-rep-r)) +(define mod-esc-rep-c0 (mod/mk-case mod-esc-rep-r)) +(define + mod-esc-rep-t + (mod/case-triage mod-esc-rep-c0 mod-esc-rep-reports mod/default-rules)) + +(mod-esc-test! + "repeated triage action escalate" + (mod/case-action mod-esc-rep-t) + "escalate") +(mod-esc-test! + "repeated triage tier human" + (mod/case-tier mod-esc-rep-t) + "human") +(mod-esc-test! + "repeated still triaged after triage" + (mod/case-state mod-esc-rep-t) + "triaged") + +(define mod-esc-rep-block (mod/case-resolve mod-esc-rep-t)) +(mod-esc-test! + "auto-resolve blocked on human tier (state unchanged)" + (mod/case-state mod-esc-rep-block) + "triaged") +(mod-esc-test! + "blocked resolve sets error" + (nil? (mod/case-error mod-esc-rep-block)) + false) + +(define + mod-esc-rep-rev + (mod/case-review + mod-esc-rep-t + "confirmed-abuse" + "human" + mod-esc-rep-reports + mod/default-rules)) +(mod-esc-test! + "human review → decided" + (mod/case-state mod-esc-rep-rev) + "decided") +(mod-esc-test! + "human review action remove" + (mod/case-action mod-esc-rep-rev) + "remove") +(mod-esc-test! + "review attached evidence to report" + (len (mod/report-evidence (mod/case-report mod-esc-rep-rev))) + 1) + +(define mod-esc-rep-final (mod/case-finalize mod-esc-rep-rev)) +(mod-esc-test! + "review case finalizes" + (mod/case-state mod-esc-rep-final) + "final") + +;; ── appeal overrides a prior decision ── + +(define + mod-esc-ap-c0 + (mod/mk-case (mod/mk-report "r5" "u" "v" "buy now spam"))) +(define mod-esc-ap-rep (list (mod/mk-report "r5" "u" "v" "buy now spam"))) +(define + mod-esc-ap-t + (mod/case-triage mod-esc-ap-c0 mod-esc-ap-rep mod/default-rules)) +(define mod-esc-ap-d (mod/case-resolve mod-esc-ap-t)) + +(mod-esc-test! + "appeal precondition decided/hide" + (mod/case-action mod-esc-ap-d) + "hide") + +(define + mod-esc-ap-appealed + (mod/case-appeal + mod-esc-ap-d + "exonerated" + "moderator" + mod-esc-ap-rep + mod/default-rules)) +(mod-esc-test! + "appeal → appealed state" + (mod/case-state mod-esc-ap-appealed) + "appealed") +(mod-esc-test! + "appeal overrides hide → keep" + (mod/case-action mod-esc-ap-appealed) + "keep") +(mod-esc-test! + "appeal recorded via exonerated-keep rule" + (get (mod/case-decision mod-esc-ap-appealed) :rule) + "exonerated-keep") + +(define mod-esc-ap-final (mod/case-finalize mod-esc-ap-appealed)) +(mod-esc-test! "appealed → final" (mod/case-state mod-esc-ap-final) "final") + +;; ── history records the full traversal ── + +(mod-esc-test! + "full lifecycle history length 4 (triage,resolve,appeal,finalize)" + (len (mod/case-history mod-esc-ap-final)) + 4) +(mod-esc-test! + "first history step open→triaged" + (get (first (mod/case-history mod-esc-ap-final)) :to) + "triaged") +(mod-esc-test! + "last history step → final" + (get (nth (mod/case-history mod-esc-ap-final) 3) :to) + "final") + +;; ── api-level lifecycle façade ── + +(mod/reset!) +(mod/report "alice" "bob" "this is spam") +(mod/report "carol" "dave" "off-topic") +(mod/report "carol" "dave" "off-topic") +(mod/report "carol" "dave" "off-topic") + +(mod-esc-test! + "api: case opens at open" + (mod/case-state (mod/case-of "r1")) + "open") + +(define mod-esc-api-t1 (mod/triage "r1")) +(mod-esc-test! + "api: triage spam → triaged" + (mod/case-state mod-esc-api-t1) + "triaged") +(mod-esc-test! + "api: triage spam action hide" + (mod/case-action mod-esc-api-t1) + "hide") + +(define mod-esc-api-r1 (mod/resolve "r1")) +(mod-esc-test! + "api: resolve → decided" + (mod/case-state mod-esc-api-r1) + "decided") +(mod-esc-test! + "api: resolve logged decision" + (len (mod/audit "r1")) + 1) + +(define mod-esc-api-app (mod/appeal "r1" "exonerated" "mod")) +(mod-esc-test! + "api: appeal → appealed" + (mod/case-state mod-esc-api-app) + "appealed") +(mod-esc-test! + "api: appeal overrides → keep" + (mod/case-action mod-esc-api-app) + "keep") +(mod-esc-test! + "api: appeal logged second decision" + (len (mod/audit "r1")) + 2) +(mod-esc-test! + "api: finalize → final" + (mod/case-state (mod/finalize "r1")) + "final") + +;; r4 is the 3rd report about dave → escalates via the human tier +(define mod-esc-api-t4 (mod/triage "r4")) +(mod-esc-test! + "api: repeated triage escalates (human tier)" + (mod/case-tier mod-esc-api-t4) + "human") +(define mod-esc-api-blk (mod/resolve "r4")) +(mod-esc-test! + "api: escalated resolve blocked" + (mod/case-state mod-esc-api-blk) + "triaged") +(define mod-esc-api-rev (mod/review "r4" "confirmed-abuse" "human")) +(mod-esc-test! + "api: review → decided/remove" + (mod/case-action mod-esc-api-rev) + "remove") +(mod-esc-test! "api: unknown id → nil" (mod/triage "r99") nil) + +(define mod-escalation-tests-run! (fn () {:failures mod-esc-failures :total mod-esc-count :passed mod-esc-pass :failed mod-esc-fail})) diff --git a/plans/mod-on-sx.md b/plans/mod-on-sx.md index 2128b4c2..01821c18 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` → **60/60** (Phases 1–2 complete) +`bash lib/mod/conformance.sh` → **106/106** (Phases 1–3 complete) ## Ground rules @@ -96,12 +96,20 @@ lib/mod/fed.sx ## Phase 3 — Escalation + lifecycle state machine -- [ ] state machine: `:open → :triaged → :decided → :appealed → :final` -- [ ] auto-tier: first-pass rules decide quick cases -- [ ] human-tier: rules that emit `:escalate` move to next state -- [ ] appeal: re-runs with appeal evidence, may override prior decision -- [ ] `(mod/appeal id new-evidence)` API -- [ ] `lib/mod/tests/escalation.sx` — full lifecycle traversal cases +- [x] state machine: `lib/mod/lifecycle.sx` — `:open → :triaged → :decided → + :appealed → :final` as a pure SX module over the engine; transition table guards + illegal moves (sets `:error`, leaves state); immutable cases with `:history` +- [x] auto-tier: `mod/case-triage` runs the engine; terminal action (hide/remove/ + keep) → tier `auto`, `mod/case-resolve` advances to `:decided` +- [x] human-tier: `:escalate` action → tier `human`; `mod/case-resolve` is blocked + (sets `:error`); `mod/case-review` attaches evidence, re-decides, advances +- [x] appeal: `mod/case-appeal` attaches appeal evidence + re-runs the engine; new + `exonerated-keep` rule (top precedence) lets exoneration override a prior `:hide` +- [x] `(mod/appeal id new-evidence)` API — lifecycle façade over a case registry in + api.sx (`mod/triage` / `resolve` / `review` / `appeal` / `finalize`), logging + each committed decision to the audit trail +- [x] `lib/mod/tests/escalation.sx` — 46 cases: transition guards, auto/human tiers, + blocked resolve, full appeal-override traversal, history, api façade ## Phase 4 — Federation @@ -113,6 +121,22 @@ lib/mod/fed.sx ## Progress log +- **Phase 3 complete — 106/106** (+46 escalation). Lifecycle state machine, + auto/human tiers, appeal-override, and an api façade. The state machine is a + pure SX module (`lib/mod/lifecycle.sx`) over the engine — policy stays in + Prolog, lifecycle stays out of it, per the design constraint. Cases are + immutable values threaded through transitions; illegal moves set `:error` + rather than throwing (the env's error handling is untested, so this keeps tests + deterministic). Tier logic: triage runs the engine, an `:escalate` action parks + the case at the human tier where `mod/case-resolve` is blocked until + `mod/case-review` supplies evidence. Appeal-override works because the new + `exonerated-keep` rule sits at top precedence — appeal evidence re-runs the same + engine and a higher-precedence clause wins. The api façade (`mod/triage` … + `mod/finalize`) keeps a per-report case registry and logs each committed + decision to the Phase-2 audit trail, so lifecycle + audit compose. + - **Gotcha:** `sx_insert_near` inserts only the FIRST top-level form of a + multi-form source — silently drops the rest (byte count barely changes). For + multi-form additions, rewrite the file with `sx_write_file`. - **Phase 2 complete — 60/60** (+29 audit). Evidence accumulation, constructive proof trees, append-only audit log. A decision's `:proof :goals` is a real derivation: each body goal is re-queried against the same Prolog DB with the From ee9851c063536be3dd67d1424a7cea9b90b0abc5 Mon Sep 17 00:00:00 2001 From: giles Date: Sat, 6 Jun 2026 17:54:37 +0000 Subject: [PATCH 05/26] =?UTF-8?q?mod:=20Phase=204=20=E2=80=94=20federation?= =?UTF-8?q?=20(trust,=20sharing,=20revocation),=20132/132=20=E2=80=94=20ro?= =?UTF-8?q?admap=20done?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cross-instance reports ingest into the local registry with origin tags; the engine decides them unchanged. Decision sharing pushes to a mock fed-sx outbox (mod/fed-send! is the transport seam). Trust is advisory by default: a peer's decision binds locally only under (mod/trusted? peer :mod), else it lands in the advisory log unapplied. Revocation composes with the Phase-2 proof model — fed-revoke-if-invalidated re-runs the engine and undoes moderation only when the action no longer holds (exoneration flips hide→keep → revoked + origin notified). +26 fed tests. Full mod-on-sx roadmap complete. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/mod/conformance.conf | 2 + lib/mod/fed.sx | 145 ++++++++++++++++++++++++++++++++++++ lib/mod/scoreboard.json | 9 ++- lib/mod/scoreboard.md | 3 +- lib/mod/tests/fed.sx | 154 +++++++++++++++++++++++++++++++++++++++ plans/mod-on-sx.md | 35 +++++++-- 6 files changed, 337 insertions(+), 11 deletions(-) create mode 100644 lib/mod/fed.sx create mode 100644 lib/mod/tests/fed.sx diff --git a/lib/mod/conformance.conf b/lib/mod/conformance.conf index 5e5e0921..dc704baa 100644 --- a/lib/mod/conformance.conf +++ b/lib/mod/conformance.conf @@ -16,10 +16,12 @@ PRELOADS=( lib/mod/lifecycle.sx lib/mod/audit.sx lib/mod/api.sx + lib/mod/fed.sx ) SUITES=( "decide:lib/mod/tests/decide.sx:(mod-decide-tests-run!)" "audit:lib/mod/tests/audit.sx:(mod-audit-tests-run!)" "escalation:lib/mod/tests/escalation.sx:(mod-escalation-tests-run!)" + "fed:lib/mod/tests/fed.sx:(mod-fed-tests-run!)" ) diff --git a/lib/mod/fed.sx b/lib/mod/fed.sx new file mode 100644 index 00000000..855f71f8 --- /dev/null +++ b/lib/mod/fed.sx @@ -0,0 +1,145 @@ +;; lib/mod/fed.sx — federation: cross-instance reports, decision sharing, trust, +;; revocation. fed-sx itself is mocked here (an in-memory outbox); the real wire +;; transport would replace mod/fed-send!. +;; +;; Trust is advisory by default (the hard rule): a peer's decision only binds +;; locally when (mod/trusted? peer :mod) holds. An untrusted peer's decision is +;; recorded as a suggestion in the advisory log and is NOT applied. Local +;; decisions propagate outward via the outbox. Revocation undoes a locally +;; applied action when its proof is invalidated, notifying the origin peer. + +(define mod/*fed-trust* (list)) ;; {:peer :scope} +(define mod/*fed-outbox* (list)) ;; {:to :type :payload} +(define mod/*fed-advisory* (list)) ;; {:peer :decision} — received, not applied +(define mod/*fed-applied* (list)) ;; {:report-id :action :origin :revoked} +(define mod/*fed-origins* (list)) ;; {:id :origin} + +(define + mod/fed-reset! + (fn + () + (begin + (set! mod/*fed-trust* (list)) + (set! mod/*fed-outbox* (list)) + (set! mod/*fed-advisory* (list)) + (set! mod/*fed-applied* (list)) + (set! mod/*fed-origins* (list))))) + +;; ── trust model ── + +(define + mod/trust-match? + (fn + (t peer scope) + (if (= (get t :peer) peer) (= (get t :scope) scope) false))) + +(define + mod/grant-trust + (fn (peer scope) (begin (append! mod/*fed-trust* {:scope scope :peer peer}) true))) + +(define + mod/revoke-trust + (fn + (peer scope) + (set! + mod/*fed-trust* + (reduce + (fn + (acc t) + (if (mod/trust-match? t peer scope) acc (append acc (list t)))) + (list) + mod/*fed-trust*)))) + +(define + mod/trusted? + (fn + (peer scope) + (mod/any? (fn (t) (mod/trust-match? t peer scope)) mod/*fed-trust*))) + +;; ── cross-instance reports ── + +(define + mod/fed-receive-report + (fn + (peer by about reason) + (let + ((r (mod/report by about reason))) + (begin (append! mod/*fed-origins* {:id (mod/report-id r) :origin peer}) r)))) + +(define + mod/report-origin + (fn + (id) + (reduce + (fn (acc o) (if (= (get o :id) id) (get o :origin) acc)) + "local" + mod/*fed-origins*))) + +;; ── decision sharing (mock fed-sx send) ── + +(define + mod/fed-send! + (fn (to type payload) (begin (append! mod/*fed-outbox* {:type type :to to :payload payload}) true))) + +(define mod/fed-outbox (fn () mod/*fed-outbox*)) + +(define + mod/fed-share-decision + (fn + (decision peers) + (reduce + (fn + (acc p) + (begin (mod/fed-send! p "decision" decision) (append acc (list p)))) + (list) + peers))) + +;; ── receiving a peer's decision (advisory unless trusted) ── + +(define + mod/fed-applied-action + (fn + (report-id) + (reduce + (fn (acc a) (if (= (get a :report-id) report-id) a acc)) + nil + mod/*fed-applied*))) + +(define + mod/fed-receive-decision + (fn + (peer decision) + (if + (mod/trusted? peer :mod) + (begin (append! mod/*fed-applied* {:revoked false :action (get decision :action) :report-id (get decision :report-id) :origin peer}) {:advisory false :peer peer :applied true :decision decision}) + (begin (append! mod/*fed-advisory* {:peer peer :decision decision}) {:advisory true :peer peer :applied false :decision decision})))) + +;; ── revocation ── + +(define + mod/fed-revoke! + (fn + (report-id reason) + (begin + (set! + mod/*fed-applied* + (map + (fn (a) (if (= (get a :report-id) report-id) {:revoked true :action (get a :action) :report-id (get a :report-id) :origin (get a :origin)} a)) + mod/*fed-applied*)) + (mod/fed-send! (mod/report-origin report-id) "revocation" {:report-id report-id :reason reason}) + report-id))) + +;; re-run the engine; if the action no longer holds, the prior decision's proof +;; is invalidated — revoke the applied moderation. +(define + mod/fed-revoke-if-invalidated + (fn + (report decision reports rules) + (let + ((d2 (mod/decide-report report reports rules))) + (if + (= (get d2 :action) (get decision :action)) + {:revoked false :decision d2} + (begin + (mod/fed-revoke! (get decision :report-id) "proof invalidated") + {:revoked true :decision d2}))))) diff --git a/lib/mod/scoreboard.json b/lib/mod/scoreboard.json index 50d894b5..3ebb8e38 100644 --- a/lib/mod/scoreboard.json +++ b/lib/mod/scoreboard.json @@ -1,12 +1,13 @@ { "lang": "mod", - "total_passed": 106, + "total_passed": 132, "total_failed": 0, - "total": 106, + "total": 132, "suites": [ {"name":"decide","passed":31,"failed":0,"total":31}, {"name":"audit","passed":29,"failed":0,"total":29}, - {"name":"escalation","passed":46,"failed":0,"total":46} + {"name":"escalation","passed":46,"failed":0,"total":46}, + {"name":"fed","passed":26,"failed":0,"total":26} ], - "generated": "2026-06-06T17:49:32+00:00" + "generated": "2026-06-06T17:54:02+00:00" } diff --git a/lib/mod/scoreboard.md b/lib/mod/scoreboard.md index c063d904..108e0273 100644 --- a/lib/mod/scoreboard.md +++ b/lib/mod/scoreboard.md @@ -1,9 +1,10 @@ # mod scoreboard -**106 / 106 passing** (0 failure(s)). +**132 / 132 passing** (0 failure(s)). | Suite | Passed | Total | Status | |-------|--------|-------|--------| | decide | 31 | 31 | ok | | audit | 29 | 29 | ok | | escalation | 46 | 46 | ok | +| fed | 26 | 26 | ok | diff --git a/lib/mod/tests/fed.sx b/lib/mod/tests/fed.sx new file mode 100644 index 00000000..0dc73bd2 --- /dev/null +++ b/lib/mod/tests/fed.sx @@ -0,0 +1,154 @@ +;; lib/mod/tests/fed.sx — Phase 4: federation (mock fed-sx). + +(define mod-fed-count 0) +(define mod-fed-pass 0) +(define mod-fed-fail 0) +(define mod-fed-failures (list)) + +(define + mod-fed-test! + (fn + (name got expected) + (begin + (set! mod-fed-count (+ mod-fed-count 1)) + (if + (= got expected) + (set! mod-fed-pass (+ mod-fed-pass 1)) + (begin + (set! mod-fed-fail (+ mod-fed-fail 1)) + (append! + mod-fed-failures + (str name "\n expected: " expected "\n got: " got))))))) + +(mod/reset!) +(mod/fed-reset!) + +;; ── trust model (advisory by default) ── + +(mod-fed-test! "trust initially false" (mod/trusted? "peerA" :mod) false) +(mod/grant-trust "peerA" :mod) +(mod-fed-test! "trust after grant" (mod/trusted? "peerA" :mod) true) +(mod-fed-test! "trust wrong scope" (mod/trusted? "peerA" :other) false) +(mod-fed-test! "trust other peer" (mod/trusted? "peerB" :mod) false) +(mod/revoke-trust "peerA" :mod) +(mod-fed-test! "trust after revoke" (mod/trusted? "peerA" :mod) false) + +;; ── cross-instance reports ── + +(define + mod-fed-fr + (mod/fed-receive-report "peerB" "alice" "bob" "this is spam")) +(mod-fed-test! "fed report assigned id r1" (mod/report-id mod-fed-fr) "r1") +(mod-fed-test! "fed report origin is peer" (mod/report-origin "r1") "peerB") +(define mod-fed-local (mod/report "carol" "dave" "fine post")) +(mod-fed-test! + "local report origin is local" + (mod/report-origin (mod/report-id mod-fed-local)) + "local") +(mod-fed-test! + "engine decides fed report (spam → hide)" + (get + (mod/decide-report mod-fed-fr (list mod-fed-fr) mod/default-rules) + :action) + "hide") + +;; ── decision sharing (outbox) ── + +(define mod-fed-dec {:action "hide" :rule "spam-hide" :report-id "r1"}) +(define + mod-fed-shared + (mod/fed-share-decision mod-fed-dec (list "peerB" "peerC"))) +(mod-fed-test! "share returns notified peers" (len mod-fed-shared) 2) +(mod-fed-test! "outbox has two messages" (len (mod/fed-outbox)) 2) +(mod-fed-test! + "outbox message type decision" + (get (first (mod/fed-outbox)) :type) + "decision") +(mod-fed-test! + "outbox message addressed to peer" + (get (first (mod/fed-outbox)) :to) + "peerB") + +;; ── receiving a peer decision: advisory unless trusted ── + +(define mod-fed-untrusted (mod/fed-receive-decision "peerZ" {:action "remove" :rule "reviewer-remove" :report-id "rx"})) +(mod-fed-test! + "untrusted decision not applied" + (get mod-fed-untrusted :applied) + false) +(mod-fed-test! + "untrusted decision advisory" + (get mod-fed-untrusted :advisory) + true) +(mod-fed-test! + "untrusted decision absent from applied log" + (mod/fed-applied-action "rx") + nil) +(mod-fed-test! + "advisory log records suggestion" + (len mod/*fed-advisory*) + 1) + +(mod/grant-trust "peerT" :mod) +(define mod-fed-trusted (mod/fed-receive-decision "peerT" {:action "hide" :rule "spam-hide" :report-id "ry"})) +(mod-fed-test! "trusted decision applied" (get mod-fed-trusted :applied) true) +(mod-fed-test! + "trusted decision binds locally" + (get (mod/fed-applied-action "ry") :action) + "hide") + +;; ── revocation ── + +(mod-fed-test! + "applied action not yet revoked" + (get (mod/fed-applied-action "ry") :revoked) + false) +(mod/fed-revoke! "ry" "manual") +(mod-fed-test! + "revoke marks applied action revoked" + (get (mod/fed-applied-action "ry") :revoked) + true) +(mod-fed-test! + "revoke emits a revocation message" + (mod/any? (fn (m) (= (get m :type) "revocation")) (mod/fed-outbox)) + true) + +;; revoke-if-invalidated: proof still holds → no revocation +(define mod-fed-spam-r (mod/mk-report "rs" "a" "b" "this is spam")) +(define + mod-fed-spam-d + (mod/decide-report mod-fed-spam-r (list mod-fed-spam-r) mod/default-rules)) +(mod-fed-test! "spam decision is hide" (get mod-fed-spam-d :action) "hide") +(define + mod-fed-rev-same + (mod/fed-revoke-if-invalidated + mod-fed-spam-r + mod-fed-spam-d + (list mod-fed-spam-r) + mod/default-rules)) +(mod-fed-test! + "valid proof → not revoked" + (get mod-fed-rev-same :revoked) + false) + +;; exoneration invalidates the proof → revocation +(define + mod-fed-exon-r + (mod/attach-evidence mod-fed-spam-r (mod/mk-evidence "exonerated" "mod"))) +(define + mod-fed-rev-inv + (mod/fed-revoke-if-invalidated + mod-fed-exon-r + mod-fed-spam-d + (list mod-fed-exon-r) + mod/default-rules)) +(mod-fed-test! + "invalidated proof → revoked" + (get mod-fed-rev-inv :revoked) + true) +(mod-fed-test! + "re-decision after exoneration is keep" + (get (get mod-fed-rev-inv :decision) :action) + "keep") + +(define mod-fed-tests-run! (fn () {:failures mod-fed-failures :total mod-fed-count :passed mod-fed-pass :failed mod-fed-fail})) diff --git a/plans/mod-on-sx.md b/plans/mod-on-sx.md index 01821c18..8634b8d6 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` → **106/106** (Phases 1–3 complete) +`bash lib/mod/conformance.sh` → **132/132** (Phases 1–4 complete — roadmap done) ## Ground rules @@ -113,14 +113,37 @@ lib/mod/fed.sx ## Phase 4 — Federation -- [ ] cross-instance reports — peer raises report about local content (or vice versa) -- [ ] decision sharing — actions taken locally propagate to peers via fed-sx -- [ ] trust model — peer's decision is advisory unless `(trust peer :mod)` is granted -- [ ] revocation — undo applied moderation if proof was invalidated -- [ ] `lib/mod/tests/fed.sx` — federated decision chains (mock fed-sx in tests) +- [x] cross-instance reports — `mod/fed-receive-report peer …` ingests a peer's + report into the local registry, tagging origin; `mod/report-origin` resolves it + (local reports default to `"local"`); the engine decides federated reports + unchanged +- [x] decision sharing — `mod/fed-share-decision decision peers` pushes messages to + the mock outbox (`mod/fed-send!` is the seam the real fed-sx transport replaces) +- [x] trust model — `mod/fed-receive-decision` applies a peer's decision locally + ONLY when `(mod/trusted? peer :mod)`; otherwise it lands in the advisory log, + unapplied. `mod/grant-trust` / `mod/revoke-trust` manage the trust registry +- [x] revocation — `mod/fed-revoke!` marks the applied action revoked + emits a + revocation message to the origin; `mod/fed-revoke-if-invalidated` re-runs the + engine and revokes only when the action no longer holds (proof invalidated) +- [x] `lib/mod/tests/fed.sx` — 26 cases: trust grant/scope/revoke, cross-instance + ingest + origin, outbox sharing, advisory-vs-trusted apply, revocation + + invalidation (exoneration flips hide→keep → revoked) ## Progress log +- **Phase 4 complete — 132/132** (+26 fed). **Full roadmap done.** Federation: + cross-instance reports, decision sharing, advisory-by-default trust, revocation. + fed-sx is mocked behind `mod/fed-send!` (in-memory outbox) — the only seam a real + transport must replace. The hard rule is enforced: a peer's decision binds + locally only under `(mod/trusted? peer :mod)`; otherwise it is recorded as a + suggestion and never auto-applied. Revocation composes with the proof model from + Phase 2 — `mod/fed-revoke-if-invalidated` re-runs the *same* engine and undoes a + moderation only when the action it once proved no longer holds (an exoneration + evidence flips hide→keep, triggering revocation + an origin-bound revocation + message). + - **Liftable (acl-sx watch):** the trust registry (`grant`/`revoke`/`trusted?` + over `{:peer :scope}`) and the outbox/send! seam are generic federation + plumbing; candidates for `lib/guest/` if acl-sx grows a federation phase. - **Phase 3 complete — 106/106** (+46 escalation). Lifecycle state machine, auto/human tiers, appeal-override, and an api façade. The state machine is a pure SX module (`lib/mod/lifecycle.sx`) over the engine — policy stays in From 2ea87796a1c5f06d94d38288e2ec2b8caf48d454 Mon Sep 17 00:00:00 2001 From: giles Date: Sat, 6 Jun 2026 17:59:01 +0000 Subject: [PATCH 06/26] =?UTF-8?q?mod:=20Ext=201=20=E2=80=94=20negation-as-?= =?UTF-8?q?failure=20conditions=20(:not=20/=20:attr),=20146/146?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Report attributes (:attrs) project to attr(Id, name) facts; policy gains (:attr x) and (:not ) conditions. The Prolog substrate exposes negation as a functor not(Goal) (the prefix \+ operator doesn't parse here). Closed-world example: hide spam unless author verified. Default policy untouched — feature proven via custom rule sets, so all 132 base tests stay green. +14 extension tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/mod/conformance.conf | 1 + lib/mod/policy.sx | 5 ++ lib/mod/schema.sx | 43 +++++++--- lib/mod/scoreboard.json | 9 ++- lib/mod/scoreboard.md | 3 +- lib/mod/tests/extensions.sx | 153 ++++++++++++++++++++++++++++++++++++ plans/mod-on-sx.md | 19 ++++- 7 files changed, 218 insertions(+), 15 deletions(-) create mode 100644 lib/mod/tests/extensions.sx diff --git a/lib/mod/conformance.conf b/lib/mod/conformance.conf index dc704baa..c99cb992 100644 --- a/lib/mod/conformance.conf +++ b/lib/mod/conformance.conf @@ -24,4 +24,5 @@ SUITES=( "audit:lib/mod/tests/audit.sx:(mod-audit-tests-run!)" "escalation:lib/mod/tests/escalation.sx:(mod-escalation-tests-run!)" "fed:lib/mod/tests/fed.sx:(mod-fed-tests-run!)" + "extensions:lib/mod/tests/extensions.sx:(mod-extensions-tests-run!)" ) diff --git a/lib/mod/policy.sx b/lib/mod/policy.sx index 804e59f8..0853ff50 100644 --- a/lib/mod/policy.sx +++ b/lib/mod/policy.sx @@ -42,6 +42,8 @@ ;; ;; (:classification "spam") → classification(Id, spam) ;; (:evidence "kind") → evidence(Id, 'kind', _) +;; (:attr "verified") → attr(Id, verified) +;; (:not ) → not() (negation as failure) ;; (:count-at-least 3) → report(Id, B, S), report_count(S, N), N >= 3 (define @@ -60,6 +62,9 @@ ", " (mod/pl-quote (nth c 1)) ", _)")) + ((= tag :attr) (str "attr(" idterm ", " (nth c 1) ")")) + ((= tag :not) + (str "not(" (mod/cond->goal (nth c 1) idterm) ")")) ((= tag :count-at-least) (str "report(" diff --git a/lib/mod/schema.sx b/lib/mod/schema.sx index 5db13a1b..cc277de8 100644 --- a/lib/mod/schema.sx +++ b/lib/mod/schema.sx @@ -1,12 +1,13 @@ ;; lib/mod/schema.sx — report representation + Prolog fact generation. ;; -;; A report is a dict {:id :by :about :reason :evidence}. :evidence is a list of -;; accumulated evidence entries {:kind :val} (human review, automated scanners, -;; etc.). The engine derives keyword classifications from the reason text and -;; projects the report, its classifications, and its accumulated evidence into -;; Prolog facts that policy clauses match against. +;; A report is a dict {:id :by :about :reason :evidence :attrs}. :evidence is a +;; list of accumulated evidence entries {:kind :val} (human review, automated +;; scanners). :attrs is a list of attribute names (e.g. "verified") used by +;; negation-as-failure conditions. The engine derives keyword classifications +;; from the reason text and projects the report, its classifications, evidence, +;; and attributes into Prolog facts that policy clauses match against. -(define mod/mk-report (fn (id by about reason) {:id id :by by :evidence (list) :about about :reason reason})) +(define mod/mk-report (fn (id by about reason) {:attrs (list) :id id :by by :evidence (list) :about about :reason reason})) (define mod/report-id (fn (r) (get r :id))) (define mod/report-by (fn (r) (get r :by))) @@ -17,11 +18,23 @@ mod/report-evidence (fn (r) (let ((e (get r :evidence))) (if (nil? e) (list) e)))) +(define + mod/report-attrs + (fn (r) (let ((a (get r :attrs))) (if (nil? a) (list) a)))) + (define mod/mk-evidence (fn (kind val) {:val val :kind kind})) (define mod/evidence-kind (fn (e) (get e :kind))) (define mod/evidence-val (fn (e) (get e :val))) -(define mod/with-evidence (fn (r evs) {:id (mod/report-id r) :by (mod/report-by r) :evidence evs :about (mod/report-about r) :reason (mod/report-reason r)})) +(define mod/report* (fn (r evs attrs) {:attrs attrs :id (mod/report-id r) :by (mod/report-by r) :evidence evs :about (mod/report-about r) :reason (mod/report-reason r)})) + +(define + mod/with-evidence + (fn (r evs) (mod/report* r evs (mod/report-attrs r)))) + +(define + mod/with-attrs + (fn (r attrs) (mod/report* r (mod/report-evidence r) attrs))) (define mod/attach-evidence @@ -29,6 +42,10 @@ (r e) (mod/with-evidence r (append (mod/report-evidence r) (list e))))) +(define + mod/attach-attr + (fn (r a) (mod/with-attrs r (append (mod/report-attrs r) (list a))))) + ;; ── substring search (the prolog-loaded env lacks includes?; slice/len do work) ── (define @@ -139,6 +156,12 @@ ").")) evs)))) +(define + mod/attr-facts + (fn + (id attrs) + (mod/join-with "\n" (map (fn (a) (str "attr(" id ", " a ").")) attrs)))) + (define mod/report-facts (fn @@ -149,11 +172,13 @@ (about (mod/pl-quote (mod/report-about r)))) (let ((cls (mod/classification-facts id (mod/classify-keywords r))) - (evs (mod/evidence-facts id (mod/report-evidence r)))) + (evs (mod/evidence-facts id (mod/report-evidence r))) + (ats (mod/attr-facts id (mod/report-attrs r)))) (mod/join-with "\n" (list (str "report(" id ", " by ", " about ").") (str "report_count(" about ", " count ").") cls - evs)))))) + evs + ats)))))) diff --git a/lib/mod/scoreboard.json b/lib/mod/scoreboard.json index 3ebb8e38..2cb967d6 100644 --- a/lib/mod/scoreboard.json +++ b/lib/mod/scoreboard.json @@ -1,13 +1,14 @@ { "lang": "mod", - "total_passed": 132, + "total_passed": 146, "total_failed": 0, - "total": 132, + "total": 146, "suites": [ {"name":"decide","passed":31,"failed":0,"total":31}, {"name":"audit","passed":29,"failed":0,"total":29}, {"name":"escalation","passed":46,"failed":0,"total":46}, - {"name":"fed","passed":26,"failed":0,"total":26} + {"name":"fed","passed":26,"failed":0,"total":26}, + {"name":"extensions","passed":14,"failed":0,"total":14} ], - "generated": "2026-06-06T17:54:02+00:00" + "generated": "2026-06-06T17:58:37+00:00" } diff --git a/lib/mod/scoreboard.md b/lib/mod/scoreboard.md index 108e0273..b830428f 100644 --- a/lib/mod/scoreboard.md +++ b/lib/mod/scoreboard.md @@ -1,6 +1,6 @@ # mod scoreboard -**132 / 132 passing** (0 failure(s)). +**146 / 146 passing** (0 failure(s)). | Suite | Passed | Total | Status | |-------|--------|-------|--------| @@ -8,3 +8,4 @@ | audit | 29 | 29 | ok | | escalation | 46 | 46 | ok | | fed | 26 | 26 | ok | +| extensions | 14 | 14 | ok | diff --git a/lib/mod/tests/extensions.sx b/lib/mod/tests/extensions.sx new file mode 100644 index 00000000..23cf6b1d --- /dev/null +++ b/lib/mod/tests/extensions.sx @@ -0,0 +1,153 @@ +;; lib/mod/tests/extensions.sx — beyond-roadmap extensions. +;; +;; Ext 1: negation-as-failure conditions (:not / :attr) + report attributes. +;; These exercise closed-world reasoning: "hide spam UNLESS the author is +;; verified". Demonstrated with custom rule sets so the default policy (and its +;; 132 conformance tests) stays untouched. + +(define mod-ext-count 0) +(define mod-ext-pass 0) +(define mod-ext-fail 0) +(define mod-ext-failures (list)) + +(define + mod-ext-test! + (fn + (name got expected) + (begin + (set! mod-ext-count (+ mod-ext-count 1)) + (if + (= got expected) + (set! mod-ext-pass (+ mod-ext-pass 1)) + (begin + (set! mod-ext-fail (+ mod-ext-fail 1)) + (append! + mod-ext-failures + (str name "\n expected: " expected "\n got: " got))))))) + +;; ── report attributes ── + +(define mod-ext-r0 (mod/mk-report "r1" "a" "b" "this is spam")) +(mod-ext-test! + "fresh report has no attrs" + (len (mod/report-attrs mod-ext-r0)) + 0) +(define mod-ext-rv (mod/attach-attr mod-ext-r0 "verified")) +(mod-ext-test! + "attach-attr adds one attr" + (len (mod/report-attrs mod-ext-rv)) + 1) +(mod-ext-test! + "attach-attr preserves evidence field" + (len + (mod/report-evidence + (mod/attach-evidence mod-ext-rv (mod/mk-evidence "x" "y")))) + 1) +(mod-ext-test! + "attach-evidence preserves attrs" + (len + (mod/report-attrs + (mod/attach-evidence mod-ext-rv (mod/mk-evidence "x" "y")))) + 1) + +;; ── negation-as-failure: spam hidden unless author verified ── + +(define + mod-ext-rules + (list + (mod/mk-rule + "spam-unverified-hide" + :hide (list + (list :classification "spam") + (list :not (list :attr "verified")))) + (mod/mk-rule "default-keep" :keep (list)))) + +(define mod-ext-spam-plain (mod/mk-report "p1" "a" "b" "this is spam")) +(define + mod-ext-spam-verified + (mod/attach-attr (mod/mk-report "p2" "a" "b" "this is spam") "verified")) +(define mod-ext-clean (mod/mk-report "p3" "a" "b" "a fine post")) + +(mod-ext-test! + "unverified spam → hide" + (get + (mod/decide-report + mod-ext-spam-plain + (list mod-ext-spam-plain) + mod-ext-rules) + :action) + "hide") +(mod-ext-test! + "verified author spam → keep (negation blocks)" + (get + (mod/decide-report + mod-ext-spam-verified + (list mod-ext-spam-verified) + mod-ext-rules) + :action) + "keep") +(mod-ext-test! + "clean post → keep" + (get + (mod/decide-report mod-ext-clean (list mod-ext-clean) mod-ext-rules) + :action) + "keep") + +;; ── negation appears in the goal text + proof ── + +(define + mod-ext-dec + (mod/decide-report + mod-ext-spam-plain + (list mod-ext-spam-plain) + mod-ext-rules)) +(define mod-ext-goals (get (get mod-ext-dec :proof) :goals)) + +(mod-ext-test! + "rule that matched is spam-unverified-hide" + (get mod-ext-dec :rule) + "spam-unverified-hide") +(mod-ext-test! "proof has two goals" (len mod-ext-goals) 2) +(mod-ext-test! + "negation goal text" + (get (nth mod-ext-goals 1) :goal) + "not(attr(p1, verified))") +(mod-ext-test! + "negation goal solved for unverified" + (get (nth mod-ext-goals 1) :solved) + true) + +;; ── cond->goal compiles :attr and :not directly ── + +(mod-ext-test! + "cond->goal :attr" + (mod/cond->goal (list :attr "verified") "Id") + "attr(Id, verified)") +(mod-ext-test! + "cond->goal :not wraps inner" + (mod/cond->goal (list :not (list :classification "spam")) "Id") + "not(classification(Id, spam))") + +;; ── positive :attr condition (allowlist-style) ── + +(define + mod-ext-allow-rules + (list + (mod/mk-rule "trusted-keep" :keep (list (list :attr "trusted"))) + (mod/mk-rule "spam-hide" :hide (list (list :classification "spam"))) + (mod/mk-rule "default-keep" :keep (list)))) + +(define + mod-ext-trusted-spam + (mod/attach-attr (mod/mk-report "t1" "a" "b" "this is spam") "trusted")) +(mod-ext-test! + "trusted attr exempts spam → keep" + (get + (mod/decide-report + mod-ext-trusted-spam + (list mod-ext-trusted-spam) + mod-ext-allow-rules) + :action) + "keep") + +(define mod-extensions-tests-run! (fn () {:failures mod-ext-failures :total mod-ext-count :passed mod-ext-pass :failed mod-ext-fail})) diff --git a/plans/mod-on-sx.md b/plans/mod-on-sx.md index 8634b8d6..4c70baf3 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` → **132/132** (Phases 1–4 complete — roadmap done) +`bash lib/mod/conformance.sh` → **146/146** (roadmap done + extensions in progress) ## Ground rules @@ -129,8 +129,25 @@ lib/mod/fed.sx ingest + origin, outbox sharing, advisory-vs-trusted apply, revocation + invalidation (exoneration flips hide→keep → revoked) +## Extensions (post-roadmap) + +- [x] **Ext 1 — negation-as-failure** (`lib/mod/tests/extensions.sx`, +14). Report + `:attrs`; policy conditions `(:attr "x")` → `attr(Id, x)` and `(:not )` → + `not()` (the Prolog supports `not/1` and `\+/1` as *functors*, not the + prefix `\+` operator). Closed-world example: "hide spam UNLESS author verified". + Default policy untouched — demonstrated via custom rule sets, so all 132 base + tests stay green. +- [ ] Ext 2 — weighted/aggregate evidence scoring + threshold rules +- [ ] Ext 3 — human-readable proof explanation (render a decision's `:goals`) +- [ ] Ext 4 — report linking / dedup (relations between reports about one subject) + ## Progress log +- **Ext 1 — negation-as-failure, 146/146** (+14). `:attr` and `:not` conditions + give the policy closed-world reasoning. The substrate's negation is a functor + (`not(Goal)`), not the ISO prefix `\+` operator (that doesn't parse here) — + noted for any future negation work. Kept the default rule set and its 132 tests + untouched by proving the feature through custom rule sets instead. - **Phase 4 complete — 132/132** (+26 fed). **Full roadmap done.** Federation: cross-instance reports, decision sharing, advisory-by-default trust, revocation. fed-sx is mocked behind `mod/fed-send!` (in-memory outbox) — the only seam a real From 779a5926144308cf6149b4d5573b8c90e5d04793 Mon Sep 17 00:00:00 2001 From: giles Date: Sat, 6 Jun 2026 18:02:52 +0000 Subject: [PATCH 07/26] =?UTF-8?q?mod:=20Ext=202=20=E2=80=94=20weighted/agg?= =?UTF-8?q?regate=20scoring=20(:score-at-least),=20154/154?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Report :signals ({:kind :weight}) project to signal(Id, 'kind', weight) facts; condition (:score-at-least N) compiles to aggregate_all(sum(W), signal(Id,_,W),T), T >= N. Low-confidence signals accumulate past a threshold via genuine Prolog arithmetic aggregation. Default policy untouched — proven via custom rule sets. +8 extension tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/mod/policy.sx | 7 +++ lib/mod/schema.sx | 68 ++++++++++++++++++---- lib/mod/scoreboard.json | 8 +-- lib/mod/scoreboard.md | 4 +- lib/mod/tests/extensions.sx | 113 +++++++++++++++++++++++++++++++++--- plans/mod-on-sx.md | 15 ++++- 6 files changed, 187 insertions(+), 28 deletions(-) diff --git a/lib/mod/policy.sx b/lib/mod/policy.sx index 0853ff50..3d1cde58 100644 --- a/lib/mod/policy.sx +++ b/lib/mod/policy.sx @@ -45,6 +45,7 @@ ;; (:attr "verified") → attr(Id, verified) ;; (:not ) → not() (negation as failure) ;; (:count-at-least 3) → report(Id, B, S), report_count(S, N), N >= 3 +;; (:score-at-least 5) → aggregate_all(sum(W), signal(Id, _, W), T), T >= 5 (define mod/cond->goal @@ -71,6 +72,12 @@ idterm ", B, S), report_count(S, N), N >= " (nth c 1))) + ((= tag :score-at-least) + (str + "aggregate_all(sum(W), signal(" + idterm + ", _, W), T), T >= " + (nth c 1))) (true "true"))))) (define diff --git a/lib/mod/schema.sx b/lib/mod/schema.sx index cc277de8..32d907c6 100644 --- a/lib/mod/schema.sx +++ b/lib/mod/schema.sx @@ -1,13 +1,14 @@ ;; lib/mod/schema.sx — report representation + Prolog fact generation. ;; -;; A report is a dict {:id :by :about :reason :evidence :attrs}. :evidence is a -;; list of accumulated evidence entries {:kind :val} (human review, automated -;; scanners). :attrs is a list of attribute names (e.g. "verified") used by -;; negation-as-failure conditions. The engine derives keyword classifications -;; from the reason text and projects the report, its classifications, evidence, -;; and attributes into Prolog facts that policy clauses match against. +;; A report is a dict {:id :by :about :reason :evidence :attrs :signals}. +;; :evidence — accumulated {:kind :val} entries (human review, scanners) +;; :attrs — attribute names ("verified") for negation-as-failure conditions +;; :signals — weighted {:kind :weight} entries for aggregate scoring rules +;; The engine derives keyword classifications from the reason text and projects +;; the report, its classifications, evidence, attributes, and signals into Prolog +;; facts that policy clauses match against. -(define mod/mk-report (fn (id by about reason) {:attrs (list) :id id :by by :evidence (list) :about about :reason reason})) +(define mod/mk-report (fn (id by about reason) {:attrs (list) :id id :signals (list) :by by :evidence (list) :about about :reason reason})) (define mod/report-id (fn (r) (get r :id))) (define mod/report-by (fn (r) (get r :by))) @@ -22,19 +23,37 @@ mod/report-attrs (fn (r) (let ((a (get r :attrs))) (if (nil? a) (list) a)))) +(define + mod/report-signals + (fn (r) (let ((s (get r :signals))) (if (nil? s) (list) s)))) + (define mod/mk-evidence (fn (kind val) {:val val :kind kind})) (define mod/evidence-kind (fn (e) (get e :kind))) (define mod/evidence-val (fn (e) (get e :val))) -(define mod/report* (fn (r evs attrs) {:attrs attrs :id (mod/report-id r) :by (mod/report-by r) :evidence evs :about (mod/report-about r) :reason (mod/report-reason r)})) +(define mod/mk-signal (fn (kind weight) {:kind kind :weight weight})) +(define mod/signal-kind (fn (s) (get s :kind))) +(define mod/signal-weight (fn (s) (get s :weight))) + +(define mod/report* (fn (r evs attrs sigs) {:attrs attrs :id (mod/report-id r) :signals sigs :by (mod/report-by r) :evidence evs :about (mod/report-about r) :reason (mod/report-reason r)})) (define mod/with-evidence - (fn (r evs) (mod/report* r evs (mod/report-attrs r)))) + (fn + (r evs) + (mod/report* r evs (mod/report-attrs r) (mod/report-signals r)))) (define mod/with-attrs - (fn (r attrs) (mod/report* r (mod/report-evidence r) attrs))) + (fn + (r attrs) + (mod/report* r (mod/report-evidence r) attrs (mod/report-signals r)))) + +(define + mod/with-signals + (fn + (r sigs) + (mod/report* r (mod/report-evidence r) (mod/report-attrs r) sigs))) (define mod/attach-evidence @@ -46,6 +65,10 @@ mod/attach-attr (fn (r a) (mod/with-attrs r (append (mod/report-attrs r) (list a))))) +(define + mod/attach-signal + (fn (r s) (mod/with-signals r (append (mod/report-signals r) (list s))))) + ;; ── substring search (the prolog-loaded env lacks includes?; slice/len do work) ── (define @@ -162,6 +185,25 @@ (id attrs) (mod/join-with "\n" (map (fn (a) (str "attr(" id ", " a ").")) attrs)))) +(define + mod/signal-facts + (fn + (id sigs) + (mod/join-with + "\n" + (map + (fn + (s) + (str + "signal(" + id + ", " + (mod/pl-quote (mod/signal-kind s)) + ", " + (mod/signal-weight s) + ").")) + sigs)))) + (define mod/report-facts (fn @@ -173,7 +215,8 @@ (let ((cls (mod/classification-facts id (mod/classify-keywords r))) (evs (mod/evidence-facts id (mod/report-evidence r))) - (ats (mod/attr-facts id (mod/report-attrs r)))) + (ats (mod/attr-facts id (mod/report-attrs r))) + (sgs (mod/signal-facts id (mod/report-signals r)))) (mod/join-with "\n" (list @@ -181,4 +224,5 @@ (str "report_count(" about ", " count ").") cls evs - ats)))))) + ats + sgs)))))) diff --git a/lib/mod/scoreboard.json b/lib/mod/scoreboard.json index 2cb967d6..d8930e4f 100644 --- a/lib/mod/scoreboard.json +++ b/lib/mod/scoreboard.json @@ -1,14 +1,14 @@ { "lang": "mod", - "total_passed": 146, + "total_passed": 154, "total_failed": 0, - "total": 146, + "total": 154, "suites": [ {"name":"decide","passed":31,"failed":0,"total":31}, {"name":"audit","passed":29,"failed":0,"total":29}, {"name":"escalation","passed":46,"failed":0,"total":46}, {"name":"fed","passed":26,"failed":0,"total":26}, - {"name":"extensions","passed":14,"failed":0,"total":14} + {"name":"extensions","passed":22,"failed":0,"total":22} ], - "generated": "2026-06-06T17:58:37+00:00" + "generated": "2026-06-06T18:02:25+00:00" } diff --git a/lib/mod/scoreboard.md b/lib/mod/scoreboard.md index b830428f..353b11ca 100644 --- a/lib/mod/scoreboard.md +++ b/lib/mod/scoreboard.md @@ -1,6 +1,6 @@ # mod scoreboard -**146 / 146 passing** (0 failure(s)). +**154 / 154 passing** (0 failure(s)). | Suite | Passed | Total | Status | |-------|--------|-------|--------| @@ -8,4 +8,4 @@ | audit | 29 | 29 | ok | | escalation | 46 | 46 | ok | | fed | 26 | 26 | ok | -| extensions | 14 | 14 | ok | +| extensions | 22 | 22 | ok | diff --git a/lib/mod/tests/extensions.sx b/lib/mod/tests/extensions.sx index 23cf6b1d..46090632 100644 --- a/lib/mod/tests/extensions.sx +++ b/lib/mod/tests/extensions.sx @@ -1,9 +1,12 @@ ;; lib/mod/tests/extensions.sx — beyond-roadmap extensions. ;; ;; Ext 1: negation-as-failure conditions (:not / :attr) + report attributes. -;; These exercise closed-world reasoning: "hide spam UNLESS the author is -;; verified". Demonstrated with custom rule sets so the default policy (and its -;; 132 conformance tests) stays untouched. +;; "hide spam UNLESS the author is verified" (closed-world reasoning). +;; Ext 2: weighted/aggregate evidence scoring (:score-at-least) + report signals. +;; Many low-confidence signals accumulate past a threshold via Prolog +;; aggregate_all(sum(W), ...). +;; Demonstrated with custom rule sets so the default policy (and its conformance +;; tests) stays untouched. (define mod-ext-count 0) (define mod-ext-pass 0) @@ -25,7 +28,7 @@ mod-ext-failures (str name "\n expected: " expected "\n got: " got))))))) -;; ── report attributes ── +;; ── Ext 1: report attributes ── (define mod-ext-r0 (mod/mk-report "r1" "a" "b" "this is spam")) (mod-ext-test! @@ -50,7 +53,7 @@ (mod/attach-evidence mod-ext-rv (mod/mk-evidence "x" "y")))) 1) -;; ── negation-as-failure: spam hidden unless author verified ── +;; ── Ext 1: negation-as-failure: spam hidden unless author verified ── (define mod-ext-rules @@ -93,7 +96,7 @@ :action) "keep") -;; ── negation appears in the goal text + proof ── +;; ── Ext 1: negation appears in the goal text + proof ── (define mod-ext-dec @@ -117,7 +120,7 @@ (get (nth mod-ext-goals 1) :solved) true) -;; ── cond->goal compiles :attr and :not directly ── +;; ── Ext 1: cond->goal compiles :attr and :not directly ── (mod-ext-test! "cond->goal :attr" @@ -128,7 +131,7 @@ (mod/cond->goal (list :not (list :classification "spam")) "Id") "not(classification(Id, spam))") -;; ── positive :attr condition (allowlist-style) ── +;; ── Ext 1: positive :attr condition (allowlist-style) ── (define mod-ext-allow-rules @@ -150,4 +153,98 @@ :action) "keep") +;; ── Ext 2: weighted signals + aggregate scoring ── + +(define mod-ext-s0 (mod/mk-report "s1" "a" "b" "neutral")) +(mod-ext-test! + "fresh report has no signals" + (len (mod/report-signals mod-ext-s0)) + 0) +(define + mod-ext-s1 + (mod/attach-signal mod-ext-s0 (mod/mk-signal "link" 2))) +(mod-ext-test! + "attach-signal adds one" + (len (mod/report-signals mod-ext-s1)) + 1) +(mod-ext-test! + "attach-signal preserves attrs" + (len + (mod/report-attrs + (mod/attach-signal mod-ext-rv (mod/mk-signal "x" 1)))) + 1) + +(define + mod-ext-score-rules + (list + (mod/mk-rule + "high-score-hide" + :hide (list (list :score-at-least 5))) + (mod/mk-rule "default-keep" :keep (list)))) + +;; one weak signal (2) — below threshold +(define + mod-ext-weak + (mod/attach-signal + (mod/mk-report "w1" "a" "b" "neutral") + (mod/mk-signal "link" 2))) +(mod-ext-test! + "single weak signal → keep (below threshold)" + (get + (mod/decide-report mod-ext-weak (list mod-ext-weak) mod-ext-score-rules) + :action) + "keep") + +;; three signals summing to 6 — over threshold +(define + mod-ext-strong0 + (mod/attach-signal + (mod/mk-report "w2" "a" "b" "neutral") + (mod/mk-signal "link" 2))) +(define + mod-ext-strong1 + (mod/attach-signal mod-ext-strong0 (mod/mk-signal "newaccount" 2))) +(define + mod-ext-strong + (mod/attach-signal mod-ext-strong1 (mod/mk-signal "burst" 2))) +(mod-ext-test! + "accumulated signals (2+2+2=6) → hide" + (get + (mod/decide-report + mod-ext-strong + (list mod-ext-strong) + mod-ext-score-rules) + :action) + "hide") +(mod-ext-test! + "scoring rule named in decision" + (get + (mod/decide-report + mod-ext-strong + (list mod-ext-strong) + mod-ext-score-rules) + :rule) + "high-score-hide") + +;; exactly at threshold (5) fires +(define + mod-ext-exact0 + (mod/attach-signal + (mod/mk-report "w3" "a" "b" "neutral") + (mod/mk-signal "link" 3))) +(define + mod-ext-exact + (mod/attach-signal mod-ext-exact0 (mod/mk-signal "burst" 2))) +(mod-ext-test! + "exactly at threshold (5) → hide" + (get + (mod/decide-report mod-ext-exact (list mod-ext-exact) mod-ext-score-rules) + :action) + "hide") + +(mod-ext-test! + "cond->goal :score-at-least" + (mod/cond->goal (list :score-at-least 5) "Id") + "aggregate_all(sum(W), signal(Id, _, W), T), T >= 5") + (define mod-extensions-tests-run! (fn () {:failures mod-ext-failures :total mod-ext-count :passed mod-ext-pass :failed mod-ext-fail})) diff --git a/plans/mod-on-sx.md b/plans/mod-on-sx.md index 4c70baf3..f7c9c690 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` → **146/146** (roadmap done + extensions in progress) +`bash lib/mod/conformance.sh` → **154/154** (roadmap done + extensions in progress) ## Ground rules @@ -137,12 +137,23 @@ lib/mod/fed.sx prefix `\+` operator). Closed-world example: "hide spam UNLESS author verified". Default policy untouched — demonstrated via custom rule sets, so all 132 base tests stay green. -- [ ] Ext 2 — weighted/aggregate evidence scoring + threshold rules +- [x] **Ext 2 — weighted/aggregate scoring** (+8). Report `:signals` ({:kind + :weight}) project to `signal(Id, 'kind', weight)` facts; condition + `(:score-at-least N)` → `aggregate_all(sum(W), signal(Id, _, W), T), T >= N`. + Many weak signals accumulate past a threshold — genuine Prolog arithmetic + aggregation. Default policy untouched. - [ ] Ext 3 — human-readable proof explanation (render a decision's `:goals`) - [ ] Ext 4 — report linking / dedup (relations between reports about one subject) ## Progress log +- **Ext 2 — weighted/aggregate scoring, 154/154** (+8). `:signals` + the + `(:score-at-least N)` condition push aggregation into Prolog + (`aggregate_all(sum(W), …)`), so low-confidence signals can accumulate to a + takedown. The schema's report-rebuild helpers (`report*` / `with-*`) now thread + six fields; each addition stays non-breaking because empty collections project + to empty fact blocks. Default policy and its 132 tests untouched (proven via + custom rule sets). - **Ext 1 — negation-as-failure, 146/146** (+14). `:attr` and `:not` conditions give the policy closed-world reasoning. The substrate's negation is a functor (`not(Goal)`), not the ISO prefix `\+` operator (that doesn't parse here) — From 102c806451aa1cdae976cfb76e617e15d1007b2b Mon Sep 17 00:00:00 2001 From: giles Date: Sat, 6 Jun 2026 18:06:29 +0000 Subject: [PATCH 08/26] =?UTF-8?q?mod:=20Ext=203=20=E2=80=94=20human-readab?= =?UTF-8?q?le=20proof=20explanation=20(mod/explain),=20164/164?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mod/explain renders a decision's proof tree into legible text: action + rule, evidence line, and each derivation goal with [proved]/[unproved] and the unification bindings that satisfied it (e.g. {B=ann, N=3, S=dave}). Pure SX over the Phase-2 proof data — the audit trail's 'why' made readable. +10 tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/mod/conformance.conf | 1 + lib/mod/explain.sx | 55 ++++++++++++++++++++++++++++++++ lib/mod/scoreboard.json | 8 ++--- lib/mod/scoreboard.md | 4 +-- lib/mod/tests/extensions.sx | 63 +++++++++++++++++++++++++++++++++++++ plans/mod-on-sx.md | 13 ++++++-- 6 files changed, 136 insertions(+), 8 deletions(-) create mode 100644 lib/mod/explain.sx diff --git a/lib/mod/conformance.conf b/lib/mod/conformance.conf index c99cb992..53926a76 100644 --- a/lib/mod/conformance.conf +++ b/lib/mod/conformance.conf @@ -13,6 +13,7 @@ PRELOADS=( lib/mod/schema.sx lib/mod/policy.sx lib/mod/engine.sx + lib/mod/explain.sx lib/mod/lifecycle.sx lib/mod/audit.sx lib/mod/api.sx diff --git a/lib/mod/explain.sx b/lib/mod/explain.sx new file mode 100644 index 00000000..6a210393 --- /dev/null +++ b/lib/mod/explain.sx @@ -0,0 +1,55 @@ +;; lib/mod/explain.sx — human-readable proof explanation. +;; +;; Turns a decision (from mod/decide-report, or any audit entry) into a readable +;; multi-line "why": the action, the rule that fired, the evidence in play, and +;; the derivation goal-by-goal with [proved]/[unproved] marks and the unification +;; bindings that satisfied each goal. Pure SX over the Phase-2 proof tree. + +(define + mod/explain-binds + (fn + (binds) + (mod/join-with + ", " + (map (fn (k) (str k "=" (dict-get binds k))) (keys binds))))) + +(define + mod/explain-goal + (fn + (g) + (let + ((mark (if (get g :solved) " [proved] " " [unproved] ")) + (binds (get g :bindings))) + (if + (empty? (keys binds)) + (str mark (get g :goal)) + (str mark (get g :goal) " {" (mod/explain-binds binds) "}"))))) + +(define + mod/explain-evidence + (fn + (evidence) + (if + (empty? evidence) + "Evidence: (none)" + (str "Evidence: " (mod/join-with ", " evidence))))) + +(define + mod/explain + (fn + (decision) + (let + ((id (get decision :report-id)) + (action (get decision :action)) + (rule (get decision :rule)) + (proof (get decision :proof))) + (let + ((goals (get proof :goals)) (evidence (get proof :evidence))) + (mod/join-with + "\n" + (append + (list + (str "Report " id ": " action " (rule: " rule ")") + (mod/explain-evidence evidence) + "Because:") + (map mod/explain-goal goals))))))) diff --git a/lib/mod/scoreboard.json b/lib/mod/scoreboard.json index d8930e4f..5c1d8a77 100644 --- a/lib/mod/scoreboard.json +++ b/lib/mod/scoreboard.json @@ -1,14 +1,14 @@ { "lang": "mod", - "total_passed": 154, + "total_passed": 164, "total_failed": 0, - "total": 154, + "total": 164, "suites": [ {"name":"decide","passed":31,"failed":0,"total":31}, {"name":"audit","passed":29,"failed":0,"total":29}, {"name":"escalation","passed":46,"failed":0,"total":46}, {"name":"fed","passed":26,"failed":0,"total":26}, - {"name":"extensions","passed":22,"failed":0,"total":22} + {"name":"extensions","passed":32,"failed":0,"total":32} ], - "generated": "2026-06-06T18:02:25+00:00" + "generated": "2026-06-06T18:05:54+00:00" } diff --git a/lib/mod/scoreboard.md b/lib/mod/scoreboard.md index 353b11ca..02db4f60 100644 --- a/lib/mod/scoreboard.md +++ b/lib/mod/scoreboard.md @@ -1,6 +1,6 @@ # mod scoreboard -**154 / 154 passing** (0 failure(s)). +**164 / 164 passing** (0 failure(s)). | Suite | Passed | Total | Status | |-------|--------|-------|--------| @@ -8,4 +8,4 @@ | audit | 29 | 29 | ok | | escalation | 46 | 46 | ok | | fed | 26 | 26 | ok | -| extensions | 22 | 22 | ok | +| extensions | 32 | 32 | ok | diff --git a/lib/mod/tests/extensions.sx b/lib/mod/tests/extensions.sx index 46090632..cafd11ba 100644 --- a/lib/mod/tests/extensions.sx +++ b/lib/mod/tests/extensions.sx @@ -5,6 +5,7 @@ ;; Ext 2: weighted/aggregate evidence scoring (:score-at-least) + report signals. ;; Many low-confidence signals accumulate past a threshold via Prolog ;; aggregate_all(sum(W), ...). +;; Ext 3: human-readable proof explanation (mod/explain) over the proof tree. ;; Demonstrated with custom rule sets so the default policy (and its conformance ;; tests) stays untouched. @@ -247,4 +248,66 @@ (mod/cond->goal (list :score-at-least 5) "Id") "aggregate_all(sum(W), signal(Id, _, W), T), T >= 5") +;; ── Ext 3: human-readable proof explanation ── + +(define mod-ext-spam-explain (mod/explain mod-ext-dec)) + +(mod-ext-test! + "explain mentions the report id" + (mod/str-contains? mod-ext-spam-explain "Report p1") + true) +(mod-ext-test! + "explain mentions the action" + (mod/str-contains? mod-ext-spam-explain "hide") + true) +(mod-ext-test! + "explain mentions the rule" + (mod/str-contains? mod-ext-spam-explain "spam-unverified-hide") + true) +(mod-ext-test! + "explain marks proved goals" + (mod/str-contains? mod-ext-spam-explain "[proved]") + true) +(mod-ext-test! + "explain renders the evidence line" + (mod/str-contains? mod-ext-spam-explain "Evidence: spam") + true) + +;; count-rule explanation shows the unification bindings +(define mod-ext-rep-r (mod/mk-report "rc" "ann" "dave" "off-topic")) +(define + mod-ext-rep-d + (mod/decide-report + mod-ext-rep-r + (list mod-ext-rep-r mod-ext-rep-r mod-ext-rep-r) + mod/default-rules)) +(define mod-ext-rep-explain (mod/explain mod-ext-rep-d)) +(mod-ext-test! + "explain shows binding N=3" + (mod/str-contains? mod-ext-rep-explain "N=3") + true) +(mod-ext-test! + "explain shows subject binding" + (mod/str-contains? mod-ext-rep-explain "dave") + true) + +;; explain-goal direct: unproved goal gets [unproved] +(mod-ext-test! + "explain-goal marks unproved" + (mod/str-contains? (mod/explain-goal {:solved false :goal "attr(x, foo)" :bindings {}}) "[unproved]") + true) +;; explain-binds renders key=value pairs +(mod-ext-test! + "explain-binds renders pair" + (mod/explain-binds {:N "3"}) + "N=3") +;; no-evidence decision says (none) +(define + mod-ext-keep-d + (mod/decide-report mod-ext-clean (list mod-ext-clean) mod-ext-rules)) +(mod-ext-test! + "explain (none) for empty evidence" + (mod/str-contains? (mod/explain mod-ext-keep-d) "Evidence: (none)") + true) + (define mod-extensions-tests-run! (fn () {:failures mod-ext-failures :total mod-ext-count :passed mod-ext-pass :failed mod-ext-fail})) diff --git a/plans/mod-on-sx.md b/plans/mod-on-sx.md index f7c9c690..fbe820ef 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` → **154/154** (roadmap done + extensions in progress) +`bash lib/mod/conformance.sh` → **164/164** (roadmap done + extensions in progress) ## Ground rules @@ -142,11 +142,20 @@ lib/mod/fed.sx `(:score-at-least N)` → `aggregate_all(sum(W), signal(Id, _, W), T), T >= N`. Many weak signals accumulate past a threshold — genuine Prolog arithmetic aggregation. Default policy untouched. -- [ ] Ext 3 — human-readable proof explanation (render a decision's `:goals`) +- [x] **Ext 3 — proof explanation** (`lib/mod/explain.sx`, +10). `mod/explain` + renders a decision into a readable "why": action + rule, evidence line, and the + 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}`. - [ ] Ext 4 — report linking / dedup (relations between reports about one subject) ## Progress log +- **Ext 3 — proof explanation, 164/164** (+10). `mod/explain` turns the Phase-2 + proof tree into human-readable text — the audit trail's "why" made legible. Pure + SX over existing decision data; no engine change. Renders unification bindings + inline (`{B=ann, N=3, S=dave}`) so a moderator sees exactly which facts proved + the decision. - **Ext 2 — weighted/aggregate scoring, 154/154** (+8). `:signals` + the `(:score-at-least N)` condition push aggregation into Prolog (`aggregate_all(sum(W), …)`), so low-confidence signals can accumulate to a From 3d2c1d94f2acb05f48b91a04256e3fc13f8054c0 Mon Sep 17 00:00:00 2001 From: giles Date: Sat, 6 Jun 2026 18:09:37 +0000 Subject: [PATCH 09/26] =?UTF-8?q?mod:=20Ext=204=20=E2=80=94=20report=20lin?= =?UTF-8?q?king=20+=20dedup=20(Prolog-backed=20retrieval),=20176/176?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mod/related-ids and mod/reporters-of find reports about a subject via a Prolog relational query (report(Id, _, 'subject')) — the policy substrate reused for retrieval. mod/dedup-reports collapses identical reports by a normalized reporter|subject|reason key; mod/distinct-reporters-of counts unique reporters. Own suite (tests/link.sx). +12 tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/mod/conformance.conf | 2 + lib/mod/link.sx | 92 ++++++++++++++++++++++++++++++++++++++++ lib/mod/scoreboard.json | 9 ++-- lib/mod/scoreboard.md | 3 +- lib/mod/tests/link.sx | 86 +++++++++++++++++++++++++++++++++++++ plans/mod-on-sx.md | 16 ++++++- 6 files changed, 201 insertions(+), 7 deletions(-) create mode 100644 lib/mod/link.sx create mode 100644 lib/mod/tests/link.sx diff --git a/lib/mod/conformance.conf b/lib/mod/conformance.conf index 53926a76..bac5c834 100644 --- a/lib/mod/conformance.conf +++ b/lib/mod/conformance.conf @@ -18,6 +18,7 @@ PRELOADS=( lib/mod/audit.sx lib/mod/api.sx lib/mod/fed.sx + lib/mod/link.sx ) SUITES=( @@ -26,4 +27,5 @@ SUITES=( "escalation:lib/mod/tests/escalation.sx:(mod-escalation-tests-run!)" "fed:lib/mod/tests/fed.sx:(mod-fed-tests-run!)" "extensions:lib/mod/tests/extensions.sx:(mod-extensions-tests-run!)" + "link:lib/mod/tests/link.sx:(mod-link-tests-run!)" ) diff --git a/lib/mod/link.sx b/lib/mod/link.sx new file mode 100644 index 00000000..500fbe96 --- /dev/null +++ b/lib/mod/link.sx @@ -0,0 +1,92 @@ +;; lib/mod/link.sx — report linking + deduplication. +;; +;; Reports about the same subject form a cluster; identical reports (same +;; reporter + subject + reason) are duplicates. Linking is Prolog-backed: all +;; report facts are loaded and related ids are found by unification — the same +;; relational substrate the policy engine uses, here for retrieval rather than +;; decision. Dedup is pure SX over a normalized link key. + +(define + mod/link-key + (fn + (r) + (str + (mod/report-by r) + "|" + (mod/report-about r) + "|" + (downcase (mod/report-reason r))))) + +(define + mod/dedup-reports + (fn + (reports) + (reduce + (fn + (acc r) + (if + (mod/any? (fn (x) (= (mod/link-key x) (mod/link-key r))) acc) + acc + (append acc (list r)))) + (list) + reports))) + +(define + mod/duplicate-count + (fn (reports) (- (len reports) (len (mod/dedup-reports reports))))) + +;; ── Prolog-backed relational retrieval ── + +(define + mod/report-rel-facts + (fn + (reports) + (mod/join-with + "\n" + (map + (fn + (r) + (str + "report(" + (mod/report-id r) + ", " + (mod/pl-quote (mod/report-by r)) + ", " + (mod/pl-quote (mod/report-about r)) + ").")) + reports)))) + +(define + mod/related-ids + (fn + (subject reports) + (let + ((db (pl-load (mod/report-rel-facts reports)))) + (map + (fn (sol) (dict-get sol "Id")) + (pl-query-all db (str "report(Id, _, " (mod/pl-quote subject) ")")))))) + +(define + mod/reporters-of + (fn + (subject reports) + (let + ((db (pl-load (mod/report-rel-facts reports)))) + (map + (fn (sol) (dict-get sol "By")) + (pl-query-all db (str "report(_, By, " (mod/pl-quote subject) ")")))))) + +(define + mod/distinct + (fn + (items) + (reduce + (fn + (acc x) + (if (mod/any? (fn (y) (= y x)) acc) acc (append acc (list x)))) + (list) + items))) + +(define + mod/distinct-reporters-of + (fn (subject reports) (mod/distinct (mod/reporters-of subject reports)))) diff --git a/lib/mod/scoreboard.json b/lib/mod/scoreboard.json index 5c1d8a77..031c5e68 100644 --- a/lib/mod/scoreboard.json +++ b/lib/mod/scoreboard.json @@ -1,14 +1,15 @@ { "lang": "mod", - "total_passed": 164, + "total_passed": 176, "total_failed": 0, - "total": 164, + "total": 176, "suites": [ {"name":"decide","passed":31,"failed":0,"total":31}, {"name":"audit","passed":29,"failed":0,"total":29}, {"name":"escalation","passed":46,"failed":0,"total":46}, {"name":"fed","passed":26,"failed":0,"total":26}, - {"name":"extensions","passed":32,"failed":0,"total":32} + {"name":"extensions","passed":32,"failed":0,"total":32}, + {"name":"link","passed":12,"failed":0,"total":12} ], - "generated": "2026-06-06T18:05:54+00:00" + "generated": "2026-06-06T18:09:14+00:00" } diff --git a/lib/mod/scoreboard.md b/lib/mod/scoreboard.md index 02db4f60..e10a739d 100644 --- a/lib/mod/scoreboard.md +++ b/lib/mod/scoreboard.md @@ -1,6 +1,6 @@ # mod scoreboard -**164 / 164 passing** (0 failure(s)). +**176 / 176 passing** (0 failure(s)). | Suite | Passed | Total | Status | |-------|--------|-------|--------| @@ -9,3 +9,4 @@ | escalation | 46 | 46 | ok | | fed | 26 | 26 | ok | | extensions | 32 | 32 | ok | +| link | 12 | 12 | ok | diff --git a/lib/mod/tests/link.sx b/lib/mod/tests/link.sx new file mode 100644 index 00000000..19d20161 --- /dev/null +++ b/lib/mod/tests/link.sx @@ -0,0 +1,86 @@ +;; lib/mod/tests/link.sx — Ext 4: report linking + dedup. + +(define mod-lnk-count 0) +(define mod-lnk-pass 0) +(define mod-lnk-fail 0) +(define mod-lnk-failures (list)) + +(define + mod-lnk-test! + (fn + (name got expected) + (begin + (set! mod-lnk-count (+ mod-lnk-count 1)) + (if + (= got expected) + (set! mod-lnk-pass (+ mod-lnk-pass 1)) + (begin + (set! mod-lnk-fail (+ mod-lnk-fail 1)) + (append! + mod-lnk-failures + (str name "\n expected: " expected "\n got: " got))))))) + +;; ── link-key + dedup ── + +(define mod-lnk-a (mod/mk-report "r1" "alice" "bob" "this is spam")) +(define mod-lnk-a2 (mod/mk-report "r2" "alice" "bob" "THIS IS SPAM")) +(define mod-lnk-b (mod/mk-report "r3" "carol" "bob" "abuse")) +(define mod-lnk-c (mod/mk-report "r4" "alice" "eve" "this is spam")) + +(mod-lnk-test! + "identical reports share a link key (case-insensitive reason)" + (= (mod/link-key mod-lnk-a) (mod/link-key mod-lnk-a2)) + true) +(mod-lnk-test! + "different reporter → different key" + (= (mod/link-key mod-lnk-a) (mod/link-key mod-lnk-b)) + false) +(mod-lnk-test! + "different subject → different key" + (= (mod/link-key mod-lnk-a) (mod/link-key mod-lnk-c)) + false) + +(define mod-lnk-set (list mod-lnk-a mod-lnk-a2 mod-lnk-b mod-lnk-c)) +(mod-lnk-test! + "dedup collapses identical reports" + (len (mod/dedup-reports mod-lnk-set)) + 3) +(mod-lnk-test! + "duplicate-count counts collapsed" + (mod/duplicate-count mod-lnk-set) + 1) +(mod-lnk-test! + "dedup of all-distinct keeps all" + (len (mod/dedup-reports (list mod-lnk-a mod-lnk-b mod-lnk-c))) + 3) + +;; ── Prolog-backed relational linking ── + +(mod-lnk-test! + "related-ids finds all reports about subject" + (len (mod/related-ids "bob" mod-lnk-set)) + 3) +(mod-lnk-test! + "related-ids returns the ids" + (mod/related-ids "eve" mod-lnk-set) + (list "r4")) +(mod-lnk-test! + "related-ids empty for unknown subject" + (mod/related-ids "nobody" mod-lnk-set) + (list)) + +;; reporters: bob reported by alice (x2) + carol → 3 raw, 2 distinct +(mod-lnk-test! + "reporters-of counts all reports" + (len (mod/reporters-of "bob" mod-lnk-set)) + 3) +(mod-lnk-test! + "distinct reporters-of dedups reporters" + (len (mod/distinct-reporters-of "bob" mod-lnk-set)) + 2) +(mod-lnk-test! + "distinct utility removes dups" + (mod/distinct (list "a" "b" "a" "c" "b")) + (list "a" "b" "c")) + +(define mod-link-tests-run! (fn () {:failures mod-lnk-failures :total mod-lnk-count :passed mod-lnk-pass :failed mod-lnk-fail})) diff --git a/plans/mod-on-sx.md b/plans/mod-on-sx.md index fbe820ef..cd5b4755 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` → **164/164** (roadmap done + extensions in progress) +`bash lib/mod/conformance.sh` → **176/176** (roadmap + 4 extensions complete) ## Ground rules @@ -147,10 +147,22 @@ 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}`. -- [ ] Ext 4 — report linking / dedup (relations between reports about one subject) +- [x] **Ext 4 — report linking / dedup** (`lib/mod/link.sx`, +12). `mod/related-ids` + and `mod/reporters-of` find reports about a subject via a Prolog relational query + (`report(Id, _, 'subject')`) — the policy substrate reused for retrieval. + `mod/dedup-reports` collapses identical reports (reporter|subject|reason key, + case-insensitive); `mod/distinct-reporters-of` counts unique reporters. ## Progress log +- **Ext 4 — report linking / dedup, 176/176** (+12). Relational retrieval + (`related-ids`, `reporters-of`) reuses the Prolog substrate for *querying* report + clusters, not just deciding them — `report(Id, _, 'subject')` by unification. + Dedup is pure SX over a normalized link key. Own suite (`tests/link.sx`) — going + forward, new extensions get their own test file rather than growing + `extensions.sx`. With roadmap + 4 extensions the subsystem now spans schema → + policy DSL (6 condition types) → engine + proofs → audit → lifecycle → + federation → explanation → linking, all on the green `lib/prolog` substrate. - **Ext 3 — proof explanation, 164/164** (+10). `mod/explain` turns the Phase-2 proof tree into human-readable text — the audit trail's "why" made legible. Pure SX over existing decision data; no engine change. Renders unification bindings From e53a292f1a90aa6b8ad33db377d7b6c0050b23b1 Mon Sep 17 00:00:00 2001 From: giles Date: Sat, 6 Jun 2026 18:15:41 +0000 Subject: [PATCH 10/26] =?UTF-8?q?mod:=20Ext=205=20=E2=80=94=20policy=20rul?= =?UTF-8?q?e-set=20lint=20(unreachable/catch-all/dups),=20190/190?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Static analysis of a policy without running the engine: mod/unreachable-rules flags rules after an unconditional rule (dead under first-match precedence), mod/has-catchall? checks total coverage, mod/duplicate-rule-names + mod/rules-ok? give a well-formedness verdict policy authors can assert. Own suite. +14 tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/mod/conformance.conf | 2 + lib/mod/lint.sx | 69 ++++++++++++++++++++++ lib/mod/scoreboard.json | 9 +-- lib/mod/scoreboard.md | 3 +- lib/mod/tests/lint.sx | 122 +++++++++++++++++++++++++++++++++++++++ plans/mod-on-sx.md | 11 +++- 6 files changed, 210 insertions(+), 6 deletions(-) create mode 100644 lib/mod/lint.sx create mode 100644 lib/mod/tests/lint.sx diff --git a/lib/mod/conformance.conf b/lib/mod/conformance.conf index bac5c834..92e4abac 100644 --- a/lib/mod/conformance.conf +++ b/lib/mod/conformance.conf @@ -19,6 +19,7 @@ PRELOADS=( lib/mod/api.sx lib/mod/fed.sx lib/mod/link.sx + lib/mod/lint.sx ) SUITES=( @@ -28,4 +29,5 @@ SUITES=( "fed:lib/mod/tests/fed.sx:(mod-fed-tests-run!)" "extensions:lib/mod/tests/extensions.sx:(mod-extensions-tests-run!)" "link:lib/mod/tests/link.sx:(mod-link-tests-run!)" + "lint:lib/mod/tests/lint.sx:(mod-lint-tests-run!)" ) diff --git a/lib/mod/lint.sx b/lib/mod/lint.sx new file mode 100644 index 00000000..b3e83597 --- /dev/null +++ b/lib/mod/lint.sx @@ -0,0 +1,69 @@ +;; lib/mod/lint.sx — static analysis of a policy rule set. +;; +;; Because precedence is "first matching clause wins" (pl-query-one), the rule +;; order has correctness consequences a moderator can get wrong: a rule placed +;; after an unconditional (empty :when) rule can never fire, and a rule set with +;; no unconditional rule may leave some reports undecided. lint-rules surfaces +;; these without running the engine. + +(define mod/rule-unconditional? (fn (r) (empty? (mod/rule-when r)))) + +;; names of rules that follow the first unconditional rule — structurally dead, +;; since the unconditional rule always matches first +(define + mod/unreachable-rules + (fn + (rules) + (get + (reduce + (fn + (acc r) + (if + (get acc :hit) + {:dead (append (get acc :dead) (list (mod/rule-name r))) :hit true} + (if (mod/rule-unconditional? r) {:dead (get acc :dead) :hit true} acc))) + {:dead (list) :hit false} + rules) + :dead))) + +(define + mod/has-catchall? + (fn (rules) (mod/any? mod/rule-unconditional? rules))) + +(define + mod/count-eq + (fn + (x lst) + (reduce (fn (a y) (if (= y x) (+ a 1) a)) 0 lst))) + +(define + mod/duplicate-rule-names + (fn + (rules) + (let + ((names (map mod/rule-name rules))) + (mod/distinct + (reduce + (fn + (acc n) + (if + (< 1 (mod/count-eq n names)) + (append acc (list n)) + acc)) + (list) + names))))) + +(define mod/lint-rules (fn (rules) {:duplicate-names (mod/duplicate-rule-names rules) :has-catchall (mod/has-catchall? rules) :unreachable (mod/unreachable-rules rules)})) + +;; a rule set is well-formed when nothing is dead, it has a catch-all, and rule +;; names are unique +(define + mod/rules-ok? + (fn + (rules) + (let + ((l (mod/lint-rules rules))) + (if + (empty? (get l :unreachable)) + (if (get l :has-catchall) (empty? (get l :duplicate-names)) false) + false)))) diff --git a/lib/mod/scoreboard.json b/lib/mod/scoreboard.json index 031c5e68..d75cab7b 100644 --- a/lib/mod/scoreboard.json +++ b/lib/mod/scoreboard.json @@ -1,15 +1,16 @@ { "lang": "mod", - "total_passed": 176, + "total_passed": 190, "total_failed": 0, - "total": 176, + "total": 190, "suites": [ {"name":"decide","passed":31,"failed":0,"total":31}, {"name":"audit","passed":29,"failed":0,"total":29}, {"name":"escalation","passed":46,"failed":0,"total":46}, {"name":"fed","passed":26,"failed":0,"total":26}, {"name":"extensions","passed":32,"failed":0,"total":32}, - {"name":"link","passed":12,"failed":0,"total":12} + {"name":"link","passed":12,"failed":0,"total":12}, + {"name":"lint","passed":14,"failed":0,"total":14} ], - "generated": "2026-06-06T18:09:14+00:00" + "generated": "2026-06-06T18:15:06+00:00" } diff --git a/lib/mod/scoreboard.md b/lib/mod/scoreboard.md index e10a739d..ecec4269 100644 --- a/lib/mod/scoreboard.md +++ b/lib/mod/scoreboard.md @@ -1,6 +1,6 @@ # mod scoreboard -**176 / 176 passing** (0 failure(s)). +**190 / 190 passing** (0 failure(s)). | Suite | Passed | Total | Status | |-------|--------|-------|--------| @@ -10,3 +10,4 @@ | fed | 26 | 26 | ok | | extensions | 32 | 32 | ok | | link | 12 | 12 | ok | +| lint | 14 | 14 | ok | diff --git a/lib/mod/tests/lint.sx b/lib/mod/tests/lint.sx new file mode 100644 index 00000000..2aa14177 --- /dev/null +++ b/lib/mod/tests/lint.sx @@ -0,0 +1,122 @@ +;; lib/mod/tests/lint.sx — Ext 5: policy rule-set static analysis. + +(define mod-lint-count 0) +(define mod-lint-pass 0) +(define mod-lint-fail 0) +(define mod-lint-failures (list)) + +(define + mod-lint-test! + (fn + (name got expected) + (begin + (set! mod-lint-count (+ mod-lint-count 1)) + (if + (= got expected) + (set! mod-lint-pass (+ mod-lint-pass 1)) + (begin + (set! mod-lint-fail (+ mod-lint-fail 1)) + (append! + mod-lint-failures + (str name "\n expected: " expected "\n got: " got))))))) + +;; ── the default rule set is well-formed ── + +(mod-lint-test! + "default rules: no unreachable" + (mod/unreachable-rules mod/default-rules) + (list)) +(mod-lint-test! + "default rules: has catch-all" + (mod/has-catchall? mod/default-rules) + true) +(mod-lint-test! + "default rules: no duplicate names" + (mod/duplicate-rule-names mod/default-rules) + (list)) +(mod-lint-test! + "default rules: well-formed" + (mod/rules-ok? mod/default-rules) + true) + +;; ── unreachable detection ── + +(define + mod-lint-shadowed + (list + (mod/mk-rule "spam-hide" :hide (list (list :classification "spam"))) + (mod/mk-rule "catch-all" :keep (list)) + (mod/mk-rule + "abuse-remove" + :remove (list (list :classification "abuse"))) + (mod/mk-rule + "repeated" + :escalate (list (list :count-at-least 3))))) + +(mod-lint-test! + "rules after catch-all are unreachable" + (mod/unreachable-rules mod-lint-shadowed) + (list "abuse-remove" "repeated")) +(mod-lint-test! + "shadowed rule set is not ok" + (mod/rules-ok? mod-lint-shadowed) + false) + +;; ── missing catch-all ── + +(define + mod-lint-nocatch + (list + (mod/mk-rule "spam-hide" :hide (list (list :classification "spam"))) + (mod/mk-rule + "abuse-remove" + :remove (list (list :classification "abuse"))))) + +(mod-lint-test! + "no catch-all detected" + (mod/has-catchall? mod-lint-nocatch) + false) +(mod-lint-test! + "no unreachable when no catch-all" + (mod/unreachable-rules mod-lint-nocatch) + (list)) +(mod-lint-test! + "no-catch-all rule set is not ok" + (mod/rules-ok? mod-lint-nocatch) + false) + +;; ── duplicate names ── + +(define + mod-lint-dups + (list + (mod/mk-rule "x" :hide (list (list :classification "spam"))) + (mod/mk-rule "x" :remove (list (list :classification "abuse"))) + (mod/mk-rule "default" :keep (list)))) + +(mod-lint-test! + "duplicate names detected" + (mod/duplicate-rule-names mod-lint-dups) + (list "x")) +(mod-lint-test! + "duplicate-name rule set is not ok" + (mod/rules-ok? mod-lint-dups) + false) + +;; ── helpers ── + +(mod-lint-test! + "rule-unconditional? true for empty when" + (mod/rule-unconditional? (mod/mk-rule "d" :keep (list))) + true) +(mod-lint-test! + "rule-unconditional? false with conditions" + (mod/rule-unconditional? + (mod/mk-rule "s" :hide (list (list :classification "spam")))) + false) +(mod-lint-test! + "count-eq counts occurrences" + (mod/count-eq "a" (list "a" "b" "a")) + 2) + +(define mod-lint-tests-run! (fn () {:failures mod-lint-failures :total mod-lint-count :passed mod-lint-pass :failed mod-lint-fail})) diff --git a/plans/mod-on-sx.md b/plans/mod-on-sx.md index cd5b4755..8670105d 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` → **176/176** (roadmap + 4 extensions complete) +`bash lib/mod/conformance.sh` → **190/190** (roadmap + 5 extensions complete) ## Ground rules @@ -147,6 +147,11 @@ 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 5 — policy lint** (`lib/mod/lint.sx`, +14). Static analysis of a rule + set: `mod/unreachable-rules` flags rules placed after an unconditional (always- + matching) rule — structurally dead under first-match precedence; + `mod/has-catchall?` checks every report gets a decision; `mod/duplicate-rule-names` + + `mod/rules-ok?` give a one-call well-formedness verdict. No engine run needed. - [x] **Ext 4 — report linking / dedup** (`lib/mod/link.sx`, +12). `mod/related-ids` and `mod/reporters-of` find reports about a subject via a Prolog relational query (`report(Id, _, 'subject')`) — the policy substrate reused for retrieval. @@ -155,6 +160,10 @@ lib/mod/fed.sx ## Progress log +- **Ext 5 — policy lint, 190/190** (+14). Static analysis of the rule set itself, + catching the failure modes first-match precedence makes easy: dead rules after a + catch-all, missing catch-all (undecided reports), duplicate names. `mod/rules-ok?` + is a single well-formedness gate a policy author can assert in their own tests. - **Ext 4 — report linking / dedup, 176/176** (+12). Relational retrieval (`related-ids`, `reporters-of`) reuses the Prolog substrate for *querying* report clusters, not just deciding them — `report(Id, _, 'subject')` by unification. From 01be84b5d81ea2b1c03460f28acccb7531cfaf2b Mon Sep 17 00:00:00 2001 From: giles Date: Sat, 6 Jun 2026 18:20:15 +0000 Subject: [PATCH 11/26] =?UTF-8?q?mod:=20Ext=206=20=E2=80=94=20strictest-wi?= =?UTF-8?q?ns=20decision=20strategy=20+=20action=20severity,=20204/204?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mod/decide-strictest collects every proven rule (pl-query-all) and applies the harshest action by mod/action-severity (keep --- lib/mod/conformance.conf | 2 + lib/mod/scoreboard.json | 9 +-- lib/mod/scoreboard.md | 3 +- lib/mod/severity.sx | 60 +++++++++++++++++++ lib/mod/tests/severity.sx | 120 ++++++++++++++++++++++++++++++++++++++ plans/mod-on-sx.md | 12 +++- 6 files changed, 200 insertions(+), 6 deletions(-) create mode 100644 lib/mod/severity.sx create mode 100644 lib/mod/tests/severity.sx diff --git a/lib/mod/conformance.conf b/lib/mod/conformance.conf index 92e4abac..ccc017a9 100644 --- a/lib/mod/conformance.conf +++ b/lib/mod/conformance.conf @@ -14,6 +14,7 @@ PRELOADS=( lib/mod/policy.sx lib/mod/engine.sx lib/mod/explain.sx + lib/mod/severity.sx lib/mod/lifecycle.sx lib/mod/audit.sx lib/mod/api.sx @@ -30,4 +31,5 @@ SUITES=( "extensions:lib/mod/tests/extensions.sx:(mod-extensions-tests-run!)" "link:lib/mod/tests/link.sx:(mod-link-tests-run!)" "lint:lib/mod/tests/lint.sx:(mod-lint-tests-run!)" + "severity:lib/mod/tests/severity.sx:(mod-severity-tests-run!)" ) diff --git a/lib/mod/scoreboard.json b/lib/mod/scoreboard.json index d75cab7b..5f185e5d 100644 --- a/lib/mod/scoreboard.json +++ b/lib/mod/scoreboard.json @@ -1,8 +1,8 @@ { "lang": "mod", - "total_passed": 190, + "total_passed": 204, "total_failed": 0, - "total": 190, + "total": 204, "suites": [ {"name":"decide","passed":31,"failed":0,"total":31}, {"name":"audit","passed":29,"failed":0,"total":29}, @@ -10,7 +10,8 @@ {"name":"fed","passed":26,"failed":0,"total":26}, {"name":"extensions","passed":32,"failed":0,"total":32}, {"name":"link","passed":12,"failed":0,"total":12}, - {"name":"lint","passed":14,"failed":0,"total":14} + {"name":"lint","passed":14,"failed":0,"total":14}, + {"name":"severity","passed":14,"failed":0,"total":14} ], - "generated": "2026-06-06T18:15:06+00:00" + "generated": "2026-06-06T18:19:30+00:00" } diff --git a/lib/mod/scoreboard.md b/lib/mod/scoreboard.md index ecec4269..0fe3c277 100644 --- a/lib/mod/scoreboard.md +++ b/lib/mod/scoreboard.md @@ -1,6 +1,6 @@ # mod scoreboard -**190 / 190 passing** (0 failure(s)). +**204 / 204 passing** (0 failure(s)). | Suite | Passed | Total | Status | |-------|--------|-------|--------| @@ -11,3 +11,4 @@ | extensions | 32 | 32 | ok | | link | 12 | 12 | ok | | lint | 14 | 14 | ok | +| severity | 14 | 14 | ok | diff --git a/lib/mod/severity.sx b/lib/mod/severity.sx new file mode 100644 index 00000000..aae53f43 --- /dev/null +++ b/lib/mod/severity.sx @@ -0,0 +1,60 @@ +;; lib/mod/severity.sx — "strictest-wins" decision strategy. +;; +;; The default engine resolves precedence by rule ORDER (first proven clause wins, +;; via pl-query-one). Some policies instead want the HARSHEST applicable sanction +;; regardless of order. mod/decide-strictest collects every rule that proves +;; (pl-query-all) and picks the highest-severity action. Same decision shape as +;; the engine, plus :strategy. Built over the engine's helpers; engine untouched. + +(define + mod/action-severity + (fn + (action) + (cond + ((= action "ban") 4) + ((= action "remove") 3) + ((= action "hide") 2) + ((= action "escalate") 1) + (true 0)))) + +(define + mod/strictest-sol + (fn + (sols) + (reduce + (fn + (acc s) + (if + (nil? acc) + s + (if + (< + (mod/action-severity (dict-get acc "Action")) + (mod/action-severity (dict-get s "Action"))) + s + acc))) + nil + sols))) + +(define + mod/decide-strictest + (fn + (r reports rules) + (let + ((count (mod/report-count (mod/report-about r) reports)) + (kinds (mod/classify-keywords r)) + (id (mod/report-id r))) + (let + ((program (mod/build-program r count rules))) + (let + ((db (pl-load program))) + (let + ((sols (pl-query-all db (str "policy_action(" id ", Action, Rule)")))) + (let + ((best (mod/strictest-sol sols))) + (if + (nil? best) + {:action "keep" :proof {:goals (list) :evidence kinds :conditions (list) :rule "none" :count count} :report-id id :rule "none" :strategy "strictest"} + (let + ((rule (mod/find-rule rules (dict-get best "Rule")))) + {:action (mod/rule-action rule) :proof {:goals (mod/proof-goals db id (mod/rule-when rule)) :evidence kinds :conditions (mod/rule-when rule) :rule (mod/rule-name rule) :count count} :report-id id :rule (mod/rule-name rule) :strategy "strictest"}))))))))) diff --git a/lib/mod/tests/severity.sx b/lib/mod/tests/severity.sx new file mode 100644 index 00000000..2a40c6f5 --- /dev/null +++ b/lib/mod/tests/severity.sx @@ -0,0 +1,120 @@ +;; lib/mod/tests/severity.sx — Ext 6: strictest-wins decision strategy. + +(define mod-sev-count 0) +(define mod-sev-pass 0) +(define mod-sev-fail 0) +(define mod-sev-failures (list)) + +(define + mod-sev-test! + (fn + (name got expected) + (begin + (set! mod-sev-count (+ mod-sev-count 1)) + (if + (= got expected) + (set! mod-sev-pass (+ mod-sev-pass 1)) + (begin + (set! mod-sev-fail (+ mod-sev-fail 1)) + (append! + mod-sev-failures + (str name "\n expected: " expected "\n got: " got))))))) + +;; ── severity ranking ── + +(mod-sev-test! "ban most severe" (mod/action-severity "ban") 4) +(mod-sev-test! + "remove > hide" + (< (mod/action-severity "hide") (mod/action-severity "remove")) + true) +(mod-sev-test! "keep least severe" (mod/action-severity "keep") 0) +(mod-sev-test! + "escalate above keep" + (< (mod/action-severity "keep") (mod/action-severity "escalate")) + true) + +;; ── strictest agrees with default-rules on simple cases ── + +(define mod-sev-spam (mod/mk-report "r1" "a" "b" "this is spam")) +(mod-sev-test! + "strictest spam → hide" + (get + (mod/decide-strictest mod-sev-spam (list mod-sev-spam) mod/default-rules) + :action) + "hide") +(define mod-sev-clean (mod/mk-report "r2" "a" "b" "a fine post")) +(mod-sev-test! + "strictest clean → keep" + (get + (mod/decide-strictest + mod-sev-clean + (list mod-sev-clean) + mod/default-rules) + :action) + "keep") +(mod-sev-test! + "decision tagged strategy strictest" + (get + (mod/decide-strictest mod-sev-spam (list mod-sev-spam) mod/default-rules) + :strategy) + "strictest") + +;; ── strictest diverges from first-match when order ≠ severity ── + +(define + mod-sev-rules + (list + (mod/mk-rule + "early-escalate" + :escalate (list (list :count-at-least 1))) + (mod/mk-rule "spam-remove" :remove (list (list :classification "spam"))) + (mod/mk-rule "default-keep" :keep (list)))) + +(define mod-sev-r (mod/mk-report "r3" "a" "b" "this is spam")) + +(mod-sev-test! + "first-match picks earliest rule (escalate)" + (get (mod/decide-report mod-sev-r (list mod-sev-r) mod-sev-rules) :action) + "escalate") +(mod-sev-test! + "strictest picks harshest action (remove)" + (get + (mod/decide-strictest mod-sev-r (list mod-sev-r) mod-sev-rules) + :action) + "remove") +(mod-sev-test! + "strictest names the harshest rule" + (get (mod/decide-strictest mod-sev-r (list mod-sev-r) mod-sev-rules) :rule) + "spam-remove") +(mod-sev-test! + "strictest carries proof goals" + (len + (get + (get + (mod/decide-strictest mod-sev-r (list mod-sev-r) mod-sev-rules) + :proof) + :goals)) + 1) + +;; ── strictest among three matches (spam + repeated) ── + +(define mod-sev-rep (mod/mk-report "r4" "a" "b" "buy now spam")) +(define mod-sev-reps (list mod-sev-rep mod-sev-rep mod-sev-rep)) +(mod-sev-test! + "strictest among hide+escalate+keep → hide (default rules)" + (get + (mod/decide-strictest mod-sev-rep mod-sev-reps mod/default-rules) + :action) + "hide") + +;; ── strictest-sol helper ── + +(mod-sev-test! + "strictest-sol picks max severity" + (dict-get + (mod/strictest-sol (list {:Action "keep" :Rule "k"} {:Action "remove" :Rule "r"} {:Action "hide" :Rule "h"})) + "Action") + "remove") +(mod-sev-test! "strictest-sol nil for empty" (mod/strictest-sol (list)) nil) + +(define mod-severity-tests-run! (fn () {:failures mod-sev-failures :total mod-sev-count :passed mod-sev-pass :failed mod-sev-fail})) diff --git a/plans/mod-on-sx.md b/plans/mod-on-sx.md index 8670105d..c2ba8fce 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` → **190/190** (roadmap + 5 extensions complete) +`bash lib/mod/conformance.sh` → **204/204** (roadmap + 6 extensions complete) ## Ground rules @@ -147,6 +147,11 @@ 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 6 — strictest-wins strategy** (`lib/mod/severity.sx`, +14). Alternative + to first-match: `mod/decide-strictest` collects every proven rule (`pl-query-all`) + and picks the highest-`mod/action-severity` action (keep Date: Sat, 6 Jun 2026 18:29:36 +0000 Subject: [PATCH 12/26] =?UTF-8?q?mod:=20Ext=207=20=E2=80=94=20repeat-offen?= =?UTF-8?q?der=20escalation=20(audit=20log=20as=20evidence),=20223/223?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mod/subject-sanctions counts prior hide/remove/ban decisions about a subject from the append-only audit log; mod/decide-escalating upgrades a sanction to :ban when the subject has >= k priors. Non-sanction outcomes (keep/escalate) pass through. Closes the loop between audit and policy — the trail feeds future decisions. Own suite. +19 tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/mod/conformance.conf | 2 + lib/mod/offenders.sx | 59 +++++++++++++++++++ lib/mod/scoreboard.json | 9 +-- lib/mod/scoreboard.md | 3 +- lib/mod/tests/offenders.sx | 115 +++++++++++++++++++++++++++++++++++++ plans/mod-on-sx.md | 15 ++++- 6 files changed, 197 insertions(+), 6 deletions(-) create mode 100644 lib/mod/offenders.sx create mode 100644 lib/mod/tests/offenders.sx diff --git a/lib/mod/conformance.conf b/lib/mod/conformance.conf index ccc017a9..ee8f911a 100644 --- a/lib/mod/conformance.conf +++ b/lib/mod/conformance.conf @@ -15,6 +15,7 @@ PRELOADS=( lib/mod/engine.sx lib/mod/explain.sx lib/mod/severity.sx + lib/mod/offenders.sx lib/mod/lifecycle.sx lib/mod/audit.sx lib/mod/api.sx @@ -32,4 +33,5 @@ SUITES=( "link:lib/mod/tests/link.sx:(mod-link-tests-run!)" "lint:lib/mod/tests/lint.sx:(mod-lint-tests-run!)" "severity:lib/mod/tests/severity.sx:(mod-severity-tests-run!)" + "offenders:lib/mod/tests/offenders.sx:(mod-offenders-tests-run!)" ) diff --git a/lib/mod/offenders.sx b/lib/mod/offenders.sx new file mode 100644 index 00000000..03249ad3 --- /dev/null +++ b/lib/mod/offenders.sx @@ -0,0 +1,59 @@ +;; lib/mod/offenders.sx — repeat-offender escalation (audit log as evidence). +;; +;; The append-only audit trail is itself a source of evidence: a subject already +;; sanctioned several times is a repeat offender. mod/decide-escalating decides a +;; report normally, then — if the action is a sanction and the subject has at +;; least k PRIOR sanctions in the audit log — upgrades it to :ban. This is the one +;; place a decision depends on history beyond the single report, and it reads that +;; history from the audit log rather than re-deriving it. + +(define + mod/sanction? + (fn + (action) + (mod/any? (fn (a) (= a action)) (list "hide" "remove" "ban")))) + +;; count of prior sanctioning decisions in the audit log about a subject +(define + mod/subject-sanctions + (fn + (subject) + (reduce + (fn + (acc e) + (let + ((r (mod/get-report (get e :report-id)))) + (if + (nil? r) + acc + (if + (if + (= (mod/report-about r) subject) + (mod/sanction? (get e :action)) + false) + (+ acc 1) + acc)))) + 0 + (mod/audit-all)))) + +(define + mod/repeat-offender? + (fn (subject k) (<= k (mod/subject-sanctions subject)))) + +(define + mod/decide-escalating + (fn + (id k) + (let + ((r (mod/get-report id))) + (if + (nil? r) + nil + (let + ((priors (mod/subject-sanctions (mod/report-about r)))) + (let + ((d (mod/decide id))) + (if + (if (mod/sanction? (get d :action)) (<= k priors) false) + {:action "ban" :proof {:goals (get (get d :proof) :goals) :prior-sanctions priors :evidence (get (get d :proof) :evidence) :conditions (list) :rule "repeat-offender-ban" :count (get (get d :proof) :count)} :report-id id :rule "repeat-offender-ban" :strategy "escalating"} + d))))))) diff --git a/lib/mod/scoreboard.json b/lib/mod/scoreboard.json index 5f185e5d..f6fa6337 100644 --- a/lib/mod/scoreboard.json +++ b/lib/mod/scoreboard.json @@ -1,8 +1,8 @@ { "lang": "mod", - "total_passed": 204, + "total_passed": 223, "total_failed": 0, - "total": 204, + "total": 223, "suites": [ {"name":"decide","passed":31,"failed":0,"total":31}, {"name":"audit","passed":29,"failed":0,"total":29}, @@ -11,7 +11,8 @@ {"name":"extensions","passed":32,"failed":0,"total":32}, {"name":"link","passed":12,"failed":0,"total":12}, {"name":"lint","passed":14,"failed":0,"total":14}, - {"name":"severity","passed":14,"failed":0,"total":14} + {"name":"severity","passed":14,"failed":0,"total":14}, + {"name":"offenders","passed":19,"failed":0,"total":19} ], - "generated": "2026-06-06T18:19:30+00:00" + "generated": "2026-06-06T18:29:10+00:00" } diff --git a/lib/mod/scoreboard.md b/lib/mod/scoreboard.md index 0fe3c277..645ce026 100644 --- a/lib/mod/scoreboard.md +++ b/lib/mod/scoreboard.md @@ -1,6 +1,6 @@ # mod scoreboard -**204 / 204 passing** (0 failure(s)). +**223 / 223 passing** (0 failure(s)). | Suite | Passed | Total | Status | |-------|--------|-------|--------| @@ -12,3 +12,4 @@ | link | 12 | 12 | ok | | lint | 14 | 14 | ok | | severity | 14 | 14 | ok | +| offenders | 19 | 19 | ok | diff --git a/lib/mod/tests/offenders.sx b/lib/mod/tests/offenders.sx new file mode 100644 index 00000000..319930c4 --- /dev/null +++ b/lib/mod/tests/offenders.sx @@ -0,0 +1,115 @@ +;; lib/mod/tests/offenders.sx — Ext 7: repeat-offender escalation. + +(define mod-off-count 0) +(define mod-off-pass 0) +(define mod-off-fail 0) +(define mod-off-failures (list)) + +(define + mod-off-test! + (fn + (name got expected) + (begin + (set! mod-off-count (+ mod-off-count 1)) + (if + (= got expected) + (set! mod-off-pass (+ mod-off-pass 1)) + (begin + (set! mod-off-fail (+ mod-off-fail 1)) + (append! + mod-off-failures + (str name "\n expected: " expected "\n got: " got))))))) + +;; ── sanction? predicate ── + +(mod-off-test! "hide is a sanction" (mod/sanction? "hide") true) +(mod-off-test! "remove is a sanction" (mod/sanction? "remove") true) +(mod-off-test! "ban is a sanction" (mod/sanction? "ban") true) +(mod-off-test! "keep is not a sanction" (mod/sanction? "keep") false) +(mod-off-test! "escalate is not a sanction" (mod/sanction? "escalate") false) + +;; ── repeat-offender escalation over the audit log ── + +(mod/reset!) +(mod/report "u1" "spammer" "this is spam") +(mod/report "u2" "spammer" "buy now offer") +(mod/report "u3" "spammer" "click here free money") +(mod/report "u4" "innocent" "fine post") + +(mod-off-test! + "no sanctions before any decision" + (mod/subject-sanctions "spammer") + 0) + +(define mod-off-d1 (mod/decide-escalating "r1" 2)) +(mod-off-test! + "first spam → hide (0 priors)" + (get mod-off-d1 :action) + "hide") +(mod-off-test! + "one sanction recorded" + (mod/subject-sanctions "spammer") + 1) + +(define mod-off-d2 (mod/decide-escalating "r2" 2)) +(mod-off-test! + "second spam → hide (1 prior, below k=2)" + (get mod-off-d2 :action) + "hide") +(mod-off-test! + "two sanctions recorded" + (mod/subject-sanctions "spammer") + 2) + +(define mod-off-d3 (mod/decide-escalating "r3" 2)) +(mod-off-test! + "third spam → ban (2 priors ≥ k)" + (get mod-off-d3 :action) + "ban") +(mod-off-test! + "ban decision names repeat-offender rule" + (get mod-off-d3 :rule) + "repeat-offender-ban") +(mod-off-test! + "ban proof records prior sanction count" + (get (get mod-off-d3 :proof) :prior-sanctions) + 2) + +;; ── different subjects accumulate independently ── + +(define mod-off-d4 (mod/decide-escalating "r4" 2)) +(mod-off-test! + "innocent keep → not escalated" + (get mod-off-d4 :action) + "keep") +(mod-off-test! + "innocent has no sanctions" + (mod/subject-sanctions "innocent") + 0) +(mod-off-test! + "repeat-offender? true for spammer at k=2" + (mod/repeat-offender? "spammer" 2) + true) +(mod-off-test! + "repeat-offender? false for innocent at k=1" + (mod/repeat-offender? "innocent" 1) + false) + +;; ── non-sanction decisions are never upgraded to ban ── +;; r5 is a clean post, but it is the 4th report about "spammer", so the +;; repeated-report rule escalates it. escalate is not a sanction, so it passes +;; through decide-escalating unchanged (never becomes :ban). + +(mod/report "u5" "spammer" "a perfectly fine post") +(define mod-off-d5 (mod/decide-escalating "r5" 1)) +(mod-off-test! + "non-sanction (escalate) decision is not upgraded to ban" + (get mod-off-d5 :action) + "escalate") + +(mod-off-test! + "decide-escalating unknown id → nil" + (mod/decide-escalating "r99" 2) + nil) + +(define mod-offenders-tests-run! (fn () {:failures mod-off-failures :total mod-off-count :passed mod-off-pass :failed mod-off-fail})) diff --git a/plans/mod-on-sx.md b/plans/mod-on-sx.md index c2ba8fce..4e17b71c 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` → **204/204** (roadmap + 6 extensions complete) +`bash lib/mod/conformance.sh` → **223/223** (roadmap + 7 extensions complete) ## Ground rules @@ -147,6 +147,13 @@ 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 7 — repeat-offender escalation** (`lib/mod/offenders.sx`, +19). The + audit log as evidence: `mod/subject-sanctions` counts prior hide/remove/ban + decisions about a subject; `mod/decide-escalating id k` decides normally then + upgrades a *sanction* to `:ban` when the subject already has ≥k prior sanctions. + Non-sanction outcomes (keep/escalate) pass through untouched. First decision + whose input spans history beyond the single report — read from the trail, not + re-derived. - [x] **Ext 6 — strictest-wins strategy** (`lib/mod/severity.sx`, +14). Alternative to first-match: `mod/decide-strictest` collects every proven rule (`pl-query-all`) and picks the highest-`mod/action-severity` action (keep Date: Sat, 6 Jun 2026 18:30:44 +0000 Subject: [PATCH 13/26] =?UTF-8?q?briefings:=20mod-loop=20=E2=80=94=20cut/b?= =?UTF-8?q?acktracking=20allowance=20+=20sx=5Fwrite=5Ffile-first=20+=20loa?= =?UTF-8?q?ded-env/not(Goal)=20gotchas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make explicit that the loop may lean on Prolog backtracking (pl-query-all) and cut, preferring clause-order precedence via pl-query-one. Default to sx_write_file over path/pattern edits; flag that sx_insert_near drops all but the first form. Document the loaded-env primitive restriction (includes?/chars/etc. undefined after prolog preloads; use the tokenizer's surviving set) and that negation is the not(Goal) functor, not the prefix \+ operator. Co-Authored-By: Claude Opus 4.8 (1M context) --- plans/agent-briefings/mod-loop.md | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/plans/agent-briefings/mod-loop.md b/plans/agent-briefings/mod-loop.md index 2353de14..9f1e2c6c 100644 --- a/plans/agent-briefings/mod-loop.md +++ b/plans/agent-briefings/mod-loop.md @@ -66,7 +66,12 @@ Every iteration: implement → test → commit → tick `[ ]` → Progress log like a broken binary but is just a param mismatch. `sx_validate` after edits. Path-based edits (`sx_replace_node`) count comment headers in their indices and can clobber the wrong node — re-read after, or prefer `sx_write_file` for small - files. + files. **Default to `sx_write_file` (rewrite the whole file) over path/pattern + edits** — these are small files and the rewrite always parses-before-writing. + `sx_insert_near` inserts only the FIRST top-level form of a multi-form source + (it silently drops the rest; byte count barely moves) — never use it to add a + block of forms; rewrite the file instead. `sx_replace_by_pattern` is fiddly to + match — don't fight it, just rewrite. - **Unicode in `.sx`:** raw UTF-8 only, never `\uXXXX` escapes. - **Commit granularity:** one feature per commit. Short factual messages (`mod: spam-keyword policy rule → :hide + 6 tests`). Push to `origin/loops/mod`. @@ -82,8 +87,30 @@ Every iteration: implement → test → commit → tick `[ ]` → Progress log precedence explicit and deterministic (tests will depend on it). A "no rule matched" outcome must be a real, testable result (`:keep`), not a query failure you forget to handle. +- **You may lean on backtracking and cut.** The substrate is full Prolog — + `pl-query-all` gives every proven clause (use it for "strictest-wins" or + multi-match analysis), `pl-query-one` gives the first (clause order = precedence). + Cut (`!`) and the other control constructs are available if you need to prune + alternatives inside a body, but for rule precedence prefer plain clause ordering + resolved by `pl-query-one` — it's the clean, testable default. Don't hand-roll + precedence in SX when the engine's backtracking already gives it to you. - **Negative decisions need closed-world care.** "No evidence of violation" vs "evidence absent" differ. Be explicit about negation-as-failure where you use it. + In this substrate, negation is the **functor** `not(Goal)` / `\+(Goal)` — the + prefix `\+ Goal` operator does **not** parse. Unknown predicates *fail* (no + existence error), so a report lacking some fact safely falls through a rule that + references it. Quote user-data atoms (`'foo-bar'`) — a bare hyphen is the minus + operator and will misparse. +- **Loaded-env strips the high-level string prims.** After the prolog preloads are + loaded, the eval env loses `includes?`, `chars`, `str-join`, `keyword` and + friends — they are **undefined** (a function calling one fails only when called, + often mid-test-load, looking like a mystery crash). Only the set the Prolog + tokenizer itself uses survives: `slice`, `len`, `nth`, `=`, `join` (sep first: + `(join sep list)`), `downcase`, `map`, `reduce`, `append`/`append!`, `when`, + `cond`, `if`, `let`, `begin`, `get`, `dict-get`, `keys`, `empty?`, `first`, + `reverse`, `+`, `-`, `<`, `<=`. Build substring search yourself over `slice`/ + `len` (see `mod/str-contains?`). Treat `not`, `and`, `or`, `>` as suspect in + guest code unless you've confirmed them — nest `if`/`when` and use `(< a b)`. - **Lifecycle state is separate from policy.** Keep the state machine (Phase 3) as an SX module over the engine, not tangled into Prolog rules. - **Federation trust is advisory by default.** A peer's decision only binds locally From 062a76e64f3a42553ae7f9f665089a988b2e7ef6 Mon Sep 17 00:00:00 2001 From: giles Date: Sat, 6 Jun 2026 18:45:28 +0000 Subject: [PATCH 14/26] =?UTF-8?q?mod:=20Ext=208=20=E2=80=94=20quorum=20ove?= =?UTF-8?q?r=20distinct=20reporters=20(anti-brigade),=20232/232?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (:reporters-at-least N) compiles to setof(Br, report(_, Br, Sr), Bsr), length(Bsr, Nr), Nr >= N — counts distinct reporters, not raw reports. mod/decide-quorum asserts every report's report/3 fact (base engine scopes to the decided report) so Prolog can aggregate reporters. One user filing 3 reports stays :keep under quorum while the count rule escalates. Own suite. +9 tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/mod/conformance.conf | 2 + lib/mod/policy.sx | 22 ++++++-- lib/mod/quorum.sx | 40 +++++++++++++ lib/mod/scoreboard.json | 9 +-- lib/mod/scoreboard.md | 3 +- lib/mod/tests/quorum.sx | 119 +++++++++++++++++++++++++++++++++++++++ plans/mod-on-sx.md | 17 +++++- 7 files changed, 200 insertions(+), 12 deletions(-) create mode 100644 lib/mod/quorum.sx create mode 100644 lib/mod/tests/quorum.sx diff --git a/lib/mod/conformance.conf b/lib/mod/conformance.conf index ee8f911a..70b99efc 100644 --- a/lib/mod/conformance.conf +++ b/lib/mod/conformance.conf @@ -16,6 +16,7 @@ PRELOADS=( lib/mod/explain.sx lib/mod/severity.sx lib/mod/offenders.sx + lib/mod/quorum.sx lib/mod/lifecycle.sx lib/mod/audit.sx lib/mod/api.sx @@ -34,4 +35,5 @@ SUITES=( "lint:lib/mod/tests/lint.sx:(mod-lint-tests-run!)" "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!)" ) diff --git a/lib/mod/policy.sx b/lib/mod/policy.sx index 3d1cde58..3ac585c8 100644 --- a/lib/mod/policy.sx +++ b/lib/mod/policy.sx @@ -40,12 +40,15 @@ ;; ── condition → Prolog goal ── ;; -;; (:classification "spam") → classification(Id, spam) -;; (:evidence "kind") → evidence(Id, 'kind', _) -;; (:attr "verified") → attr(Id, verified) -;; (:not ) → not() (negation as failure) -;; (:count-at-least 3) → report(Id, B, S), report_count(S, N), N >= 3 -;; (:score-at-least 5) → aggregate_all(sum(W), signal(Id, _, W), T), T >= 5 +;; (:classification "spam") → classification(Id, spam) +;; (:evidence "kind") → evidence(Id, 'kind', _) +;; (:attr "verified") → attr(Id, verified) +;; (:not ) → not() (negation as failure) +;; (:count-at-least 3) → report(Id, B, S), report_count(S, N), N >= 3 +;; (:score-at-least 5) → aggregate_all(sum(W), signal(Id, _, W), T), T >= 5 +;; (:reporters-at-least 2) → report(Id, _, Sr), setof(Br, report(_, Br, Sr), Bsr), +;; length(Bsr, Nr), Nr >= 2 (distinct reporters; +;; needs the quorum engine which asserts every report) (define mod/cond->goal @@ -78,6 +81,13 @@ idterm ", _, W), T), T >= " (nth c 1))) + ((= tag :reporters-at-least) + (str + "report(" + idterm + ", _, Sr), setof(Br, report(_, Br, Sr), Bsr), " + "length(Bsr, Nr), Nr >= " + (nth c 1))) (true "true"))))) (define diff --git a/lib/mod/quorum.sx b/lib/mod/quorum.sx new file mode 100644 index 00000000..4a549d68 --- /dev/null +++ b/lib/mod/quorum.sx @@ -0,0 +1,40 @@ +;; lib/mod/quorum.sx — quorum decisions over distinct reporters (anti-brigade). +;; +;; The base engine asserts only the decided report's report/3 fact, so it can't +;; reason about WHO reported a subject. The quorum engine additionally asserts +;; every report's report/3 fact (via link's rel-facts), letting a rule require N +;; *distinct* reporters with `setof`/`length` — so one user filing many reports +;; does not manufacture consensus. Same decision shape as the base engine, plus +;; :strategy "quorum". + +(define + mod/build-quorum-program + (fn + (r count reports rules) + (str + (mod/report-rel-facts reports) + "\n" + (mod/report-facts r count) + "\n" + (mod/rules->program rules)))) + +(define + mod/decide-quorum + (fn + (r reports rules) + (let + ((count (mod/report-count (mod/report-about r) reports)) + (kinds (mod/classify-keywords r)) + (id (mod/report-id r))) + (let + ((program (mod/build-quorum-program r count reports rules))) + (let + ((db (pl-load program))) + (let + ((sol (pl-query-one db (str "policy_action(" id ", Action, Rule)")))) + (if + (nil? sol) + {:action "keep" :proof {:goals (list) :evidence kinds :conditions (list) :rule "none" :count count} :report-id id :rule "none" :strategy "quorum"} + (let + ((rule (mod/find-rule rules (dict-get sol "Rule")))) + {:action (mod/rule-action rule) :proof {:goals (mod/proof-goals db id (mod/rule-when rule)) :evidence kinds :conditions (mod/rule-when rule) :rule (mod/rule-name rule) :count count} :report-id id :rule (mod/rule-name rule) :strategy "quorum"})))))))) diff --git a/lib/mod/scoreboard.json b/lib/mod/scoreboard.json index f6fa6337..3ce936ee 100644 --- a/lib/mod/scoreboard.json +++ b/lib/mod/scoreboard.json @@ -1,8 +1,8 @@ { "lang": "mod", - "total_passed": 223, + "total_passed": 232, "total_failed": 0, - "total": 223, + "total": 232, "suites": [ {"name":"decide","passed":31,"failed":0,"total":31}, {"name":"audit","passed":29,"failed":0,"total":29}, @@ -12,7 +12,8 @@ {"name":"link","passed":12,"failed":0,"total":12}, {"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":"offenders","passed":19,"failed":0,"total":19}, + {"name":"quorum","passed":9,"failed":0,"total":9} ], - "generated": "2026-06-06T18:29:10+00:00" + "generated": "2026-06-06T18:44:47+00:00" } diff --git a/lib/mod/scoreboard.md b/lib/mod/scoreboard.md index 645ce026..9b12db6a 100644 --- a/lib/mod/scoreboard.md +++ b/lib/mod/scoreboard.md @@ -1,6 +1,6 @@ # mod scoreboard -**223 / 223 passing** (0 failure(s)). +**232 / 232 passing** (0 failure(s)). | Suite | Passed | Total | Status | |-------|--------|-------|--------| @@ -13,3 +13,4 @@ | lint | 14 | 14 | ok | | severity | 14 | 14 | ok | | offenders | 19 | 19 | ok | +| quorum | 9 | 9 | ok | diff --git a/lib/mod/tests/quorum.sx b/lib/mod/tests/quorum.sx new file mode 100644 index 00000000..670e3f64 --- /dev/null +++ b/lib/mod/tests/quorum.sx @@ -0,0 +1,119 @@ +;; lib/mod/tests/quorum.sx — Ext 8: quorum over distinct reporters. + +(define mod-q-count 0) +(define mod-q-pass 0) +(define mod-q-fail 0) +(define mod-q-failures (list)) + +(define + mod-q-test! + (fn + (name got expected) + (begin + (set! mod-q-count (+ mod-q-count 1)) + (if + (= got expected) + (set! mod-q-pass (+ mod-q-pass 1)) + (begin + (set! mod-q-fail (+ mod-q-fail 1)) + (append! + mod-q-failures + (str name "\n expected: " expected "\n got: " got))))))) + +(define + mod-q-rules + (list + (mod/mk-rule + "quorum-hide" + :hide (list (list :reporters-at-least 2))) + (mod/mk-rule "default-keep" :keep (list)))) + +;; ── two distinct reporters meet quorum ── + +(define + mod-q-two + (list + (mod/mk-report "r1" "alice" "bob" "off-topic") + (mod/mk-report "r2" "carol" "bob" "off-topic"))) + +(mod-q-test! + "two distinct reporters → hide" + (get (mod/decide-quorum (first mod-q-two) mod-q-two mod-q-rules) :action) + "hide") +(mod-q-test! + "quorum decision names the rule" + (get (mod/decide-quorum (first mod-q-two) mod-q-two mod-q-rules) :rule) + "quorum-hide") +(mod-q-test! + "quorum decision tagged strategy" + (get (mod/decide-quorum (first mod-q-two) mod-q-two mod-q-rules) :strategy) + "quorum") + +;; ── single reporter does not meet quorum ── + +(define mod-q-one (list (mod/mk-report "r1" "alice" "bob" "off-topic"))) +(mod-q-test! + "one reporter → keep (below quorum)" + (get (mod/decide-quorum (first mod-q-one) mod-q-one mod-q-rules) :action) + "keep") + +;; ── anti-brigade: one user filing many reports does NOT meet quorum ── + +(define + mod-q-brigade + (list + (mod/mk-report "r1" "alice" "bob" "off-topic") + (mod/mk-report "r2" "alice" "bob" "off-topic") + (mod/mk-report "r3" "alice" "bob" "off-topic"))) + +(mod-q-test! + "three reports, one reporter → keep (quorum counts distinct)" + (get + (mod/decide-quorum (first mod-q-brigade) mod-q-brigade mod-q-rules) + :action) + "keep") + +;; contrast: the count rule WOULD fire on the same brigade (3 reports ≥ 3) — +;; quorum is strictly stronger against single-actor brigading +(mod-q-test! + "count rule fires on the brigade (distinct from quorum)" + (get + (mod/decide-report (first mod-q-brigade) mod-q-brigade mod/default-rules) + :action) + "escalate") + +;; ── three distinct reporters ── + +(define + mod-q-three + (list + (mod/mk-report "r1" "alice" "bob" "off-topic") + (mod/mk-report "r2" "carol" "bob" "off-topic") + (mod/mk-report "r3" "dave" "bob" "off-topic"))) + +(mod-q-test! + "three distinct reporters → hide" + (get + (mod/decide-quorum (first mod-q-three) mod-q-three mod-q-rules) + :action) + "hide") +(mod-q-test! + "quorum proof goal solved" + (get + (first + (get + (get + (mod/decide-quorum (first mod-q-three) mod-q-three mod-q-rules) + :proof) + :goals)) + :solved) + true) + +;; ── cond->goal compiles :reporters-at-least ── + +(mod-q-test! + "cond->goal :reporters-at-least" + (mod/cond->goal (list :reporters-at-least 2) "Id") + "report(Id, _, Sr), setof(Br, report(_, Br, Sr), Bsr), length(Bsr, Nr), Nr >= 2") + +(define mod-quorum-tests-run! (fn () {:failures mod-q-failures :total mod-q-count :passed mod-q-pass :failed mod-q-fail})) diff --git a/plans/mod-on-sx.md b/plans/mod-on-sx.md index 4e17b71c..103de69c 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` → **223/223** (roadmap + 7 extensions complete) +`bash lib/mod/conformance.sh` → **232/232** (roadmap + 8 extensions complete) ## Ground rules @@ -147,6 +147,14 @@ 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 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. + `mod/decide-quorum` asserts every report's `report/3` fact (the base engine only + asserts the decided one) so Prolog can aggregate reporters. Verified one user + filing 3 reports stays `:keep` under quorum while the count rule would escalate. + (Substrate note: `^` existential doesn't parse; `setof(B, p(_, B, S), …)` with `_` + yields the distinct set in a single solution here.) - [x] **Ext 7 — repeat-offender escalation** (`lib/mod/offenders.sx`, +19). The audit log as evidence: `mod/subject-sanctions` counts prior hide/remove/ban decisions about a subject; `mod/decide-escalating id k` decides normally then @@ -172,6 +180,13 @@ lib/mod/fed.sx ## Progress log +- **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 + report). Demonstrates the substrate handles set-aggregation, and that the + brigade case (one actor, many reports) is defeated by counting reporters not + reports. `^` existential doesn't parse here — `setof(B, p(_,B,S), …)` with `_` + gives the distinct set in one solution. - **Ext 7 — repeat-offender escalation, 223/223** (+19). Decisions can now depend on history: the append-only audit log is read back as evidence, and a subject with k prior sanctions has its next sanction upgraded to `:ban`. Closes the loop From 3764b62206899ac660eaf37f69b477c385847bf9 Mon Sep 17 00:00:00 2001 From: giles Date: Sat, 6 Jun 2026 18:48:44 +0000 Subject: [PATCH 15/26] =?UTF-8?q?mod:=20Ext=209=20=E2=80=94=20policy=20dry?= =?UTF-8?q?-run=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 From bf65de7b24db152f9587274be5c2e8d2c425f3b8 Mon Sep 17 00:00:00 2001 From: giles Date: Sat, 6 Jun 2026 18:51:47 +0000 Subject: [PATCH 16/26] =?UTF-8?q?mod:=20Ext=2010=20=E2=80=94=20policy=20wh?= =?UTF-8?q?at-if=20/=20impact=20analysis,=20260/260?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mod/decision-diff compares one report's action under two rule sets; mod/policy-impact batches a set and returns only the reports whose decision flips; mod/impact-count / mod/impact-report summarize. Lets a mod team measure a policy change's blast radius before shipping (e.g. removing spam-hide flips r1 hide→keep). Pure SX over decide-report. +13 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/whatif.sx | 117 +++++++++++++++++++++++++++++++++++++++ lib/mod/whatif.sx | 56 +++++++++++++++++++ plans/mod-on-sx.md | 13 ++++- 6 files changed, 194 insertions(+), 6 deletions(-) create mode 100644 lib/mod/tests/whatif.sx create mode 100644 lib/mod/whatif.sx diff --git a/lib/mod/conformance.conf b/lib/mod/conformance.conf index 75243257..517be3c0 100644 --- a/lib/mod/conformance.conf +++ b/lib/mod/conformance.conf @@ -18,6 +18,7 @@ PRELOADS=( lib/mod/offenders.sx lib/mod/quorum.sx lib/mod/trace.sx + lib/mod/whatif.sx lib/mod/lifecycle.sx lib/mod/audit.sx lib/mod/api.sx @@ -38,4 +39,5 @@ SUITES=( "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!)" + "whatif:lib/mod/tests/whatif.sx:(mod-whatif-tests-run!)" ) diff --git a/lib/mod/scoreboard.json b/lib/mod/scoreboard.json index 6282142f..3c9acdc6 100644 --- a/lib/mod/scoreboard.json +++ b/lib/mod/scoreboard.json @@ -1,8 +1,8 @@ { "lang": "mod", - "total_passed": 247, + "total_passed": 260, "total_failed": 0, - "total": 247, + "total": 260, "suites": [ {"name":"decide","passed":31,"failed":0,"total":31}, {"name":"audit","passed":29,"failed":0,"total":29}, @@ -14,7 +14,8 @@ {"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":"trace","passed":15,"failed":0,"total":15} + {"name":"trace","passed":15,"failed":0,"total":15}, + {"name":"whatif","passed":13,"failed":0,"total":13} ], - "generated": "2026-06-06T18:48:10+00:00" + "generated": "2026-06-06T18:51:15+00:00" } diff --git a/lib/mod/scoreboard.md b/lib/mod/scoreboard.md index 04d8ae2d..365c2351 100644 --- a/lib/mod/scoreboard.md +++ b/lib/mod/scoreboard.md @@ -1,6 +1,6 @@ # mod scoreboard -**247 / 247 passing** (0 failure(s)). +**260 / 260 passing** (0 failure(s)). | Suite | Passed | Total | Status | |-------|--------|-------|--------| @@ -15,3 +15,4 @@ | offenders | 19 | 19 | ok | | quorum | 9 | 9 | ok | | trace | 15 | 15 | ok | +| whatif | 13 | 13 | ok | diff --git a/lib/mod/tests/whatif.sx b/lib/mod/tests/whatif.sx new file mode 100644 index 00000000..d4eb8099 --- /dev/null +++ b/lib/mod/tests/whatif.sx @@ -0,0 +1,117 @@ +;; lib/mod/tests/whatif.sx — Ext 10: policy what-if / impact analysis. + +(define mod-wi-count 0) +(define mod-wi-pass 0) +(define mod-wi-fail 0) +(define mod-wi-failures (list)) + +(define + mod-wi-test! + (fn + (name got expected) + (begin + (set! mod-wi-count (+ mod-wi-count 1)) + (if + (= got expected) + (set! mod-wi-pass (+ mod-wi-pass 1)) + (begin + (set! mod-wi-fail (+ mod-wi-fail 1)) + (append! + mod-wi-failures + (str name "\n expected: " expected "\n got: " got))))))) + +;; rules-b is the default policy with spam-hide removed: spam now falls through +;; to default-keep. A spam report flips hide → keep; everything else is unchanged. +(define mod-wi-rules-a mod/default-rules) +(define + mod-wi-rules-b + (list + (mod/mk-rule + "reviewer-remove" + :remove (list (list :evidence "confirmed-abuse"))) + (mod/mk-rule + "abuse-remove" + :remove (list (list :classification "abuse"))) + (mod/mk-rule + "repeated-escalate" + :escalate (list (list :count-at-least 3))) + (mod/mk-rule "default-keep" :keep (list)))) + +(define mod-wi-spam (mod/mk-report "r1" "a" "bob" "this is spam")) +(define mod-wi-abuse (mod/mk-report "r2" "a" "carol" "harassment here")) +(define mod-wi-clean (mod/mk-report "r3" "a" "dave" "a fine post")) + +;; ── single-report diff ── + +(define + mod-wi-d + (mod/decision-diff + mod-wi-spam + (list mod-wi-spam) + mod-wi-rules-a + mod-wi-rules-b)) +(mod-wi-test! "spam before = hide" (get mod-wi-d :before) "hide") +(mod-wi-test! "spam after = keep" (get mod-wi-d :after) "keep") +(mod-wi-test! "spam decision flips" (get mod-wi-d :changed) true) +(mod-wi-test! "diff carries report id" (get mod-wi-d :report-id) "r1") + +(define + mod-wi-da + (mod/decision-diff + mod-wi-abuse + (list mod-wi-abuse) + mod-wi-rules-a + mod-wi-rules-b)) +(mod-wi-test! "abuse unchanged (remove both)" (get mod-wi-da :changed) false) +(mod-wi-test! "abuse stays remove" (get mod-wi-da :after) "remove") + +(define + mod-wi-dc + (mod/decision-diff + mod-wi-clean + (list mod-wi-clean) + mod-wi-rules-a + mod-wi-rules-b)) +(mod-wi-test! "clean unchanged (keep both)" (get mod-wi-dc :changed) false) + +;; ── batch impact ── + +(define mod-wi-batch (list mod-wi-spam mod-wi-abuse mod-wi-clean)) +(define + mod-wi-impact + (mod/policy-impact mod-wi-batch mod-wi-rules-a mod-wi-rules-b)) + +(mod-wi-test! + "impact lists only changed reports" + (len mod-wi-impact) + 1) +(mod-wi-test! + "impacted report is the spam one" + (get (first mod-wi-impact) :report-id) + "r1") +(mod-wi-test! + "impact-count agrees" + (mod/impact-count mod-wi-batch mod-wi-rules-a mod-wi-rules-b) + 1) + +;; ── identical rule sets → no impact ── + +(mod-wi-test! + "same rules → zero impact" + (mod/impact-count mod-wi-batch mod-wi-rules-a mod-wi-rules-a) + 0) +(mod-wi-test! + "same rules → empty report" + (mod/impact-report mod-wi-batch mod-wi-rules-a mod-wi-rules-a) + "No decisions change.") + +;; ── rendering ── + +(mod-wi-test! + "impact-report renders the flip" + (mod/str-contains? + (mod/impact-report mod-wi-batch mod-wi-rules-a mod-wi-rules-b) + "r1: hide → keep") + true) + +(define mod-whatif-tests-run! (fn () {:failures mod-wi-failures :total mod-wi-count :passed mod-wi-pass :failed mod-wi-fail})) diff --git a/lib/mod/whatif.sx b/lib/mod/whatif.sx new file mode 100644 index 00000000..23b24eac --- /dev/null +++ b/lib/mod/whatif.sx @@ -0,0 +1,56 @@ +;; lib/mod/whatif.sx — policy what-if / impact analysis. +;; +;; Before shipping a policy change, a moderation team needs to know which past or +;; pending reports would decide differently. mod/decision-diff compares one +;; report's action under two rule sets; mod/policy-impact runs a whole batch and +;; returns only the reports whose decision flips. Pure SX over decide-report. + +(define + mod/decision-diff + (fn + (r reports rules-a rules-b) + (let + ((a (get (mod/decide-report r reports rules-a) :action)) + (b (get (mod/decide-report r reports rules-b) :action))) + {:after b :changed (if (= a b) false true) :report-id (mod/report-id r) :before a}))) + +(define + mod/policy-impact + (fn + (reports rules-a rules-b) + (reduce + (fn + (acc r) + (let + ((d (mod/decision-diff r reports rules-a rules-b))) + (if (get d :changed) (append acc (list d)) acc))) + (list) + reports))) + +(define + mod/impact-count + (fn + (reports rules-a rules-b) + (len (mod/policy-impact reports rules-a rules-b)))) + +(define + mod/impact-report + (fn + (reports rules-a rules-b) + (let + ((changed (mod/policy-impact reports rules-a rules-b))) + (if + (empty? changed) + "No decisions change." + (mod/join-with + "\n" + (map + (fn + (d) + (str + (get d :report-id) + ": " + (get d :before) + " → " + (get d :after))) + changed)))))) diff --git a/plans/mod-on-sx.md b/plans/mod-on-sx.md index 7ff76ea3..5e074024 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` → **247/247** (roadmap + 9 extensions complete) +`bash lib/mod/conformance.sh` → **260/260** (roadmap + 10 extensions complete) ## Ground rules @@ -147,6 +147,11 @@ 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 10 — policy what-if / impact** (`lib/mod/whatif.sx`, +13). + `mod/decision-diff` compares one report's action under two rule sets; + `mod/policy-impact` runs a batch and returns only the reports whose decision + flips; `mod/impact-count` / `mod/impact-report` summarize. Lets a team measure a + policy change before shipping it (e.g. "removing spam-hide flips r1 hide→keep"). - [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 @@ -186,6 +191,12 @@ lib/mod/fed.sx ## Progress log +- **Ext 10 — policy what-if / impact, 260/260** (+13). Decisions are now + comparable across rule sets — diff one report, or batch a whole set and surface + only the flips. Pure SX over `decide-report`, no engine change. Closes the + policy-authoring loop alongside lint (Ext 5) and trace (Ext 9): lint checks + well-formedness, trace explains one report, what-if measures a change's blast + radius before it ships. - **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), From 8292607e388f729017affa3a82cc74391801be8d Mon Sep 17 00:00:00 2001 From: giles Date: Sat, 6 Jun 2026 18:56:19 +0000 Subject: [PATCH 17/26] =?UTF-8?q?mod:=20Ext=2011=20=E2=80=94=20batch=20tri?= =?UTF-8?q?age=20+=20corpus=20analytics,=20277/277?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mod/decide-batch triages a queue; mod/action-histogram summarizes outcomes by action; mod/rule-coverage + mod/never-fired measure which rules fire across a corpus — the empirical complement to lint's static unreachable check (lint finds rules that can't fire; never-fired finds rules that didn't). +17 tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/mod/batch.sx | 55 +++++++++++++++++++++ lib/mod/conformance.conf | 2 + lib/mod/scoreboard.json | 9 ++-- lib/mod/scoreboard.md | 3 +- lib/mod/tests/batch.sx | 101 +++++++++++++++++++++++++++++++++++++++ plans/mod-on-sx.md | 12 ++++- 6 files changed, 176 insertions(+), 6 deletions(-) create mode 100644 lib/mod/batch.sx create mode 100644 lib/mod/tests/batch.sx diff --git a/lib/mod/batch.sx b/lib/mod/batch.sx new file mode 100644 index 00000000..2ef4e618 --- /dev/null +++ b/lib/mod/batch.sx @@ -0,0 +1,55 @@ +;; lib/mod/batch.sx — batch triage + corpus analytics. +;; +;; Operational layer: decide a whole queue of reports at once, summarize the +;; outcomes by action, and measure which rules actually fire across a corpus. +;; mod/never-fired is the empirical complement to lint's static unreachable check +;; (Ext 5): lint finds rules that CAN'T fire by structure; never-fired finds rules +;; that DIDN'T fire on real data. + +(define + mod/decide-batch + (fn + (reports rules) + (map (fn (r) (mod/decide-report r reports rules)) reports))) + +(define + mod/count-action + (fn + (decisions action) + (reduce + (fn (acc d) (if (= (get d :action) action) (+ acc 1) acc)) + 0 + decisions))) + +(define mod/action-histogram (fn (decisions) {:keep (mod/count-action decisions "keep") :remove (mod/count-action decisions "remove") :escalate (mod/count-action decisions "escalate") :hide (mod/count-action decisions "hide") :ban (mod/count-action decisions "ban")})) + +(define + mod/rule-fire-count + (fn + (decisions rule-name) + (reduce + (fn (acc d) (if (= (get d :rule) rule-name) (+ acc 1) acc)) + 0 + decisions))) + +(define + mod/rule-coverage + (fn + (reports rules) + (let + ((decisions (mod/decide-batch reports rules))) + (map (fn (rule) {:rule (mod/rule-name rule) :fired (mod/rule-fire-count decisions (mod/rule-name rule))}) rules)))) + +(define + mod/never-fired + (fn + (reports rules) + (reduce + (fn + (acc c) + (if + (= (get c :fired) 0) + (append acc (list (get c :rule))) + acc)) + (list) + (mod/rule-coverage reports rules)))) diff --git a/lib/mod/conformance.conf b/lib/mod/conformance.conf index 517be3c0..154e11b2 100644 --- a/lib/mod/conformance.conf +++ b/lib/mod/conformance.conf @@ -19,6 +19,7 @@ PRELOADS=( lib/mod/quorum.sx lib/mod/trace.sx lib/mod/whatif.sx + lib/mod/batch.sx lib/mod/lifecycle.sx lib/mod/audit.sx lib/mod/api.sx @@ -40,4 +41,5 @@ SUITES=( "quorum:lib/mod/tests/quorum.sx:(mod-quorum-tests-run!)" "trace:lib/mod/tests/trace.sx:(mod-trace-tests-run!)" "whatif:lib/mod/tests/whatif.sx:(mod-whatif-tests-run!)" + "batch:lib/mod/tests/batch.sx:(mod-batch-tests-run!)" ) diff --git a/lib/mod/scoreboard.json b/lib/mod/scoreboard.json index 3c9acdc6..ebcd4326 100644 --- a/lib/mod/scoreboard.json +++ b/lib/mod/scoreboard.json @@ -1,8 +1,8 @@ { "lang": "mod", - "total_passed": 260, + "total_passed": 277, "total_failed": 0, - "total": 260, + "total": 277, "suites": [ {"name":"decide","passed":31,"failed":0,"total":31}, {"name":"audit","passed":29,"failed":0,"total":29}, @@ -15,7 +15,8 @@ {"name":"offenders","passed":19,"failed":0,"total":19}, {"name":"quorum","passed":9,"failed":0,"total":9}, {"name":"trace","passed":15,"failed":0,"total":15}, - {"name":"whatif","passed":13,"failed":0,"total":13} + {"name":"whatif","passed":13,"failed":0,"total":13}, + {"name":"batch","passed":17,"failed":0,"total":17} ], - "generated": "2026-06-06T18:51:15+00:00" + "generated": "2026-06-06T18:55:47+00:00" } diff --git a/lib/mod/scoreboard.md b/lib/mod/scoreboard.md index 365c2351..eb159e6f 100644 --- a/lib/mod/scoreboard.md +++ b/lib/mod/scoreboard.md @@ -1,6 +1,6 @@ # mod scoreboard -**260 / 260 passing** (0 failure(s)). +**277 / 277 passing** (0 failure(s)). | Suite | Passed | Total | Status | |-------|--------|-------|--------| @@ -16,3 +16,4 @@ | quorum | 9 | 9 | ok | | trace | 15 | 15 | ok | | whatif | 13 | 13 | ok | +| batch | 17 | 17 | ok | diff --git a/lib/mod/tests/batch.sx b/lib/mod/tests/batch.sx new file mode 100644 index 00000000..0835f291 --- /dev/null +++ b/lib/mod/tests/batch.sx @@ -0,0 +1,101 @@ +;; lib/mod/tests/batch.sx — Ext 11: batch triage + corpus analytics. + +(define mod-b-count 0) +(define mod-b-pass 0) +(define mod-b-fail 0) +(define mod-b-failures (list)) + +(define + mod-b-test! + (fn + (name got expected) + (begin + (set! mod-b-count (+ mod-b-count 1)) + (if + (= got expected) + (set! mod-b-pass (+ mod-b-pass 1)) + (begin + (set! mod-b-fail (+ mod-b-fail 1)) + (append! + mod-b-failures + (str name "\n expected: " expected "\n got: " got))))))) + +;; corpus: 2 spam, 1 abuse, 2 clean — distinct subjects so the count rule stays quiet +(define + mod-b-corpus + (list + (mod/mk-report "r1" "u" "s1" "this is spam") + (mod/mk-report "r2" "u" "s2" "buy now offer") + (mod/mk-report "r3" "u" "s3" "harassment here") + (mod/mk-report "r4" "u" "s4" "a fine post") + (mod/mk-report "r5" "u" "s5" "thanks for sharing"))) + +(define mod-b-decisions (mod/decide-batch mod-b-corpus mod/default-rules)) + +;; ── decide-batch ── + +(mod-b-test! "one decision per report" (len mod-b-decisions) 5) +(mod-b-test! + "first decision is hide" + (get (first mod-b-decisions) :action) + "hide") + +;; ── action histogram ── + +(define mod-b-hist (mod/action-histogram mod-b-decisions)) +(mod-b-test! "histogram hide count" (get mod-b-hist :hide) 2) +(mod-b-test! "histogram remove count" (get mod-b-hist :remove) 1) +(mod-b-test! "histogram keep count" (get mod-b-hist :keep) 2) +(mod-b-test! "histogram escalate count" (get mod-b-hist :escalate) 0) +(mod-b-test! "histogram ban count" (get mod-b-hist :ban) 0) +(mod-b-test! + "histogram totals match corpus" + (+ + (+ (get mod-b-hist :hide) (get mod-b-hist :remove)) + (+ + (get mod-b-hist :keep) + (+ (get mod-b-hist :escalate) (get mod-b-hist :ban)))) + 5) + +;; ── rule coverage (empirical) ── + +(define mod-b-cov (mod/rule-coverage mod-b-corpus mod/default-rules)) +(mod-b-test! "coverage has one row per rule" (len mod-b-cov) 6) +(mod-b-test! + "spam-hide fired twice" + (mod/rule-fire-count mod-b-decisions "spam-hide") + 2) +(mod-b-test! + "abuse-remove fired once" + (mod/rule-fire-count mod-b-decisions "abuse-remove") + 1) +(mod-b-test! + "default-keep fired twice" + (mod/rule-fire-count mod-b-decisions "default-keep") + 2) + +;; ── never-fired: rules not exercised by this corpus ── + +(define mod-b-never (mod/never-fired mod-b-corpus mod/default-rules)) +(mod-b-test! + "exonerated-keep never fired" + (mod/member? "exonerated-keep" mod-b-never) + true) +(mod-b-test! + "reviewer-remove never fired" + (mod/member? "reviewer-remove" mod-b-never) + true) +(mod-b-test! + "repeated-escalate never fired" + (mod/member? "repeated-escalate" mod-b-never) + true) +(mod-b-test! + "spam-hide DID fire (not in never-fired)" + (mod/member? "spam-hide" mod-b-never) + false) +(mod-b-test! + "three rules never fired on this corpus" + (len mod-b-never) + 3) + +(define mod-batch-tests-run! (fn () {:failures mod-b-failures :total mod-b-count :passed mod-b-pass :failed mod-b-fail})) diff --git a/plans/mod-on-sx.md b/plans/mod-on-sx.md index 5e074024..684e4eda 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` → **260/260** (roadmap + 10 extensions complete) +`bash lib/mod/conformance.sh` → **277/277** (roadmap + 11 extensions complete) ## Ground rules @@ -147,6 +147,11 @@ 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 11 — batch triage + corpus analytics** (`lib/mod/batch.sx`, +17). + `mod/decide-batch` triages a queue; `mod/action-histogram` summarizes outcomes by + action; `mod/rule-coverage` / `mod/never-fired` measure which rules fire across a + corpus — the *empirical* complement to lint's static unreachable check (Ext 5): + lint finds rules that can't fire, never-fired finds rules that didn't. - [x] **Ext 10 — policy what-if / impact** (`lib/mod/whatif.sx`, +13). `mod/decision-diff` compares one report's action under two rule sets; `mod/policy-impact` runs a batch and returns only the reports whose decision @@ -191,6 +196,11 @@ lib/mod/fed.sx ## Progress log +- **Ext 11 — batch triage + corpus analytics, 277/277** (+17). Operational layer: + triage a queue, histogram the outcomes, and measure rule coverage over real + data. `never-fired` pairs with lint (Ext 5) — static "can't fire" vs empirical + "didn't fire" — giving policy authors both views of dead rules. Histogram avoids + dict mutation by counting over a fixed action vocabulary. - **Ext 10 — policy what-if / impact, 260/260** (+13). Decisions are now comparable across rule sets — diff one report, or batch a whole set and surface only the flips. Pure SX over `decide-report`, no engine change. Closes the From 92addf51464ff5aa6436f3d469f1fa2c1de1299e Mon Sep 17 00:00:00 2001 From: giles Date: Sat, 6 Jun 2026 19:00:51 +0000 Subject: [PATCH 18/26] =?UTF-8?q?mod:=20Ext=2012=20=E2=80=94=20temporal=20?= =?UTF-8?q?burst=20detection,=20292/292?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reports gain an :at tick (deterministic, supplied). mod/decide-temporal counts reports about a subject within [now-window, now], asserts burst_count/2, and a (:burst-at-least K) rule fires only on a real burst. 3 reports at 10/11/12 → hide; 3 at 1/2/12 (window 5) → keep, while the plain count rule escalates both. Fifth report field threaded through rebuild helpers, non-breaking. +15 tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/mod/conformance.conf | 2 + lib/mod/policy.sx | 8 ++ lib/mod/schema.sx | 43 +++++++++-- lib/mod/scoreboard.json | 9 ++- lib/mod/scoreboard.md | 3 +- lib/mod/temporal.sx | 62 +++++++++++++++ lib/mod/tests/temporal.sx | 156 ++++++++++++++++++++++++++++++++++++++ plans/mod-on-sx.md | 14 +++- 8 files changed, 285 insertions(+), 12 deletions(-) create mode 100644 lib/mod/temporal.sx create mode 100644 lib/mod/tests/temporal.sx diff --git a/lib/mod/conformance.conf b/lib/mod/conformance.conf index 154e11b2..9f348d59 100644 --- a/lib/mod/conformance.conf +++ b/lib/mod/conformance.conf @@ -20,6 +20,7 @@ PRELOADS=( lib/mod/trace.sx lib/mod/whatif.sx lib/mod/batch.sx + lib/mod/temporal.sx lib/mod/lifecycle.sx lib/mod/audit.sx lib/mod/api.sx @@ -42,4 +43,5 @@ SUITES=( "trace:lib/mod/tests/trace.sx:(mod-trace-tests-run!)" "whatif:lib/mod/tests/whatif.sx:(mod-whatif-tests-run!)" "batch:lib/mod/tests/batch.sx:(mod-batch-tests-run!)" + "temporal:lib/mod/tests/temporal.sx:(mod-temporal-tests-run!)" ) diff --git a/lib/mod/policy.sx b/lib/mod/policy.sx index 3ac585c8..758e2cae 100644 --- a/lib/mod/policy.sx +++ b/lib/mod/policy.sx @@ -49,6 +49,8 @@ ;; (:reporters-at-least 2) → report(Id, _, Sr), setof(Br, report(_, Br, Sr), Bsr), ;; length(Bsr, Nr), Nr >= 2 (distinct reporters; ;; needs the quorum engine which asserts every report) +;; (:burst-at-least 3) → report(Id, _, Sb), burst_count(Sb, Nb), Nb >= 3 +;; (reports in a time window; needs the temporal engine) (define mod/cond->goal @@ -88,6 +90,12 @@ ", _, Sr), setof(Br, report(_, Br, Sr), Bsr), " "length(Bsr, Nr), Nr >= " (nth c 1))) + ((= tag :burst-at-least) + (str + "report(" + idterm + ", _, Sb), burst_count(Sb, Nb), Nb >= " + (nth c 1))) (true "true"))))) (define diff --git a/lib/mod/schema.sx b/lib/mod/schema.sx index 32d907c6..fc32c133 100644 --- a/lib/mod/schema.sx +++ b/lib/mod/schema.sx @@ -1,14 +1,15 @@ ;; lib/mod/schema.sx — report representation + Prolog fact generation. ;; -;; A report is a dict {:id :by :about :reason :evidence :attrs :signals}. +;; A report is a dict {:id :by :about :reason :evidence :attrs :signals :at}. ;; :evidence — accumulated {:kind :val} entries (human review, scanners) ;; :attrs — attribute names ("verified") for negation-as-failure conditions ;; :signals — weighted {:kind :weight} entries for aggregate scoring rules +;; :at — integer timestamp/tick (deterministic; supplied, not clock-read) ;; The engine derives keyword classifications from the reason text and projects ;; the report, its classifications, evidence, attributes, and signals into Prolog ;; facts that policy clauses match against. -(define mod/mk-report (fn (id by about reason) {:attrs (list) :id id :signals (list) :by by :evidence (list) :about about :reason reason})) +(define mod/mk-report (fn (id by about reason) {:attrs (list) :id id :signals (list) :by by :evidence (list) :about about :at 0 :reason reason})) (define mod/report-id (fn (r) (get r :id))) (define mod/report-by (fn (r) (get r :by))) @@ -27,6 +28,10 @@ mod/report-signals (fn (r) (let ((s (get r :signals))) (if (nil? s) (list) s)))) +(define + mod/report-at + (fn (r) (let ((t (get r :at))) (if (nil? t) 0 t)))) + (define mod/mk-evidence (fn (kind val) {:val val :kind kind})) (define mod/evidence-kind (fn (e) (get e :kind))) (define mod/evidence-val (fn (e) (get e :val))) @@ -35,25 +40,51 @@ (define mod/signal-kind (fn (s) (get s :kind))) (define mod/signal-weight (fn (s) (get s :weight))) -(define mod/report* (fn (r evs attrs sigs) {:attrs attrs :id (mod/report-id r) :signals sigs :by (mod/report-by r) :evidence evs :about (mod/report-about r) :reason (mod/report-reason r)})) +(define mod/report* (fn (r evs attrs sigs at) {:attrs attrs :id (mod/report-id r) :signals sigs :by (mod/report-by r) :evidence evs :about (mod/report-about r) :at at :reason (mod/report-reason r)})) (define mod/with-evidence (fn (r evs) - (mod/report* r evs (mod/report-attrs r) (mod/report-signals r)))) + (mod/report* + r + evs + (mod/report-attrs r) + (mod/report-signals r) + (mod/report-at r)))) (define mod/with-attrs (fn (r attrs) - (mod/report* r (mod/report-evidence r) attrs (mod/report-signals r)))) + (mod/report* + r + (mod/report-evidence r) + attrs + (mod/report-signals r) + (mod/report-at r)))) (define mod/with-signals (fn (r sigs) - (mod/report* r (mod/report-evidence r) (mod/report-attrs r) sigs))) + (mod/report* + r + (mod/report-evidence r) + (mod/report-attrs r) + sigs + (mod/report-at r)))) + +(define + mod/with-at + (fn + (r at) + (mod/report* + r + (mod/report-evidence r) + (mod/report-attrs r) + (mod/report-signals r) + at))) (define mod/attach-evidence diff --git a/lib/mod/scoreboard.json b/lib/mod/scoreboard.json index ebcd4326..40cf2c07 100644 --- a/lib/mod/scoreboard.json +++ b/lib/mod/scoreboard.json @@ -1,8 +1,8 @@ { "lang": "mod", - "total_passed": 277, + "total_passed": 292, "total_failed": 0, - "total": 277, + "total": 292, "suites": [ {"name":"decide","passed":31,"failed":0,"total":31}, {"name":"audit","passed":29,"failed":0,"total":29}, @@ -16,7 +16,8 @@ {"name":"quorum","passed":9,"failed":0,"total":9}, {"name":"trace","passed":15,"failed":0,"total":15}, {"name":"whatif","passed":13,"failed":0,"total":13}, - {"name":"batch","passed":17,"failed":0,"total":17} + {"name":"batch","passed":17,"failed":0,"total":17}, + {"name":"temporal","passed":15,"failed":0,"total":15} ], - "generated": "2026-06-06T18:55:47+00:00" + "generated": "2026-06-06T19:00:19+00:00" } diff --git a/lib/mod/scoreboard.md b/lib/mod/scoreboard.md index eb159e6f..f66956bc 100644 --- a/lib/mod/scoreboard.md +++ b/lib/mod/scoreboard.md @@ -1,6 +1,6 @@ # mod scoreboard -**277 / 277 passing** (0 failure(s)). +**292 / 292 passing** (0 failure(s)). | Suite | Passed | Total | Status | |-------|--------|-------|--------| @@ -17,3 +17,4 @@ | trace | 15 | 15 | ok | | whatif | 13 | 13 | ok | | batch | 17 | 17 | ok | +| temporal | 15 | 15 | ok | diff --git a/lib/mod/temporal.sx b/lib/mod/temporal.sx new file mode 100644 index 00000000..a286c652 --- /dev/null +++ b/lib/mod/temporal.sx @@ -0,0 +1,62 @@ +;; lib/mod/temporal.sx — burst detection over a time window. +;; +;; A plain report count can't tell a burst (N reports in minutes) from slow +;; accumulation (N reports over months). mod/decide-temporal takes a `now` tick +;; and a `window`, counts reports about the subject with :at within [now-window, +;; now], asserts it as burst_count/2, and lets a `(:burst-at-least K)` rule fire +;; only on a genuine burst. Time is supplied (deterministic), never clock-read. + +(define + mod/window-count + (fn + (subject reports now window) + (reduce + (fn + (acc r) + (if + (if + (= (mod/report-about r) subject) + (<= (- now window) (mod/report-at r)) + false) + (+ acc 1) + acc)) + 0 + reports))) + +(define + mod/build-temporal-program + (fn + (r count bcount rules) + (str + (mod/report-facts r count) + "\n" + "burst_count(" + (mod/pl-quote (mod/report-about r)) + ", " + bcount + ").\n" + (mod/rules->program rules)))) + +(define + mod/decide-temporal + (fn + (r reports rules now window) + (let + ((about (mod/report-about r)) + (id (mod/report-id r)) + (kinds (mod/classify-keywords r))) + (let + ((count (mod/report-count about reports)) + (bcount (mod/window-count about reports now window))) + (let + ((program (mod/build-temporal-program r count bcount rules))) + (let + ((db (pl-load program))) + (let + ((sol (pl-query-one db (str "policy_action(" id ", Action, Rule)")))) + (if + (nil? sol) + {:action "keep" :proof {:burst bcount :goals (list) :evidence kinds :conditions (list) :rule "none" :count count} :report-id id :rule "none" :strategy "temporal"} + (let + ((rule (mod/find-rule rules (dict-get sol "Rule")))) + {:action (mod/rule-action rule) :proof {:burst bcount :goals (mod/proof-goals db id (mod/rule-when rule)) :evidence kinds :conditions (mod/rule-when rule) :rule (mod/rule-name rule) :count count} :report-id id :rule (mod/rule-name rule) :strategy "temporal"}))))))))) diff --git a/lib/mod/tests/temporal.sx b/lib/mod/tests/temporal.sx new file mode 100644 index 00000000..287992e5 --- /dev/null +++ b/lib/mod/tests/temporal.sx @@ -0,0 +1,156 @@ +;; lib/mod/tests/temporal.sx — Ext 12: burst detection over a time window. + +(define mod-tm-count 0) +(define mod-tm-pass 0) +(define mod-tm-fail 0) +(define mod-tm-failures (list)) + +(define + mod-tm-test! + (fn + (name got expected) + (begin + (set! mod-tm-count (+ mod-tm-count 1)) + (if + (= got expected) + (set! mod-tm-pass (+ mod-tm-pass 1)) + (begin + (set! mod-tm-fail (+ mod-tm-fail 1)) + (append! + mod-tm-failures + (str name "\n expected: " expected "\n got: " got))))))) + +(define + mod-tm-at + (fn (id about t) (mod/with-at (mod/mk-report id "u" about "off-topic") t))) + +(define + mod-tm-rules + (list + (mod/mk-rule "burst-hide" :hide (list (list :burst-at-least 3))) + (mod/mk-rule "default-keep" :keep (list)))) + +;; ── window-count helper ── + +(define + mod-tm-burst + (list + (mod-tm-at "r1" "bob" 10) + (mod-tm-at "r2" "bob" 11) + (mod-tm-at "r3" "bob" 12))) +(define + mod-tm-slow + (list + (mod-tm-at "r1" "bob" 1) + (mod-tm-at "r2" "bob" 2) + (mod-tm-at "r3" "bob" 12))) + +(mod-tm-test! + "window-count: all 3 within window" + (mod/window-count "bob" mod-tm-burst 12 5) + 3) +(mod-tm-test! + "window-count: only 1 within window" + (mod/window-count "bob" mod-tm-slow 12 5) + 1) +(mod-tm-test! + "window-count: subject filter" + (mod/window-count "eve" mod-tm-burst 12 5) + 0) + +;; ── burst fires; slow accumulation does not ── + +(mod-tm-test! + "burst (3 in window) → hide" + (get + (mod/decide-temporal + (first mod-tm-burst) + mod-tm-burst + mod-tm-rules + 12 + 5) + :action) + "hide") +(mod-tm-test! + "slow accumulation (1 in window) → keep" + (get + (mod/decide-temporal + (first mod-tm-slow) + mod-tm-slow + mod-tm-rules + 12 + 5) + :action) + "keep") + +;; ── contrast: the plain count rule fires on BOTH (3 total reports) ── +(mod-tm-test! + "count rule fires on slow case (distinct from burst)" + (get + (mod/decide-report (first mod-tm-slow) mod-tm-slow mod/default-rules) + :action) + "escalate") + +;; ── decision shape ── + +(define + mod-tm-d + (mod/decide-temporal + (first mod-tm-burst) + mod-tm-burst + mod-tm-rules + 12 + 5)) +(mod-tm-test! "burst decision rule" (get mod-tm-d :rule) "burst-hide") +(mod-tm-test! + "burst decision tagged strategy" + (get mod-tm-d :strategy) + "temporal") +(mod-tm-test! + "burst recorded in proof" + (get (get mod-tm-d :proof) :burst) + 3) +(mod-tm-test! + "burst proof goal solved" + (get (first (get (get mod-tm-d :proof) :goals)) :solved) + true) + +;; ── window boundary is inclusive ── + +(define + mod-tm-edge + (list + (mod-tm-at "r1" "bob" 7) + (mod-tm-at "r2" "bob" 8) + (mod-tm-at "r3" "bob" 9))) +(mod-tm-test! + "window boundary inclusive (now-window = at)" + (mod/window-count "bob" mod-tm-edge 12 5) + 3) + +;; ── schema :at round-trips and survives evidence attach ── + +(mod-tm-test! + "report-at reads timestamp" + (mod/report-at (mod-tm-at "r1" "bob" 42)) + 42) +(mod-tm-test! + "default report-at is 0" + (mod/report-at (mod/mk-report "r1" "a" "b" "x")) + 0) +(mod-tm-test! + "attach-evidence preserves :at" + (mod/report-at + (mod/attach-evidence + (mod-tm-at "r1" "bob" 42) + (mod/mk-evidence "k" "v"))) + 42) + +;; ── cond->goal :burst-at-least ── + +(mod-tm-test! + "cond->goal :burst-at-least" + (mod/cond->goal (list :burst-at-least 3) "Id") + "report(Id, _, Sb), burst_count(Sb, Nb), Nb >= 3") + +(define mod-temporal-tests-run! (fn () {:failures mod-tm-failures :total mod-tm-count :passed mod-tm-pass :failed mod-tm-fail})) diff --git a/plans/mod-on-sx.md b/plans/mod-on-sx.md index 684e4eda..0d394230 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` → **277/277** (roadmap + 11 extensions complete) +`bash lib/mod/conformance.sh` → **292/292** (roadmap + 12 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 12 — temporal burst detection** (`lib/mod/temporal.sx`, +15). Reports + gain an `:at` tick (deterministic, supplied — never clock-read). + `mod/decide-temporal now window` counts reports about the subject within + `[now-window, now]`, asserts `burst_count/2`, and a `(:burst-at-least K)` rule + fires only on a real burst. Verified: 3 reports at ticks 10/11/12 → hide; + 3 reports at 1/2/12 (window 5) → keep, while the plain count rule escalates both. - [x] **Ext 11 — batch triage + corpus analytics** (`lib/mod/batch.sx`, +17). `mod/decide-batch` triages a queue; `mod/action-histogram` summarizes outcomes by action; `mod/rule-coverage` / `mod/never-fired` measure which rules fire across a @@ -196,6 +202,12 @@ lib/mod/fed.sx ## Progress log +- **Ext 12 — temporal burst detection, 292/292** (+15). Adds the time dimension: + a windowed count distinguishes a burst from slow accumulation, where the plain + count rule cannot. Time is a supplied tick (`:at`), keeping everything + deterministic and testable — no clock primitive. Fifth report field (`:at`) + threaded through the rebuild helpers, same non-breaking pattern as + evidence/attrs/signals; all 277 prior tests stayed green. - **Ext 11 — batch triage + corpus analytics, 277/277** (+17). Operational layer: triage a queue, histogram the outcomes, and measure rule coverage over real data. `never-fired` pairs with lint (Ext 5) — static "can't fire" vs empirical From 68c8e39508d97adde20dc0ac7e531e1b791674eb Mon Sep 17 00:00:00 2001 From: giles Date: Sat, 6 Jun 2026 19:08:37 +0000 Subject: [PATCH 19/26] =?UTF-8?q?mod:=20Ext=2013=20=E2=80=94=20SLA=20sweep?= =?UTF-8?q?=20over=20pending=20lifecycle=20cases,=20307/307?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Composes lifecycle (Phase 3) with time (Ext 12): a timed-case pairs a case with its state-entry tick; mod/overdue? flags pending cases (open/triaged/appealed) past a deadline; mod/sla-sweep returns the breached report ids. Terminal states never breach. Pure overlay — lifecycle stays timeless, caller stamps entry. +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/sla.sx | 47 +++++++++++++++++ lib/mod/tests/sla.sx | 108 +++++++++++++++++++++++++++++++++++++++ plans/mod-on-sx.md | 12 ++++- 6 files changed, 175 insertions(+), 6 deletions(-) create mode 100644 lib/mod/sla.sx create mode 100644 lib/mod/tests/sla.sx diff --git a/lib/mod/conformance.conf b/lib/mod/conformance.conf index 9f348d59..e558811f 100644 --- a/lib/mod/conformance.conf +++ b/lib/mod/conformance.conf @@ -21,6 +21,7 @@ PRELOADS=( lib/mod/whatif.sx lib/mod/batch.sx lib/mod/temporal.sx + lib/mod/sla.sx lib/mod/lifecycle.sx lib/mod/audit.sx lib/mod/api.sx @@ -44,4 +45,5 @@ SUITES=( "whatif:lib/mod/tests/whatif.sx:(mod-whatif-tests-run!)" "batch:lib/mod/tests/batch.sx:(mod-batch-tests-run!)" "temporal:lib/mod/tests/temporal.sx:(mod-temporal-tests-run!)" + "sla:lib/mod/tests/sla.sx:(mod-sla-tests-run!)" ) diff --git a/lib/mod/scoreboard.json b/lib/mod/scoreboard.json index 40cf2c07..97f2beb8 100644 --- a/lib/mod/scoreboard.json +++ b/lib/mod/scoreboard.json @@ -1,8 +1,8 @@ { "lang": "mod", - "total_passed": 292, + "total_passed": 307, "total_failed": 0, - "total": 292, + "total": 307, "suites": [ {"name":"decide","passed":31,"failed":0,"total":31}, {"name":"audit","passed":29,"failed":0,"total":29}, @@ -17,7 +17,8 @@ {"name":"trace","passed":15,"failed":0,"total":15}, {"name":"whatif","passed":13,"failed":0,"total":13}, {"name":"batch","passed":17,"failed":0,"total":17}, - {"name":"temporal","passed":15,"failed":0,"total":15} + {"name":"temporal","passed":15,"failed":0,"total":15}, + {"name":"sla","passed":15,"failed":0,"total":15} ], - "generated": "2026-06-06T19:00:19+00:00" + "generated": "2026-06-06T19:08:06+00:00" } diff --git a/lib/mod/scoreboard.md b/lib/mod/scoreboard.md index f66956bc..680ef546 100644 --- a/lib/mod/scoreboard.md +++ b/lib/mod/scoreboard.md @@ -1,6 +1,6 @@ # mod scoreboard -**292 / 292 passing** (0 failure(s)). +**307 / 307 passing** (0 failure(s)). | Suite | Passed | Total | Status | |-------|--------|-------|--------| @@ -18,3 +18,4 @@ | whatif | 13 | 13 | ok | | batch | 17 | 17 | ok | | temporal | 15 | 15 | ok | +| sla | 15 | 15 | ok | diff --git a/lib/mod/sla.sx b/lib/mod/sla.sx new file mode 100644 index 00000000..4a437957 --- /dev/null +++ b/lib/mod/sla.sx @@ -0,0 +1,47 @@ +;; lib/mod/sla.sx — service-level sweep over pending lifecycle cases. +;; +;; Composes the Phase-3 lifecycle with the Ext-12 time dimension: a case left in a +;; pending state (open / triaged / appealed) past a deadline has breached SLA and +;; should resurface. A timed-case pairs a case with the tick it entered its +;; current state (the caller stamps this — the lifecycle stays timeless and pure). +;; Terminal states (decided / final) never breach. + +(define mod/pending-states (list "open" "triaged" "appealed")) +(define mod/pending-state? (fn (s) (mod/member? s mod/pending-states))) + +(define mod/mk-timed-case (fn (c entered-at) {:entered-at entered-at :case c})) +(define mod/tc-case (fn (tc) (get tc :case))) +(define mod/tc-entered-at (fn (tc) (get tc :entered-at))) + +(define + mod/overdue? + (fn + (tc now deadline) + (if + (mod/pending-state? (mod/case-state (mod/tc-case tc))) + (< deadline (- now (mod/tc-entered-at tc))) + false))) + +(define + mod/sla-sweep + (fn + (timed-cases now deadline) + (reduce + (fn + (acc tc) + (if + (mod/overdue? tc now deadline) + (append + acc + (list (mod/report-id (mod/case-report (mod/tc-case tc))))) + acc)) + (list) + timed-cases))) + +(define + mod/overdue-count + (fn + (timed-cases now deadline) + (len (mod/sla-sweep timed-cases now deadline)))) + +(define mod/age (fn (tc now) (- now (mod/tc-entered-at tc)))) diff --git a/lib/mod/tests/sla.sx b/lib/mod/tests/sla.sx new file mode 100644 index 00000000..ccf50ec9 --- /dev/null +++ b/lib/mod/tests/sla.sx @@ -0,0 +1,108 @@ +;; lib/mod/tests/sla.sx — Ext 13: SLA sweep over pending lifecycle cases. + +(define mod-sla-count 0) +(define mod-sla-pass 0) +(define mod-sla-fail 0) +(define mod-sla-failures (list)) + +(define + mod-sla-test! + (fn + (name got expected) + (begin + (set! mod-sla-count (+ mod-sla-count 1)) + (if + (= got expected) + (set! mod-sla-pass (+ mod-sla-pass 1)) + (begin + (set! mod-sla-fail (+ mod-sla-fail 1)) + (append! + mod-sla-failures + (str name "\n expected: " expected "\n got: " got))))))) + +;; ── pending-state? ── + +(mod-sla-test! "open is pending" (mod/pending-state? "open") true) +(mod-sla-test! "triaged is pending" (mod/pending-state? "triaged") true) +(mod-sla-test! "appealed is pending" (mod/pending-state? "appealed") true) +(mod-sla-test! "decided is not pending" (mod/pending-state? "decided") false) +(mod-sla-test! "final is not pending" (mod/pending-state? "final") false) + +;; build cases in known states +(define mod-sla-spam (mod/mk-report "r1" "u" "bob" "this is spam")) +(define mod-sla-spam-reports (list mod-sla-spam)) +(define + mod-sla-triaged + (mod/case-triage + (mod/mk-case mod-sla-spam) + mod-sla-spam-reports + mod/default-rules)) +(define mod-sla-decided (mod/case-resolve mod-sla-triaged)) +(define mod-sla-open (mod/mk-case (mod/mk-report "r2" "u" "eve" "hello"))) + +;; ── overdue? ── + +(define mod-sla-tc-old (mod/mk-timed-case mod-sla-triaged 0)) +(define mod-sla-tc-fresh (mod/mk-timed-case mod-sla-triaged 90)) +(define mod-sla-tc-done (mod/mk-timed-case mod-sla-decided 0)) + +(mod-sla-test! + "old triaged case is overdue" + (mod/overdue? mod-sla-tc-old 100 50) + true) +(mod-sla-test! + "fresh triaged case not overdue" + (mod/overdue? mod-sla-tc-fresh 100 50) + false) +(mod-sla-test! + "decided case never overdue" + (mod/overdue? mod-sla-tc-done 100 50) + false) +(mod-sla-test! + "age computes elapsed ticks" + (mod/age mod-sla-tc-old 100) + 100) +(mod-sla-test! + "boundary: exactly at deadline not overdue" + (mod/overdue? + (mod/mk-timed-case mod-sla-triaged 50) + 100 + 50) + false) +(mod-sla-test! + "boundary: one past deadline overdue" + (mod/overdue? + (mod/mk-timed-case mod-sla-triaged 49) + 100 + 50) + true) + +;; ── sweep over a mixed queue ── + +(define + mod-sla-queue + (list + (mod/mk-timed-case mod-sla-triaged 0) + (mod/mk-timed-case mod-sla-decided 0) + (mod/mk-timed-case mod-sla-open 90))) ;; r2, pending, age 10 → not + +(mod-sla-test! + "sweep finds only the overdue pending case" + (mod/sla-sweep mod-sla-queue 100 50) + (list "r1")) +(mod-sla-test! + "overdue-count agrees" + (mod/overdue-count mod-sla-queue 100 50) + 1) + +;; tighten deadline so the young open case also breaches +(mod-sla-test! + "tighter deadline catches the open case too" + (mod/overdue-count mod-sla-queue 100 5) + 2) +(mod-sla-test! + "empty queue → no breaches" + (mod/sla-sweep (list) 100 50) + (list)) + +(define mod-sla-tests-run! (fn () {:failures mod-sla-failures :total mod-sla-count :passed mod-sla-pass :failed mod-sla-fail})) diff --git a/plans/mod-on-sx.md b/plans/mod-on-sx.md index 0d394230..742919eb 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` → **292/292** (roadmap + 12 extensions complete) +`bash lib/mod/conformance.sh` → **307/307** (roadmap + 13 extensions complete) ## Ground rules @@ -147,6 +147,11 @@ 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 13 — SLA sweep over pending cases** (`lib/mod/sla.sx`, +15). Composes + lifecycle (Phase 3) with time (Ext 12): a timed-case pairs a case with the tick + it entered its state; `mod/overdue?` flags pending cases (open/triaged/appealed) + past a deadline; `mod/sla-sweep` returns the breached report ids. Terminal states + never breach. Pure overlay — lifecycle stays timeless, the caller stamps entry. - [x] **Ext 12 — temporal burst detection** (`lib/mod/temporal.sx`, +15). Reports gain an `:at` tick (deterministic, supplied — never clock-read). `mod/decide-temporal now window` counts reports about the subject within @@ -202,6 +207,11 @@ lib/mod/fed.sx ## Progress log +- **Ext 13 — SLA sweep, 307/307** (+15). Two subsystems compose cleanly: lifecycle + states + temporal ticks → "which pending cases have sat too long". Kept lifecycle + pure by having the SLA layer carry entry-time externally (timed-case wrapper) + rather than stamping the case — same separation-of-concerns as keeping the state + machine out of Prolog. - **Ext 12 — temporal burst detection, 292/292** (+15). Adds the time dimension: a windowed count distinguishes a burst from slow accumulation, where the plain count rule cannot. Time is a supplied tick (`:at`), keeping everything From b43901d297508eb5e62114efd295dcca453888e5 Mon Sep 17 00:00:00 2001 From: giles Date: Sat, 6 Jun 2026 19:17:19 +0000 Subject: [PATCH 20/26] =?UTF-8?q?mod:=20Ext=2014=20=E2=80=94=20decision=20?= =?UTF-8?q?wire=20format=20for=20federation=20transport,=20323/323?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mod/decision->wire emits a versioned pipe-delimited line (MOD1|r1|hide|spam-hide); mod/wire->decision parses it back (mod/wire-valid? guards). split-char built over slice/len (loaded env has no split). Integration test runs the full federated path: serialize → wire → deserialize → fed-receive-decision trust-gating (untrusted→advisory, trusted→applied). +16 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/wire.sx | 96 ++++++++++++++++++++++++++++++++++++++++ lib/mod/wire.sx | 55 +++++++++++++++++++++++ plans/mod-on-sx.md | 13 +++++- 6 files changed, 172 insertions(+), 6 deletions(-) create mode 100644 lib/mod/tests/wire.sx create mode 100644 lib/mod/wire.sx diff --git a/lib/mod/conformance.conf b/lib/mod/conformance.conf index e558811f..ee19cec8 100644 --- a/lib/mod/conformance.conf +++ b/lib/mod/conformance.conf @@ -22,6 +22,7 @@ PRELOADS=( lib/mod/batch.sx lib/mod/temporal.sx lib/mod/sla.sx + lib/mod/wire.sx lib/mod/lifecycle.sx lib/mod/audit.sx lib/mod/api.sx @@ -46,4 +47,5 @@ SUITES=( "batch:lib/mod/tests/batch.sx:(mod-batch-tests-run!)" "temporal:lib/mod/tests/temporal.sx:(mod-temporal-tests-run!)" "sla:lib/mod/tests/sla.sx:(mod-sla-tests-run!)" + "wire:lib/mod/tests/wire.sx:(mod-wire-tests-run!)" ) diff --git a/lib/mod/scoreboard.json b/lib/mod/scoreboard.json index 97f2beb8..005313c5 100644 --- a/lib/mod/scoreboard.json +++ b/lib/mod/scoreboard.json @@ -1,8 +1,8 @@ { "lang": "mod", - "total_passed": 307, + "total_passed": 323, "total_failed": 0, - "total": 307, + "total": 323, "suites": [ {"name":"decide","passed":31,"failed":0,"total":31}, {"name":"audit","passed":29,"failed":0,"total":29}, @@ -18,7 +18,8 @@ {"name":"whatif","passed":13,"failed":0,"total":13}, {"name":"batch","passed":17,"failed":0,"total":17}, {"name":"temporal","passed":15,"failed":0,"total":15}, - {"name":"sla","passed":15,"failed":0,"total":15} + {"name":"sla","passed":15,"failed":0,"total":15}, + {"name":"wire","passed":16,"failed":0,"total":16} ], - "generated": "2026-06-06T19:08:06+00:00" + "generated": "2026-06-06T19:16:49+00:00" } diff --git a/lib/mod/scoreboard.md b/lib/mod/scoreboard.md index 680ef546..21b21cf7 100644 --- a/lib/mod/scoreboard.md +++ b/lib/mod/scoreboard.md @@ -1,6 +1,6 @@ # mod scoreboard -**307 / 307 passing** (0 failure(s)). +**323 / 323 passing** (0 failure(s)). | Suite | Passed | Total | Status | |-------|--------|-------|--------| @@ -19,3 +19,4 @@ | batch | 17 | 17 | ok | | temporal | 15 | 15 | ok | | sla | 15 | 15 | ok | +| wire | 16 | 16 | ok | diff --git a/lib/mod/tests/wire.sx b/lib/mod/tests/wire.sx new file mode 100644 index 00000000..fd070a67 --- /dev/null +++ b/lib/mod/tests/wire.sx @@ -0,0 +1,96 @@ +;; lib/mod/tests/wire.sx — Ext 14: decision wire format + federated transport. + +(define mod-w-count 0) +(define mod-w-pass 0) +(define mod-w-fail 0) +(define mod-w-failures (list)) + +(define + mod-w-test! + (fn + (name got expected) + (begin + (set! mod-w-count (+ mod-w-count 1)) + (if + (= got expected) + (set! mod-w-pass (+ mod-w-pass 1)) + (begin + (set! mod-w-fail (+ mod-w-fail 1)) + (append! + mod-w-failures + (str name "\n expected: " expected "\n got: " got))))))) + +;; ── split-char ── + +(mod-w-test! "split on pipe" (mod/split-char "a|b|c" "|") (list "a" "b" "c")) +(mod-w-test! "split single field" (mod/split-char "abc" "|") (list "abc")) +(mod-w-test! + "split four fields" + (len (mod/split-char "MOD1|r1|hide|spam-hide" "|")) + 4) + +;; ── serialize ── + +(define + mod-w-dec + (mod/decide-report + (mod/mk-report "r1" "a" "bob" "this is spam") + (list (mod/mk-report "r1" "a" "bob" "this is spam")) + mod/default-rules)) +(define mod-w-line (mod/decision->wire mod-w-dec)) + +(mod-w-test! + "wire is versioned + delimited" + mod-w-line + "MOD1|r1|hide|spam-hide") +(mod-w-test! + "wire-valid? accepts well-formed" + (mod/wire-valid? mod-w-line) + true) +(mod-w-test! + "wire-valid? rejects junk" + (mod/wire-valid? "not a wire line") + false) +(mod-w-test! + "wire-valid? rejects wrong version" + (mod/wire-valid? "MOD9|r1|hide|x") + false) + +;; ── round-trip ── + +(define mod-w-back (mod/wire->decision mod-w-line)) +(mod-w-test! "round-trip report-id" (get mod-w-back :report-id) "r1") +(mod-w-test! "round-trip action" (get mod-w-back :action) "hide") +(mod-w-test! "round-trip rule" (get mod-w-back :rule) "spam-hide") +(mod-w-test! "round-trip tags :wire" (get mod-w-back :wire) true) +(mod-w-test! "malformed → nil" (mod/wire->decision "garbage") nil) + +;; ── full federated transport: serialize → wire → deserialize → trust-gate ── + +(mod/fed-reset!) +(define mod-w-peer-dec (mod/wire->decision mod-w-line)) + +;; untrusted peer: decision is advisory, not applied +(define mod-w-recv1 (mod/fed-receive-decision "peerX" mod-w-peer-dec)) +(mod-w-test! + "wired decision from untrusted peer → advisory" + (get mod-w-recv1 :applied) + false) +(mod-w-test! + "untrusted wired decision not applied locally" + (mod/fed-applied-action "r1") + nil) + +;; trusted peer: decision binds locally +(mod/grant-trust "peerY" :mod) +(define mod-w-recv2 (mod/fed-receive-decision "peerY" mod-w-peer-dec)) +(mod-w-test! + "wired decision from trusted peer → applied" + (get mod-w-recv2 :applied) + true) +(mod-w-test! + "trusted wired decision binds locally" + (get (mod/fed-applied-action "r1") :action) + "hide") + +(define mod-wire-tests-run! (fn () {:failures mod-w-failures :total mod-w-count :passed mod-w-pass :failed mod-w-fail})) diff --git a/lib/mod/wire.sx b/lib/mod/wire.sx new file mode 100644 index 00000000..6ed0ef85 --- /dev/null +++ b/lib/mod/wire.sx @@ -0,0 +1,55 @@ +;; lib/mod/wire.sx — portable decision wire format for federation transport. +;; +;; fed.sx shares decisions as in-memory dicts and leaves mod/fed-send! as the +;; transport seam. This is the bytes that cross it: a versioned, pipe-delimited +;; line encoding the verdict a peer needs (report id, action, rule) — enough to +;; trust-gate and apply/advise, without shipping the whole proof tree. The +;; loaded env has no string split, so split is built over slice/len. + +(define + mod/split-loop + (fn + (s ch n start pos acc) + (if + (= pos n) + (append acc (list (slice s start n))) + (if + (= (slice s pos (+ pos 1)) ch) + (mod/split-loop + s + ch + n + (+ pos 1) + (+ pos 1) + (append acc (list (slice s start pos)))) + (mod/split-loop s ch n start (+ pos 1) acc))))) + +(define + mod/split-char + (fn (s ch) (mod/split-loop s ch (len s) 0 0 (list)))) + +(define + mod/decision->wire + (fn + (d) + (str "MOD1|" (get d :report-id) "|" (get d :action) "|" (get d :rule)))) + +(define + mod/wire-valid? + (fn + (w) + (let + ((parts (mod/split-char w "|"))) + (if + (= (len parts) 4) + (= (nth parts 0) "MOD1") + false)))) + +(define + mod/wire->decision + (fn + (w) + (if + (mod/wire-valid? w) + (let ((parts (mod/split-char w "|"))) {:action (nth parts 2) :wire true :rule (nth parts 3) :report-id (nth parts 1)}) + nil))) diff --git a/plans/mod-on-sx.md b/plans/mod-on-sx.md index 742919eb..3595a15d 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` → **307/307** (roadmap + 13 extensions complete) +`bash lib/mod/conformance.sh` → **323/323** (roadmap + 14 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 14 — decision wire format** (`lib/mod/wire.sx`, +16). The bytes that + cross `fed/fed-send!`: `mod/decision->wire` emits a versioned pipe-delimited line + (`MOD1|r1|hide|spam-hide`), `mod/wire->decision` parses it back (`mod/wire-valid?` + guards). Built `mod/split-char` over `slice`/`len` (loaded env has no split). + Integration test exercises the full path: serialize → wire → deserialize → + `fed-receive-decision` trust-gating (untrusted→advisory, trusted→applied). - [x] **Ext 13 — SLA sweep over pending cases** (`lib/mod/sla.sx`, +15). Composes lifecycle (Phase 3) with time (Ext 12): a timed-case pairs a case with the tick it entered its state; `mod/overdue?` flags pending cases (open/triaged/appealed) @@ -207,6 +213,11 @@ lib/mod/fed.sx ## Progress log +- **Ext 14 — decision wire format, 323/323** (+16). Fills the federation transport + seam: decisions now serialize to a portable line and parse back, and the + integration test runs the whole federated path end-to-end (serialize on one + instance → trust-gated apply on another). Needed a hand-rolled `split-char` + (loaded env has no split) — over `slice`/`len`, same toolkit as `str-contains?`. - **Ext 13 — SLA sweep, 307/307** (+15). Two subsystems compose cleanly: lifecycle states + temporal ticks → "which pending cases have sat too long". Kept lifecycle pure by having the SLA layer carry entry-time externally (timed-case wrapper) From 329b3c49034969326a33e495ee7c367bc935120a Mon Sep 17 00:00:00 2001 From: giles Date: Sat, 6 Jun 2026 19:23:15 +0000 Subject: [PATCH 21/26] =?UTF-8?q?mod:=20Ext=2015=20=E2=80=94=20disjunctive?= =?UTF-8?q?=20(:any)=20conditions,=20333/333?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (:any (list c1 c2 ...)) compiles to Prolog disjunction (g1 ; g2 ; ...), completing the condition boolean algebra (AND via :when list, :not, :any). cond->goal recurses so combinators nest arbitrarily; the proof tree shows the compiled disjunction verbatim. Maps onto Prolog's control constructs rather than reimplementing boolean logic in SX. +10 tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/mod/conformance.conf | 1 + lib/mod/policy.sx | 28 +++++-- lib/mod/scoreboard.json | 9 ++- lib/mod/scoreboard.md | 3 +- lib/mod/tests/disjunction.sx | 145 +++++++++++++++++++++++++++++++++++ plans/mod-on-sx.md | 13 +++- 6 files changed, 185 insertions(+), 14 deletions(-) create mode 100644 lib/mod/tests/disjunction.sx diff --git a/lib/mod/conformance.conf b/lib/mod/conformance.conf index ee19cec8..0ed46604 100644 --- a/lib/mod/conformance.conf +++ b/lib/mod/conformance.conf @@ -48,4 +48,5 @@ SUITES=( "temporal:lib/mod/tests/temporal.sx:(mod-temporal-tests-run!)" "sla:lib/mod/tests/sla.sx:(mod-sla-tests-run!)" "wire:lib/mod/tests/wire.sx:(mod-wire-tests-run!)" + "disjunction:lib/mod/tests/disjunction.sx:(mod-disjunction-tests-run!)" ) diff --git a/lib/mod/policy.sx b/lib/mod/policy.sx index 758e2cae..c33a4e73 100644 --- a/lib/mod/policy.sx +++ b/lib/mod/policy.sx @@ -1,10 +1,13 @@ ;; lib/mod/policy.sx — moderation rules → Prolog clauses. ;; ;; A rule is {:name :action :when}. :when is a list of condition forms; each -;; compiles to a Prolog goal. Rule order is precedence: the engine queries with -;; pl-query-one, so the first clause that proves wins. The final default rule has -;; an empty body (true) so every report yields at least :keep — "no rule matched" -;; is a real result, not a query failure. +;; compiles to a Prolog goal. The conditions in a :when list are ANDed (joined by +;; ", "); :not negates and :any (a list of sub-conditions) disjoins — so the +;; condition language is a small boolean algebra over the leaf predicates. +;; Rule order is precedence: the engine queries with pl-query-one, so the first +;; clause that proves wins. The final default rule has an empty body (true) so +;; every report yields at least :keep — "no rule matched" is a real result, not a +;; query failure. ;; ;; cond->goal takes an id-term so the same condition can be compiled with the ;; head variable "Id" (for clause bodies) or a concrete report id (for proof-tree @@ -43,14 +46,14 @@ ;; (:classification "spam") → classification(Id, spam) ;; (:evidence "kind") → evidence(Id, 'kind', _) ;; (:attr "verified") → attr(Id, verified) -;; (:not ) → not() (negation as failure) +;; (:not ) → not() (negation) +;; (:any (list c1 c2 ...)) → (g1 ; g2 ; ...) (disjunction) ;; (:count-at-least 3) → report(Id, B, S), report_count(S, N), N >= 3 ;; (:score-at-least 5) → aggregate_all(sum(W), signal(Id, _, W), T), T >= 5 ;; (:reporters-at-least 2) → report(Id, _, Sr), setof(Br, report(_, Br, Sr), Bsr), -;; length(Bsr, Nr), Nr >= 2 (distinct reporters; -;; needs the quorum engine which asserts every report) +;; length(Bsr, Nr), Nr >= 2 (quorum engine) ;; (:burst-at-least 3) → report(Id, _, Sb), burst_count(Sb, Nb), Nb >= 3 -;; (reports in a time window; needs the temporal engine) +;; (temporal engine) (define mod/cond->goal @@ -71,6 +74,15 @@ ((= tag :attr) (str "attr(" idterm ", " (nth c 1) ")")) ((= tag :not) (str "not(" (mod/cond->goal (nth c 1) idterm) ")")) + ((= tag :any) + (str + "(" + (mod/join-with + " ; " + (map + (fn (sub) (mod/cond->goal sub idterm)) + (nth c 1))) + ")")) ((= tag :count-at-least) (str "report(" diff --git a/lib/mod/scoreboard.json b/lib/mod/scoreboard.json index 005313c5..f1207f71 100644 --- a/lib/mod/scoreboard.json +++ b/lib/mod/scoreboard.json @@ -1,8 +1,8 @@ { "lang": "mod", - "total_passed": 323, + "total_passed": 333, "total_failed": 0, - "total": 323, + "total": 333, "suites": [ {"name":"decide","passed":31,"failed":0,"total":31}, {"name":"audit","passed":29,"failed":0,"total":29}, @@ -19,7 +19,8 @@ {"name":"batch","passed":17,"failed":0,"total":17}, {"name":"temporal","passed":15,"failed":0,"total":15}, {"name":"sla","passed":15,"failed":0,"total":15}, - {"name":"wire","passed":16,"failed":0,"total":16} + {"name":"wire","passed":16,"failed":0,"total":16}, + {"name":"disjunction","passed":10,"failed":0,"total":10} ], - "generated": "2026-06-06T19:16:49+00:00" + "generated": "2026-06-06T19:22:42+00:00" } diff --git a/lib/mod/scoreboard.md b/lib/mod/scoreboard.md index 21b21cf7..5700d6dc 100644 --- a/lib/mod/scoreboard.md +++ b/lib/mod/scoreboard.md @@ -1,6 +1,6 @@ # mod scoreboard -**323 / 323 passing** (0 failure(s)). +**333 / 333 passing** (0 failure(s)). | Suite | Passed | Total | Status | |-------|--------|-------|--------| @@ -20,3 +20,4 @@ | temporal | 15 | 15 | ok | | sla | 15 | 15 | ok | | wire | 16 | 16 | ok | +| disjunction | 10 | 10 | ok | diff --git a/lib/mod/tests/disjunction.sx b/lib/mod/tests/disjunction.sx new file mode 100644 index 00000000..dd1f2891 --- /dev/null +++ b/lib/mod/tests/disjunction.sx @@ -0,0 +1,145 @@ +;; lib/mod/tests/disjunction.sx — Ext 15: disjunctive (:any) conditions. + +(define mod-or-count 0) +(define mod-or-pass 0) +(define mod-or-fail 0) +(define mod-or-failures (list)) + +(define + mod-or-test! + (fn + (name got expected) + (begin + (set! mod-or-count (+ mod-or-count 1)) + (if + (= got expected) + (set! mod-or-pass (+ mod-or-pass 1)) + (begin + (set! mod-or-fail (+ mod-or-fail 1)) + (append! + mod-or-failures + (str name "\n expected: " expected "\n got: " got))))))) + +;; one rule, OR of two classifications → one action covers both +(define + mod-or-rules + (list + (mod/mk-rule + "spam-or-abuse-hide" + :hide (list + (list + :any (list (list :classification "spam") (list :classification "abuse"))))) + (mod/mk-rule "default-keep" :keep (list)))) + +(define mod-or-spam (mod/mk-report "r1" "a" "b" "this is spam")) +(define mod-or-abuse (mod/mk-report "r2" "a" "b" "harassment here")) +(define mod-or-clean (mod/mk-report "r3" "a" "b" "a fine post")) + +(mod-or-test! + "OR: spam branch → hide" + (get + (mod/decide-report mod-or-spam (list mod-or-spam) mod-or-rules) + :action) + "hide") +(mod-or-test! + "OR: abuse branch → hide" + (get + (mod/decide-report mod-or-abuse (list mod-or-abuse) mod-or-rules) + :action) + "hide") +(mod-or-test! + "OR: neither branch → keep" + (get + (mod/decide-report mod-or-clean (list mod-or-clean) mod-or-rules) + :action) + "keep") + +;; ── goal text + proof ── + +(mod-or-test! + "cond->goal :any joins with ;" + (mod/cond->goal + (list + :any (list (list :classification "spam") (list :classification "abuse"))) + "Id") + "(classification(Id, spam) ; classification(Id, abuse))") + +(define + mod-or-dec + (mod/decide-report mod-or-spam (list mod-or-spam) mod-or-rules)) +(mod-or-test! + "OR proof goal solved" + (get (first (get (get mod-or-dec :proof) :goals)) :solved) + true) +(mod-or-test! + "OR proof goal text" + (get (first (get (get mod-or-dec :proof) :goals)) :goal) + "(classification(r1, spam) ; classification(r1, abuse))") + +;; ── :any composes with :not (NOR-ish) and :attr ── + +(define + mod-or-mixed-rules + (list + (mod/mk-rule + "spam-or-flagged-hide" + :hide (list + (list + :any (list (list :classification "spam") (list :attr "flagged"))))) + (mod/mk-rule "default-keep" :keep (list)))) + +(define + mod-or-flagged + (mod/attach-attr (mod/mk-report "r4" "a" "b" "a fine post") "flagged")) +(mod-or-test! + "OR over classification|attr: flagged clean post → hide" + (get + (mod/decide-report + mod-or-flagged + (list mod-or-flagged) + mod-or-mixed-rules) + :action) + "hide") + +(mod-or-test! + "cond->goal :any with :not branch" + (mod/cond->goal + (list + :any (list + (list :classification "spam") + (list :not (list :attr "verified")))) + "Id") + "(classification(Id, spam) ; not(attr(Id, verified)))") + +;; AND still works alongside OR in the same :when list +(define + mod-or-and-rules + (list + (mod/mk-rule + "spam-and-not-verified" + :hide (list + (list + :any (list (list :classification "spam") (list :classification "abuse"))) + (list :not (list :attr "verified")))) + (mod/mk-rule "default-keep" :keep (list)))) + +(define + mod-or-spam-verified + (mod/attach-attr (mod/mk-report "r5" "a" "b" "this is spam") "verified")) +(mod-or-test! + "AND of OR + NOT: verified spam → keep" + (get + (mod/decide-report + mod-or-spam-verified + (list mod-or-spam-verified) + mod-or-and-rules) + :action) + "keep") +(mod-or-test! + "AND of OR + NOT: unverified abuse → hide" + (get + (mod/decide-report mod-or-abuse (list mod-or-abuse) mod-or-and-rules) + :action) + "hide") + +(define mod-disjunction-tests-run! (fn () {:failures mod-or-failures :total mod-or-count :passed mod-or-pass :failed mod-or-fail})) diff --git a/plans/mod-on-sx.md b/plans/mod-on-sx.md index 3595a15d..27e10349 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` → **323/323** (roadmap + 14 extensions complete) +`bash lib/mod/conformance.sh` → **333/333** (roadmap + 15 extensions complete) ## Ground rules @@ -147,6 +147,11 @@ 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 15 — disjunctive conditions** (`policy.sx` + `tests/disjunction.sx`, + +10). `(:any (list c1 c2 …))` compiles to Prolog disjunction `(g1 ; g2 ; …)`, + completing the condition boolean algebra (AND via the :when list, `:not`, `:any`). + Composes recursively — `:any` over `:not`/`:attr`/classification, and ANDs with + other conditions in the same rule. One rule now covers "spam OR abuse". - [x] **Ext 14 — decision wire format** (`lib/mod/wire.sx`, +16). The bytes that cross `fed/fed-send!`: `mod/decision->wire` emits a versioned pipe-delimited line (`MOD1|r1|hide|spam-hide`), `mod/wire->decision` parses it back (`mod/wire-valid?` @@ -213,6 +218,12 @@ lib/mod/fed.sx ## Progress log +- **Ext 15 — disjunctive conditions, 333/333** (+10). The condition DSL is now a + full boolean algebra: AND (the :when list), `:not` (NAF), `:any` (Prolog `;`). + cond->goal recurses, so the combinators nest arbitrarily — `:any` of `:not`s, an + `:any` ANDed with a `:not`, etc. — and the proof tree shows the compiled + disjunction verbatim. Maps directly onto Prolog's own control constructs rather + than reimplementing boolean logic in SX. - **Ext 14 — decision wire format, 323/323** (+16). Fills the federation transport seam: decisions now serialize to a portable line and parse back, and the integration test runs the whole federated path end-to-end (serialize on one From 82fbf01bb3d27e04eb3af830bdc203cc02e04814 Mon Sep 17 00:00:00 2001 From: giles Date: Sat, 6 Jun 2026 19:28:49 +0000 Subject: [PATCH 22/26] =?UTF-8?q?mod:=20Ext=2016=20=E2=80=94=20ActivityPub?= =?UTF-8?q?-shaped=20decision=20export,=20350/350?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mod/decision->activity maps a decision to a moderation verb (remove→Delete, ban→Block, hide/escalate→Flag, keep→no activity) shaped like an AP activity, preserving the precise action. mod/decisions->activities batch-exports dropping keeps. With wire (Ext 14) + fed trust (Phase 4) the federated moderation path is end-to-end: decide → activity/wire → peer → trust-gate → apply. +17 tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/mod/activity.sx | 40 +++++++++++++++++ lib/mod/conformance.conf | 2 + lib/mod/scoreboard.json | 9 ++-- lib/mod/scoreboard.md | 3 +- lib/mod/tests/activity.sx | 95 +++++++++++++++++++++++++++++++++++++++ plans/mod-on-sx.md | 13 +++++- 6 files changed, 156 insertions(+), 6 deletions(-) create mode 100644 lib/mod/activity.sx create mode 100644 lib/mod/tests/activity.sx diff --git a/lib/mod/activity.sx b/lib/mod/activity.sx new file mode 100644 index 00000000..19f2ec7f --- /dev/null +++ b/lib/mod/activity.sx @@ -0,0 +1,40 @@ +;; lib/mod/activity.sx — export decisions as ActivityPub-shaped events. +;; +;; The rose-ash platform propagates cross-domain effects as ActivityPub-shaped +;; activities. A moderation decision maps to a moderation verb so the rest of the +;; platform (and federated peers) can act on it: remove→Delete, ban→Block, +;; hide/escalate→Flag, keep→no activity. The precise mod action is preserved in +;; :action so a consumer can disambiguate (e.g. hide vs escalate, both Flag). + +(define + mod/action->verb + (fn + (action) + (cond + ((= action "remove") "Delete") + ((= action "ban") "Block") + ((= action "hide") "Flag") + ((= action "escalate") "Flag") + (true nil)))) + +(define + mod/decision->activity + (fn + (d actor) + (let + ((verb (mod/action->verb (get d :action)))) + (if (nil? verb) nil {:type verb :action (get d :action) :actor actor :summary (str "moderation/" (get d :action) " via " (get d :rule)) :object (get d :report-id) :rule (get d :rule)})))) + +;; map a batch of decisions to activities, dropping the no-op keeps +(define + mod/decisions->activities + (fn + (decisions actor) + (reduce + (fn + (acc d) + (let + ((a (mod/decision->activity d actor))) + (if (nil? a) acc (append acc (list a))))) + (list) + decisions))) diff --git a/lib/mod/conformance.conf b/lib/mod/conformance.conf index 0ed46604..c451c523 100644 --- a/lib/mod/conformance.conf +++ b/lib/mod/conformance.conf @@ -23,6 +23,7 @@ PRELOADS=( lib/mod/temporal.sx lib/mod/sla.sx lib/mod/wire.sx + lib/mod/activity.sx lib/mod/lifecycle.sx lib/mod/audit.sx lib/mod/api.sx @@ -49,4 +50,5 @@ SUITES=( "sla:lib/mod/tests/sla.sx:(mod-sla-tests-run!)" "wire:lib/mod/tests/wire.sx:(mod-wire-tests-run!)" "disjunction:lib/mod/tests/disjunction.sx:(mod-disjunction-tests-run!)" + "activity:lib/mod/tests/activity.sx:(mod-activity-tests-run!)" ) diff --git a/lib/mod/scoreboard.json b/lib/mod/scoreboard.json index f1207f71..214d78b7 100644 --- a/lib/mod/scoreboard.json +++ b/lib/mod/scoreboard.json @@ -1,8 +1,8 @@ { "lang": "mod", - "total_passed": 333, + "total_passed": 350, "total_failed": 0, - "total": 333, + "total": 350, "suites": [ {"name":"decide","passed":31,"failed":0,"total":31}, {"name":"audit","passed":29,"failed":0,"total":29}, @@ -20,7 +20,8 @@ {"name":"temporal","passed":15,"failed":0,"total":15}, {"name":"sla","passed":15,"failed":0,"total":15}, {"name":"wire","passed":16,"failed":0,"total":16}, - {"name":"disjunction","passed":10,"failed":0,"total":10} + {"name":"disjunction","passed":10,"failed":0,"total":10}, + {"name":"activity","passed":17,"failed":0,"total":17} ], - "generated": "2026-06-06T19:22:42+00:00" + "generated": "2026-06-06T19:28:13+00:00" } diff --git a/lib/mod/scoreboard.md b/lib/mod/scoreboard.md index 5700d6dc..16224e3d 100644 --- a/lib/mod/scoreboard.md +++ b/lib/mod/scoreboard.md @@ -1,6 +1,6 @@ # mod scoreboard -**333 / 333 passing** (0 failure(s)). +**350 / 350 passing** (0 failure(s)). | Suite | Passed | Total | Status | |-------|--------|-------|--------| @@ -21,3 +21,4 @@ | sla | 15 | 15 | ok | | wire | 16 | 16 | ok | | disjunction | 10 | 10 | ok | +| activity | 17 | 17 | ok | diff --git a/lib/mod/tests/activity.sx b/lib/mod/tests/activity.sx new file mode 100644 index 00000000..00d2d3b0 --- /dev/null +++ b/lib/mod/tests/activity.sx @@ -0,0 +1,95 @@ +;; lib/mod/tests/activity.sx — Ext 16: ActivityPub-shaped decision export. + +(define mod-ap-count 0) +(define mod-ap-pass 0) +(define mod-ap-fail 0) +(define mod-ap-failures (list)) + +(define + mod-ap-test! + (fn + (name got expected) + (begin + (set! mod-ap-count (+ mod-ap-count 1)) + (if + (= got expected) + (set! mod-ap-pass (+ mod-ap-pass 1)) + (begin + (set! mod-ap-fail (+ mod-ap-fail 1)) + (append! + mod-ap-failures + (str name "\n expected: " expected "\n got: " got))))))) + +;; ── action → AP verb ── + +(mod-ap-test! "remove → Delete" (mod/action->verb "remove") "Delete") +(mod-ap-test! "ban → Block" (mod/action->verb "ban") "Block") +(mod-ap-test! "hide → Flag" (mod/action->verb "hide") "Flag") +(mod-ap-test! "escalate → Flag" (mod/action->verb "escalate") "Flag") +(mod-ap-test! "keep → nil (no activity)" (mod/action->verb "keep") nil) + +;; ── single decision → activity ── + +(define mod-ap-spam (mod/mk-report "r1" "a" "bob" "this is spam")) +(define + mod-ap-dec + (mod/decide-report mod-ap-spam (list mod-ap-spam) mod/default-rules)) +(define mod-ap-act (mod/decision->activity mod-ap-dec "instance.example")) + +(mod-ap-test! "activity type is Flag (hide)" (get mod-ap-act :type) "Flag") +(mod-ap-test! "activity object is report id" (get mod-ap-act :object) "r1") +(mod-ap-test! + "activity actor preserved" + (get mod-ap-act :actor) + "instance.example") +(mod-ap-test! + "activity preserves precise action" + (get mod-ap-act :action) + "hide") +(mod-ap-test! "activity carries rule" (get mod-ap-act :rule) "spam-hide") +(mod-ap-test! + "activity summary" + (get mod-ap-act :summary) + "moderation/hide via spam-hide") + +;; ── keep produces no activity ── + +(define mod-ap-clean (mod/mk-report "r2" "a" "b" "a fine post")) +(define + mod-ap-keep + (mod/decide-report mod-ap-clean (list mod-ap-clean) mod/default-rules)) +(mod-ap-test! + "keep decision → nil activity" + (mod/decision->activity mod-ap-keep "x") + nil) + +;; ── abuse → Delete ── + +(define mod-ap-abuse (mod/mk-report "r3" "a" "b" "harassment here")) +(define + mod-ap-abuse-dec + (mod/decide-report mod-ap-abuse (list mod-ap-abuse) mod/default-rules)) +(mod-ap-test! + "abuse decision → Delete activity" + (get (mod/decision->activity mod-ap-abuse-dec "x") :type) + "Delete") + +;; ── batch export drops keeps ── + +(define mod-ap-decisions (list mod-ap-dec mod-ap-keep mod-ap-abuse-dec)) +(define mod-ap-acts (mod/decisions->activities mod-ap-decisions "inst")) +(mod-ap-test! "batch export drops the keep" (len mod-ap-acts) 2) +(mod-ap-test! + "batch export first is the Flag" + (get (first mod-ap-acts) :type) + "Flag") +(mod-ap-test! + "batch export second is the Delete" + (get (nth mod-ap-acts 1) :type) + "Delete") +(mod-ap-test! + "empty decisions → no activities" + (mod/decisions->activities (list) "inst") + (list)) + +(define mod-activity-tests-run! (fn () {:failures mod-ap-failures :total mod-ap-count :passed mod-ap-pass :failed mod-ap-fail})) diff --git a/plans/mod-on-sx.md b/plans/mod-on-sx.md index 27e10349..363019c3 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` → **333/333** (roadmap + 15 extensions complete) +`bash lib/mod/conformance.sh` → **350/350** (roadmap + 16 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 16 — ActivityPub-shaped export** (`lib/mod/activity.sx`, +17). + `mod/decision->activity` maps a decision to a moderation verb (remove→Delete, + ban→Block, hide/escalate→Flag, keep→no activity) shaped like an AP activity + ({:type :actor :object :summary}), the precise mod action preserved in :action. + `mod/decisions->activities` batch-exports, dropping keeps — ready for the + platform's AP event bus / federated peers. - [x] **Ext 15 — disjunctive conditions** (`policy.sx` + `tests/disjunction.sx`, +10). `(:any (list c1 c2 …))` compiles to Prolog disjunction `(g1 ; g2 ; …)`, completing the condition boolean algebra (AND via the :when list, `:not`, `:any`). @@ -218,6 +224,11 @@ lib/mod/fed.sx ## Progress log +- **Ext 16 — ActivityPub-shaped export, 350/350** (+17). Bridges mod-sx to the + wider rose-ash platform, which propagates cross-domain effects as AP-shaped + activities. Decisions become Flag/Delete/Block activities (keep = no-op); with + the wire format (Ext 14) and fed trust model (Phase 4) the federated moderation + path is now end-to-end: decide → activity/wire → peer → trust-gate → apply. - **Ext 15 — disjunctive conditions, 333/333** (+10). The condition DSL is now a full boolean algebra: AND (the :when list), `:not` (NAF), `:any` (Prolog `;`). cond->goal recurses, so the combinators nest arbitrarily — `:any` of `:not`s, an From 2f75ab11fc453573fa51cfe27135c90ee5131971 Mon Sep 17 00:00:00 2001 From: giles Date: Sat, 6 Jun 2026 19:33:24 +0000 Subject: [PATCH 23/26] =?UTF-8?q?mod:=20Ext=2017=20=E2=80=94=20per-domain?= =?UTF-8?q?=20policy=20registry,=20364/364?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mod/register-policy! domain rules + mod/decide-in domain r reports give each rose-ash domain its own rule set; unregistered domains fall back to default-rules (never unmoderated). Same spam report → remove under a strict market policy, hide under blog default. Engine already took rules as a param, so this is registry + fallback, no engine change. +14 tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/mod/conformance.conf | 2 + lib/mod/policies.sx | 40 ++++++++++++++ lib/mod/scoreboard.json | 9 +-- lib/mod/scoreboard.md | 3 +- lib/mod/tests/policies.sx | 112 ++++++++++++++++++++++++++++++++++++++ plans/mod-on-sx.md | 12 +++- 6 files changed, 172 insertions(+), 6 deletions(-) create mode 100644 lib/mod/policies.sx create mode 100644 lib/mod/tests/policies.sx diff --git a/lib/mod/conformance.conf b/lib/mod/conformance.conf index c451c523..4410b97f 100644 --- a/lib/mod/conformance.conf +++ b/lib/mod/conformance.conf @@ -24,6 +24,7 @@ PRELOADS=( lib/mod/sla.sx lib/mod/wire.sx lib/mod/activity.sx + lib/mod/policies.sx lib/mod/lifecycle.sx lib/mod/audit.sx lib/mod/api.sx @@ -51,4 +52,5 @@ SUITES=( "wire:lib/mod/tests/wire.sx:(mod-wire-tests-run!)" "disjunction:lib/mod/tests/disjunction.sx:(mod-disjunction-tests-run!)" "activity:lib/mod/tests/activity.sx:(mod-activity-tests-run!)" + "policies:lib/mod/tests/policies.sx:(mod-policies-tests-run!)" ) diff --git a/lib/mod/policies.sx b/lib/mod/policies.sx new file mode 100644 index 00000000..a005e671 --- /dev/null +++ b/lib/mod/policies.sx @@ -0,0 +1,40 @@ +;; lib/mod/policies.sx — per-domain policy registry. +;; +;; rose-ash spans domains (blog, market, events, federation, …) that want +;; different moderation — a marketplace listing and a blog comment are not held to +;; the same bar. This registry maps a domain to a rule set; mod/decide-in resolves +;; the right policy and decides. Unregistered domains fall back to the default +;; rules, so adding a domain never leaves it unmoderated. + +(define mod/*policies* (list)) + +(define mod/policies-reset! (fn () (set! mod/*policies* (list)))) + +(define + mod/register-policy! + (fn (domain rules) (begin (append! mod/*policies* {:domain domain :rules rules}) true))) + +(define + mod/policy-registered? + (fn + (domain) + (mod/any? (fn (p) (= (get p :domain) domain)) mod/*policies*))) + +(define + mod/policy-for + (fn + (domain) + (reduce + (fn (acc p) (if (= (get p :domain) domain) (get p :rules) acc)) + mod/default-rules + mod/*policies*))) + +(define + mod/decide-in + (fn + (domain r reports) + (mod/decide-report r reports (mod/policy-for domain)))) + +(define + mod/registered-domains + (fn () (map (fn (p) (get p :domain)) mod/*policies*))) diff --git a/lib/mod/scoreboard.json b/lib/mod/scoreboard.json index 214d78b7..3fff5615 100644 --- a/lib/mod/scoreboard.json +++ b/lib/mod/scoreboard.json @@ -1,8 +1,8 @@ { "lang": "mod", - "total_passed": 350, + "total_passed": 364, "total_failed": 0, - "total": 350, + "total": 364, "suites": [ {"name":"decide","passed":31,"failed":0,"total":31}, {"name":"audit","passed":29,"failed":0,"total":29}, @@ -21,7 +21,8 @@ {"name":"sla","passed":15,"failed":0,"total":15}, {"name":"wire","passed":16,"failed":0,"total":16}, {"name":"disjunction","passed":10,"failed":0,"total":10}, - {"name":"activity","passed":17,"failed":0,"total":17} + {"name":"activity","passed":17,"failed":0,"total":17}, + {"name":"policies","passed":14,"failed":0,"total":14} ], - "generated": "2026-06-06T19:28:13+00:00" + "generated": "2026-06-06T19:32:52+00:00" } diff --git a/lib/mod/scoreboard.md b/lib/mod/scoreboard.md index 16224e3d..153e6112 100644 --- a/lib/mod/scoreboard.md +++ b/lib/mod/scoreboard.md @@ -1,6 +1,6 @@ # mod scoreboard -**350 / 350 passing** (0 failure(s)). +**364 / 364 passing** (0 failure(s)). | Suite | Passed | Total | Status | |-------|--------|-------|--------| @@ -22,3 +22,4 @@ | wire | 16 | 16 | ok | | disjunction | 10 | 10 | ok | | activity | 17 | 17 | ok | +| policies | 14 | 14 | ok | diff --git a/lib/mod/tests/policies.sx b/lib/mod/tests/policies.sx new file mode 100644 index 00000000..daa0c4e1 --- /dev/null +++ b/lib/mod/tests/policies.sx @@ -0,0 +1,112 @@ +;; lib/mod/tests/policies.sx — Ext 17: per-domain policy registry. + +(define mod-pol-count 0) +(define mod-pol-pass 0) +(define mod-pol-fail 0) +(define mod-pol-failures (list)) + +(define + mod-pol-test! + (fn + (name got expected) + (begin + (set! mod-pol-count (+ mod-pol-count 1)) + (if + (= got expected) + (set! mod-pol-pass (+ mod-pol-pass 1)) + (begin + (set! mod-pol-fail (+ mod-pol-fail 1)) + (append! + mod-pol-failures + (str name "\n expected: " expected "\n got: " got))))))) + +(mod/policies-reset!) + +;; market is strict: spam is removed outright, not just hidden +(define + mod-pol-market-rules + (list + (mod/mk-rule + "market-spam-remove" + :remove (list (list :classification "spam"))) + (mod/mk-rule "default-keep" :keep (list)))) + +(mod-pol-test! + "unregistered domain falls back to default" + (mod/policy-registered? "market") + false) +(mod/register-policy! "market" mod-pol-market-rules) +(mod-pol-test! + "domain registered after register!" + (mod/policy-registered? "market") + true) + +(define mod-pol-spam (mod/mk-report "r1" "a" "b" "this is spam")) + +;; ── same report, different domain → different action ── + +(mod-pol-test! + "market policy removes spam" + (get (mod/decide-in "market" mod-pol-spam (list mod-pol-spam)) :action) + "remove") +(mod-pol-test! + "market decision uses market rule" + (get (mod/decide-in "market" mod-pol-spam (list mod-pol-spam)) :rule) + "market-spam-remove") +(mod-pol-test! + "blog (unregistered) uses default → hide" + (get (mod/decide-in "blog" mod-pol-spam (list mod-pol-spam)) :action) + "hide") +(mod-pol-test! + "blog decision uses default rule" + (get (mod/decide-in "blog" mod-pol-spam (list mod-pol-spam)) :rule) + "spam-hide") + +;; ── policy-for resolution ── + +(mod-pol-test! + "policy-for market returns market rules" + (mod/policy-for "market") + mod-pol-market-rules) +(mod-pol-test! + "policy-for unknown returns default" + (mod/policy-for "events") + mod/default-rules) +(mod-pol-test! + "registered-domains lists market" + (mod/registered-domains) + (list "market")) + +;; ── a second domain ── + +(define + mod-pol-events-rules + (list (mod/mk-rule "events-keep-all" :keep (list)))) + +(mod/register-policy! "events" mod-pol-events-rules) +(mod-pol-test! + "events policy keeps everything (even spam)" + (get (mod/decide-in "events" mod-pol-spam (list mod-pol-spam)) :action) + "keep") +(mod-pol-test! + "two domains registered" + (len (mod/registered-domains)) + 2) +(mod-pol-test! + "market still removes after second registration" + (get (mod/decide-in "market" mod-pol-spam (list mod-pol-spam)) :action) + "remove") + +;; ── clean report is keep everywhere ── + +(define mod-pol-clean (mod/mk-report "r2" "a" "b" "a fine post")) +(mod-pol-test! + "clean report keep in market" + (get (mod/decide-in "market" mod-pol-clean (list mod-pol-clean)) :action) + "keep") +(mod-pol-test! + "clean report keep in blog" + (get (mod/decide-in "blog" mod-pol-clean (list mod-pol-clean)) :action) + "keep") + +(define mod-policies-tests-run! (fn () {:failures mod-pol-failures :total mod-pol-count :passed mod-pol-pass :failed mod-pol-fail})) diff --git a/plans/mod-on-sx.md b/plans/mod-on-sx.md index 363019c3..c89e47ec 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` → **350/350** (roadmap + 16 extensions complete) +`bash lib/mod/conformance.sh` → **364/364** (roadmap + 17 extensions complete) ## Ground rules @@ -147,6 +147,11 @@ 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 17 — per-domain policy registry** (`lib/mod/policies.sx`, +14). + `mod/register-policy! domain rules` + `mod/decide-in domain r reports` give each + rose-ash domain (blog/market/events/…) its own rule set; unregistered domains + fall back to default-rules so a new domain is never unmoderated. Same spam report + → :remove under a strict market policy, :hide under blog's default. - [x] **Ext 16 — ActivityPub-shaped export** (`lib/mod/activity.sx`, +17). `mod/decision->activity` maps a decision to a moderation verb (remove→Delete, ban→Block, hide/escalate→Flag, keep→no activity) shaped like an AP activity @@ -224,6 +229,11 @@ lib/mod/fed.sx ## Progress log +- **Ext 17 — per-domain policy registry, 364/364** (+14). Multi-tenant policy: + the engine already took `rules` as a parameter, so domain-scoping is just a + registry + a default fallback — no engine change. Makes the whole policy + vocabulary (16 prior features) per-domain configurable. Default fallback means + adding a domain can't accidentally leave it unmoderated. - **Ext 16 — ActivityPub-shaped export, 350/350** (+17). Bridges mod-sx to the wider rose-ash platform, which propagates cross-domain effects as AP-shaped activities. Decisions become Flag/Delete/Block activities (keep = no-op); with From c19f658cf26b955c6e7c535875973ff144a7ccf3 Mon Sep 17 00:00:00 2001 From: giles Date: Sat, 6 Jun 2026 19:37:12 +0000 Subject: [PATCH 24/26] =?UTF-8?q?mod:=20Ext=2018=20=E2=80=94=20ergonomic?= =?UTF-8?q?=20defrule=20/=20ruleset=20surface,=20375/375?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mod/defrule collects trailing conditions via &rest; mod/ruleset assembles rules. No macro needed — conditions are plain data, fn supports &rest here. Produces structurally identical rules to mk-rule (asserted) and works in the engine unchanged. Closes the roadmap's original defrule surface. +11 tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/mod/conformance.conf | 2 + lib/mod/defrule.sx | 16 +++++++ lib/mod/scoreboard.json | 9 ++-- lib/mod/scoreboard.md | 3 +- lib/mod/tests/defrule.sx | 95 ++++++++++++++++++++++++++++++++++++++++ plans/mod-on-sx.md | 12 ++++- 6 files changed, 131 insertions(+), 6 deletions(-) create mode 100644 lib/mod/defrule.sx create mode 100644 lib/mod/tests/defrule.sx diff --git a/lib/mod/conformance.conf b/lib/mod/conformance.conf index 4410b97f..fb122cb3 100644 --- a/lib/mod/conformance.conf +++ b/lib/mod/conformance.conf @@ -12,6 +12,7 @@ PRELOADS=( lib/prolog/compiler.sx lib/mod/schema.sx lib/mod/policy.sx + lib/mod/defrule.sx lib/mod/engine.sx lib/mod/explain.sx lib/mod/severity.sx @@ -53,4 +54,5 @@ SUITES=( "disjunction:lib/mod/tests/disjunction.sx:(mod-disjunction-tests-run!)" "activity:lib/mod/tests/activity.sx:(mod-activity-tests-run!)" "policies:lib/mod/tests/policies.sx:(mod-policies-tests-run!)" + "defrule:lib/mod/tests/defrule.sx:(mod-defrule-tests-run!)" ) diff --git a/lib/mod/defrule.sx b/lib/mod/defrule.sx new file mode 100644 index 00000000..922cbb7d --- /dev/null +++ b/lib/mod/defrule.sx @@ -0,0 +1,16 @@ +;; lib/mod/defrule.sx — ergonomic rule / ruleset construction. +;; +;; The roadmap sketched a (defrule action :when conditions) surface. Conditions +;; already evaluate to plain data, so this needs no macro — variadic functions +;; suffice: mod/defrule collects its trailing condition forms via &rest (dropping +;; the explicit outer (list ...)), and mod/ruleset assembles rules the same way. +;; +;; (mod/ruleset +;; (mod/defrule "spam-hide" :hide (list :classification "spam")) +;; (mod/defrule "default-keep" :keep)) + +(define + mod/defrule + (fn (name action &rest conds) (mod/mk-rule name action conds))) + +(define mod/ruleset (fn (&rest rules) rules)) diff --git a/lib/mod/scoreboard.json b/lib/mod/scoreboard.json index 3fff5615..f5de3c9c 100644 --- a/lib/mod/scoreboard.json +++ b/lib/mod/scoreboard.json @@ -1,8 +1,8 @@ { "lang": "mod", - "total_passed": 364, + "total_passed": 375, "total_failed": 0, - "total": 364, + "total": 375, "suites": [ {"name":"decide","passed":31,"failed":0,"total":31}, {"name":"audit","passed":29,"failed":0,"total":29}, @@ -22,7 +22,8 @@ {"name":"wire","passed":16,"failed":0,"total":16}, {"name":"disjunction","passed":10,"failed":0,"total":10}, {"name":"activity","passed":17,"failed":0,"total":17}, - {"name":"policies","passed":14,"failed":0,"total":14} + {"name":"policies","passed":14,"failed":0,"total":14}, + {"name":"defrule","passed":11,"failed":0,"total":11} ], - "generated": "2026-06-06T19:32:52+00:00" + "generated": "2026-06-06T19:36:45+00:00" } diff --git a/lib/mod/scoreboard.md b/lib/mod/scoreboard.md index 153e6112..08620704 100644 --- a/lib/mod/scoreboard.md +++ b/lib/mod/scoreboard.md @@ -1,6 +1,6 @@ # mod scoreboard -**364 / 364 passing** (0 failure(s)). +**375 / 375 passing** (0 failure(s)). | Suite | Passed | Total | Status | |-------|--------|-------|--------| @@ -23,3 +23,4 @@ | disjunction | 10 | 10 | ok | | activity | 17 | 17 | ok | | policies | 14 | 14 | ok | +| defrule | 11 | 11 | ok | diff --git a/lib/mod/tests/defrule.sx b/lib/mod/tests/defrule.sx new file mode 100644 index 00000000..894aee85 --- /dev/null +++ b/lib/mod/tests/defrule.sx @@ -0,0 +1,95 @@ +;; lib/mod/tests/defrule.sx — Ext 18: ergonomic defrule / ruleset. + +(define mod-dr-count 0) +(define mod-dr-pass 0) +(define mod-dr-fail 0) +(define mod-dr-failures (list)) + +(define + mod-dr-test! + (fn + (name got expected) + (begin + (set! mod-dr-count (+ mod-dr-count 1)) + (if + (= got expected) + (set! mod-dr-pass (+ mod-dr-pass 1)) + (begin + (set! mod-dr-fail (+ mod-dr-fail 1)) + (append! + mod-dr-failures + (str name "\n expected: " expected "\n got: " got))))))) + +;; ── defrule produces the same structure as mk-rule ── + +(define + mod-dr-r + (mod/defrule "spam-hide" :hide (list :classification "spam"))) +(mod-dr-test! "defrule name" (mod/rule-name mod-dr-r) "spam-hide") +(mod-dr-test! "defrule action" (mod/rule-action mod-dr-r) "hide") +(mod-dr-test! + "defrule when wraps the conditions" + (mod/rule-when mod-dr-r) + (list (list :classification "spam"))) +(mod-dr-test! + "defrule equals mk-rule equivalent" + (mod/rule-when mod-dr-r) + (mod/rule-when + (mod/mk-rule "spam-hide" :hide (list (list :classification "spam"))))) + +;; ── multi-condition + no-condition ── + +(define + mod-dr-multi + (mod/defrule + "strict" + :hide (list :classification "spam") + (list :not (list :attr "verified")))) +(mod-dr-test! + "defrule collects multiple conditions" + (len (mod/rule-when mod-dr-multi)) + 2) + +(define mod-dr-catch (mod/defrule "default-keep" :keep)) +(mod-dr-test! + "defrule with no conditions is unconditional" + (mod/rule-when mod-dr-catch) + (list)) + +;; ── ruleset assembles a list ── + +(define + mod-dr-rules + (mod/ruleset + (mod/defrule "spam-hide" :hide (list :classification "spam")) + (mod/defrule "default-keep" :keep))) + +(mod-dr-test! "ruleset length" (len mod-dr-rules) 2) +(mod-dr-test! + "ruleset first rule name" + (mod/rule-name (first mod-dr-rules)) + "spam-hide") + +;; ── engine works with defrule/ruleset-built policy ── + +(define mod-dr-spam (mod/mk-report "r1" "a" "b" "this is spam")) +(define mod-dr-clean (mod/mk-report "r2" "a" "b" "a fine post")) + +(mod-dr-test! + "defrule policy: spam → hide" + (get + (mod/decide-report mod-dr-spam (list mod-dr-spam) mod-dr-rules) + :action) + "hide") +(mod-dr-test! + "defrule policy: clean → keep" + (get + (mod/decide-report mod-dr-clean (list mod-dr-clean) mod-dr-rules) + :action) + "keep") +(mod-dr-test! + "defrule policy: spam names the rule" + (get (mod/decide-report mod-dr-spam (list mod-dr-spam) mod-dr-rules) :rule) + "spam-hide") + +(define mod-defrule-tests-run! (fn () {:failures mod-dr-failures :total mod-dr-count :passed mod-dr-pass :failed mod-dr-fail})) diff --git a/plans/mod-on-sx.md b/plans/mod-on-sx.md index c89e47ec..af551e59 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` → **364/364** (roadmap + 17 extensions complete) +`bash lib/mod/conformance.sh` → **375/375** (roadmap + 18 extensions complete) ## Ground rules @@ -147,6 +147,11 @@ 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 18 — ergonomic defrule / ruleset** (`lib/mod/defrule.sx`, +11). The + roadmap's `(defrule …)` surface, done with `&rest` variadics (no macro needed — + conditions are already plain data): `mod/defrule` collects trailing conditions, + `mod/ruleset` assembles rules. Produces structurally identical rules to `mk-rule` + and works in the engine unchanged. - [x] **Ext 17 — per-domain policy registry** (`lib/mod/policies.sx`, +14). `mod/register-policy! domain rules` + `mod/decide-in domain r reports` give each rose-ash domain (blog/market/events/…) its own rule set; unregistered domains @@ -229,6 +234,11 @@ lib/mod/fed.sx ## Progress log +- **Ext 18 — ergonomic defrule / ruleset, 375/375** (+11). Closes the roadmap's + original `defrule` surface. `fn` supports `&rest` here, and conditions evaluate + to plain data, so no macro is needed — variadic functions give the ergonomics + safely. Equivalence to `mk-rule` is asserted, so it's pure sugar with no new + semantics. - **Ext 17 — per-domain policy registry, 364/364** (+14). Multi-tenant policy: the engine already took `rules` as a parameter, so domain-scoping is just a registry + a default fallback — no engine change. Makes the whole policy From 739e7439186877b0a16880ea93c4522862d4e46d Mon Sep 17 00:00:00 2001 From: giles Date: Sat, 6 Jun 2026 19:40:36 +0000 Subject: [PATCH 25/26] =?UTF-8?q?mod:=20Ext=2019=20=E2=80=94=20end-to-end?= =?UTF-8?q?=20triage=20pipeline=20(capstone),=20390/390?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mod/triage-pipeline domain r reports actor composes domain-policy decision → explanation → AP activity → wire into one bundle. Integration test runs the whole federated path across 5 modules (decide → wire → peer → trust-gated apply), confirming the module-by-module subsystem composes end to end. +15 tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/mod/conformance.conf | 2 + lib/mod/pipeline.sx | 18 ++++++ lib/mod/scoreboard.json | 9 +-- lib/mod/scoreboard.md | 3 +- lib/mod/tests/pipeline.sx | 112 ++++++++++++++++++++++++++++++++++++++ plans/mod-on-sx.md | 16 +++++- 6 files changed, 154 insertions(+), 6 deletions(-) create mode 100644 lib/mod/pipeline.sx create mode 100644 lib/mod/tests/pipeline.sx diff --git a/lib/mod/conformance.conf b/lib/mod/conformance.conf index fb122cb3..565d3f49 100644 --- a/lib/mod/conformance.conf +++ b/lib/mod/conformance.conf @@ -26,6 +26,7 @@ PRELOADS=( lib/mod/wire.sx lib/mod/activity.sx lib/mod/policies.sx + lib/mod/pipeline.sx lib/mod/lifecycle.sx lib/mod/audit.sx lib/mod/api.sx @@ -55,4 +56,5 @@ SUITES=( "activity:lib/mod/tests/activity.sx:(mod-activity-tests-run!)" "policies:lib/mod/tests/policies.sx:(mod-policies-tests-run!)" "defrule:lib/mod/tests/defrule.sx:(mod-defrule-tests-run!)" + "pipeline:lib/mod/tests/pipeline.sx:(mod-pipeline-tests-run!)" ) diff --git a/lib/mod/pipeline.sx b/lib/mod/pipeline.sx new file mode 100644 index 00000000..c663f531 --- /dev/null +++ b/lib/mod/pipeline.sx @@ -0,0 +1,18 @@ +;; lib/mod/pipeline.sx — end-to-end triage orchestration. +;; +;; A single entry point that runs a report through the subsystem and returns the +;; full artifact bundle: the decision (under the report's domain policy), a +;; human-readable explanation, an ActivityPub-shaped event for the bus, and the +;; wire line for federated peers. Composes policies (Ext 17), explain (Ext 3), +;; activity (Ext 16) and wire (Ext 14) — the modules are independent, this is just +;; the convenience that wires them together for the common "process a report" path. + +(define + mod/triage-pipeline + (fn + (domain r reports actor) + (let ((d (mod/decide-in domain r reports))) {:activity (mod/decision->activity d actor) :action (get d :action) :wire (mod/decision->wire d) :rule (get d :rule) :decision d :explanation (mod/explain d)}))) + +(define mod/pipeline-action (fn (p) (get p :action))) +(define mod/pipeline-activity (fn (p) (get p :activity))) +(define mod/pipeline-wire (fn (p) (get p :wire))) diff --git a/lib/mod/scoreboard.json b/lib/mod/scoreboard.json index f5de3c9c..47f3c4c2 100644 --- a/lib/mod/scoreboard.json +++ b/lib/mod/scoreboard.json @@ -1,8 +1,8 @@ { "lang": "mod", - "total_passed": 375, + "total_passed": 390, "total_failed": 0, - "total": 375, + "total": 390, "suites": [ {"name":"decide","passed":31,"failed":0,"total":31}, {"name":"audit","passed":29,"failed":0,"total":29}, @@ -23,7 +23,8 @@ {"name":"disjunction","passed":10,"failed":0,"total":10}, {"name":"activity","passed":17,"failed":0,"total":17}, {"name":"policies","passed":14,"failed":0,"total":14}, - {"name":"defrule","passed":11,"failed":0,"total":11} + {"name":"defrule","passed":11,"failed":0,"total":11}, + {"name":"pipeline","passed":15,"failed":0,"total":15} ], - "generated": "2026-06-06T19:36:45+00:00" + "generated": "2026-06-06T19:40:03+00:00" } diff --git a/lib/mod/scoreboard.md b/lib/mod/scoreboard.md index 08620704..3b494650 100644 --- a/lib/mod/scoreboard.md +++ b/lib/mod/scoreboard.md @@ -1,6 +1,6 @@ # mod scoreboard -**375 / 375 passing** (0 failure(s)). +**390 / 390 passing** (0 failure(s)). | Suite | Passed | Total | Status | |-------|--------|-------|--------| @@ -24,3 +24,4 @@ | activity | 17 | 17 | ok | | policies | 14 | 14 | ok | | defrule | 11 | 11 | ok | +| pipeline | 15 | 15 | ok | diff --git a/lib/mod/tests/pipeline.sx b/lib/mod/tests/pipeline.sx new file mode 100644 index 00000000..0e52f30b --- /dev/null +++ b/lib/mod/tests/pipeline.sx @@ -0,0 +1,112 @@ +;; lib/mod/tests/pipeline.sx — Ext 19: end-to-end triage orchestration. + +(define mod-pp-count 0) +(define mod-pp-pass 0) +(define mod-pp-fail 0) +(define mod-pp-failures (list)) + +(define + mod-pp-test! + (fn + (name got expected) + (begin + (set! mod-pp-count (+ mod-pp-count 1)) + (if + (= got expected) + (set! mod-pp-pass (+ mod-pp-pass 1)) + (begin + (set! mod-pp-fail (+ mod-pp-fail 1)) + (append! + mod-pp-failures + (str name "\n expected: " expected "\n got: " got))))))) + +(mod/policies-reset!) +(mod/register-policy! + "market" + (mod/ruleset + (mod/defrule "market-spam-remove" :remove (list :classification "spam")) + (mod/defrule "default-keep" :keep))) + +;; ── spam in the market domain: full bundle ── + +(define mod-pp-spam (mod/mk-report "r1" "u" "bob" "this is spam")) +(define + mod-pp + (mod/triage-pipeline "market" mod-pp-spam (list mod-pp-spam) "inst.example")) + +(mod-pp-test! + "pipeline action (market policy → remove)" + (mod/pipeline-action mod-pp) + "remove") +(mod-pp-test! "pipeline rule" (get mod-pp :rule) "market-spam-remove") +(mod-pp-test! + "pipeline explanation mentions the action" + (mod/str-contains? (get mod-pp :explanation) "remove") + true) +(mod-pp-test! + "pipeline activity is Delete (remove)" + (get (mod/pipeline-activity mod-pp) :type) + "Delete") +(mod-pp-test! + "pipeline activity object is the report" + (get (mod/pipeline-activity mod-pp) :object) + "r1") +(mod-pp-test! + "pipeline wire round-trips to the same action" + (get (mod/wire->decision (mod/pipeline-wire mod-pp)) :action) + "remove") + +;; ── same report, blog domain (default) → hide, Flag ── + +(define + mod-pp-blog + (mod/triage-pipeline "blog" mod-pp-spam (list mod-pp-spam) "inst.example")) +(mod-pp-test! + "blog default policy → hide" + (mod/pipeline-action mod-pp-blog) + "hide") +(mod-pp-test! + "blog activity is Flag" + (get (mod/pipeline-activity mod-pp-blog) :type) + "Flag") + +;; ── clean report: keep, no activity, explanation says (none) ── + +(define mod-pp-clean (mod/mk-report "r2" "u" "eve" "a fine post")) +(define + mod-pp-k + (mod/triage-pipeline + "market" + mod-pp-clean + (list mod-pp-clean) + "inst.example")) +(mod-pp-test! "clean → keep" (mod/pipeline-action mod-pp-k) "keep") +(mod-pp-test! "keep → no activity" (mod/pipeline-activity mod-pp-k) nil) +(mod-pp-test! + "keep explanation says no evidence" + (mod/str-contains? (get mod-pp-k :explanation) "Evidence: (none)") + true) +(mod-pp-test! + "keep wire still round-trips" + (get (mod/wire->decision (mod/pipeline-wire mod-pp-k)) :rule) + "default-keep") + +;; ── federated handoff: market decision crosses to a peer, trust-gated ── + +(mod/fed-reset!) +(define mod-pp-peer-dec (mod/wire->decision (mod/pipeline-wire mod-pp))) +(mod-pp-test! + "untrusted peer: market decision is advisory" + (get (mod/fed-receive-decision "peerX" mod-pp-peer-dec) :applied) + false) +(mod/grant-trust "peerY" :mod) +(mod-pp-test! + "trusted peer: market decision applies" + (get (mod/fed-receive-decision "peerY" mod-pp-peer-dec) :applied) + true) +(mod-pp-test! + "applied action is remove" + (get (mod/fed-applied-action "r1") :action) + "remove") + +(define mod-pipeline-tests-run! (fn () {:failures mod-pp-failures :total mod-pp-count :passed mod-pp-pass :failed mod-pp-fail})) diff --git a/plans/mod-on-sx.md b/plans/mod-on-sx.md index af551e59..f613eae3 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` → **375/375** (roadmap + 18 extensions complete) +`bash lib/mod/conformance.sh` → **390/390** (roadmap + 19 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 19 — end-to-end triage pipeline** (`lib/mod/pipeline.sx`, +15). + `mod/triage-pipeline domain r reports actor` runs a report through domain-policy + decision → explanation → AP activity → wire, returning the full bundle. The test + is a genuine integration across 5 modules including a federated handoff (market + decision → wire → peer → trust-gated apply). The capstone that proves the + independently-built modules compose. - [x] **Ext 18 — ergonomic defrule / ruleset** (`lib/mod/defrule.sx`, +11). The roadmap's `(defrule …)` surface, done with `&rest` variadics (no macro needed — conditions are already plain data): `mod/defrule` collects trailing conditions, @@ -234,6 +240,14 @@ lib/mod/fed.sx ## Progress log +- **Ext 19 — end-to-end triage pipeline, 390/390** (+15). Capstone: one + orchestration call composes domain policy + decide + explain + activity + wire, + and the integration test runs the whole federated path (decide in a domain → + wire → peer → trust-gated apply) across 5 modules. Confirms the subsystem — built + module-by-module — actually composes end to end. mod-sx now spans schema → policy + DSL (boolean algebra + count/score/reporters/burst) → engine + proofs → audit → + lifecycle → SLA → federation (trust/wire/AP) → analytics (trace/whatif/lint/batch) + → domain policies → pipeline, all on the green lib/prolog substrate, 390 tests. - **Ext 18 — ergonomic defrule / ruleset, 375/375** (+11). Closes the roadmap's original `defrule` surface. `fn` supports `&rest` here, and conditions evaluate to plain data, so no macro is needed — variadic functions give the ergonomics From 538b8a53e06ce196fd9fc038a1b05a882445685b Mon Sep 17 00:00:00 2001 From: giles Date: Sat, 6 Jun 2026 22:20:52 +0000 Subject: [PATCH 26/26] =?UTF-8?q?plans:=20shared-plumbing=20extraction=20n?= =?UTF-8?q?ote=20=E2=80=94=20defer=20to=20post-merge=20integration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mod-sx (Prolog) and acl-sx (Datalog) converged on the same module shape but run on different engines. Only the audit log + fed trust/outbox shapes truly share; extract at the architecture-merge point refactoring both consumers atomically, not unilaterally from a loop branch. Co-Authored-By: Claude Opus 4.8 (1M context) --- plans/mod-on-sx.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/plans/mod-on-sx.md b/plans/mod-on-sx.md index f613eae3..876b92e8 100644 --- a/plans/mod-on-sx.md +++ b/plans/mod-on-sx.md @@ -238,6 +238,29 @@ lib/mod/fed.sx `mod/dedup-reports` collapses identical reports (reporter|subject|reason key, case-insensitive); `mod/distinct-reporters-of` counts unique reporters. +## Shared-plumbing extraction — post-merge integration note + +mod-sx (Prolog) and acl-sx (Datalog, `lib/acl/`, 120/120) independently converged +on the same module shape: `schema / engine / audit / explain / federation / api`. +That parallel is the signal both plans flagged. **Recommendation: do NOT extract +from a loop branch — extract at the architecture-merge integration point, after +both `lib/mod` and `lib/acl` have landed, refactoring both consumers in one change.** + +- **Different engines.** acl = Datalog bottom-up (native derivation trees); mod = + Prolog backtracking (proof via per-goal `pl-query-all`). The engine and most of + `explain` are NOT shared — same intent, different mechanism. Don't try to unify them. +- **Genuinely convergent shapes (the only real candidates):** + - **Append-only audit log** — `{seq, payload, retrieve-by-id}`; both have it (~40 + lines). Lift to e.g. `lib/guest/audit-log.sx` parameterized by the entry payload. + - **Federation trust/outbox** — advisory-unless-`(trust peer :scope)` + a send + seam; both have it. Lift the trust registry + outbox; keep `:scope` a parameter + (`:mod` vs `:acl`). +- **Trivia not worth a module:** `join-with`, `any?`, `str-contains?`, `distinct`. +- **Why not now:** the branches merge independently; lifting from one leaves the + other's copy un-refactored → duplication, not sharing. Real extraction must touch + both consumers atomically, which only the post-merge integrator can do. Designing + the abstraction also needs both payload shapes in view (only mod's is visible here). + ## Progress log - **Ext 19 — end-to-end triage pipeline, 390/390** (+15). Capstone: one