mod: Ext 8 — quorum over distinct reporters (anti-brigade), 232/232
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 55s

(: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) <noreply@anthropic.com>
This commit is contained in:
2026-06-06 18:45:28 +00:00
parent 50eb7079e5
commit 062a76e64f
7 changed files with 200 additions and 12 deletions

View File

@@ -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!)"
)

View File

@@ -40,12 +40,15 @@
;; ── condition → Prolog goal ──
;;
;; (:classification "spam") → classification(Id, spam)
;; (:evidence "kind") → evidence(Id, 'kind', _)
;; (:attr "verified") → attr(Id, verified)
;; (:not <cond>) → not(<cond>) (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 <cond>) → not(<cond>) (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

40
lib/mod/quorum.sx Normal file
View File

@@ -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"}))))))))

View File

@@ -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"
}

View File

@@ -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 |

119
lib/mod/tests/quorum.sx Normal file
View File

@@ -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}))

View File

@@ -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