From 2ea87796a1c5f06d94d38288e2ec2b8caf48d454 Mon Sep 17 00:00:00 2001 From: giles Date: Sat, 6 Jun 2026 17:59:01 +0000 Subject: [PATCH] =?UTF-8?q?mod:=20Ext=201=20=E2=80=94=20negation-as-failur?= =?UTF-8?q?e=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