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