From 062a76e64f3a42553ae7f9f665089a988b2e7ef6 Mon Sep 17 00:00:00 2001 From: giles Date: Sat, 6 Jun 2026 18:45:28 +0000 Subject: [PATCH] =?UTF-8?q?mod:=20Ext=208=20=E2=80=94=20quorum=20over=20di?= =?UTF-8?q?stinct=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