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