mod: Ext 1 — negation-as-failure conditions (:not / :attr), 146/146
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 44s

Report attributes (:attrs) project to attr(Id, name) facts; policy gains (:attr x)
and (:not <cond>) 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) <noreply@anthropic.com>
This commit is contained in:
2026-06-06 17:59:01 +00:00
parent ee9851c063
commit 2ea87796a1
7 changed files with 218 additions and 15 deletions

View File

@@ -24,4 +24,5 @@ SUITES=(
"audit:lib/mod/tests/audit.sx:(mod-audit-tests-run!)" "audit:lib/mod/tests/audit.sx:(mod-audit-tests-run!)"
"escalation:lib/mod/tests/escalation.sx:(mod-escalation-tests-run!)" "escalation:lib/mod/tests/escalation.sx:(mod-escalation-tests-run!)"
"fed:lib/mod/tests/fed.sx:(mod-fed-tests-run!)" "fed:lib/mod/tests/fed.sx:(mod-fed-tests-run!)"
"extensions:lib/mod/tests/extensions.sx:(mod-extensions-tests-run!)"
) )

View File

@@ -42,6 +42,8 @@
;; ;;
;; (:classification "spam") → classification(Id, spam) ;; (:classification "spam") → classification(Id, spam)
;; (:evidence "kind") → evidence(Id, 'kind', _) ;; (: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 ;; (:count-at-least 3) → report(Id, B, S), report_count(S, N), N >= 3
(define (define
@@ -60,6 +62,9 @@
", " ", "
(mod/pl-quote (nth c 1)) (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) ((= tag :count-at-least)
(str (str
"report(" "report("

View File

@@ -1,12 +1,13 @@
;; lib/mod/schema.sx — report representation + Prolog fact generation. ;; lib/mod/schema.sx — report representation + Prolog fact generation.
;; ;;
;; A report is a dict {:id :by :about :reason :evidence}. :evidence is a list of ;; A report is a dict {:id :by :about :reason :evidence :attrs}. :evidence is a
;; accumulated evidence entries {:kind :val} (human review, automated scanners, ;; list of accumulated evidence entries {:kind :val} (human review, automated
;; etc.). The engine derives keyword classifications from the reason text and ;; scanners). :attrs is a list of attribute names (e.g. "verified") used by
;; projects the report, its classifications, and its accumulated evidence into ;; negation-as-failure conditions. The engine derives keyword classifications
;; Prolog facts that policy clauses match against. ;; 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-id (fn (r) (get r :id)))
(define mod/report-by (fn (r) (get r :by))) (define mod/report-by (fn (r) (get r :by)))
@@ -17,11 +18,23 @@
mod/report-evidence mod/report-evidence
(fn (r) (let ((e (get r :evidence))) (if (nil? e) (list) e)))) (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/mk-evidence (fn (kind val) {:val val :kind kind}))
(define mod/evidence-kind (fn (e) (get e :kind))) (define mod/evidence-kind (fn (e) (get e :kind)))
(define mod/evidence-val (fn (e) (get e :val))) (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 (define
mod/attach-evidence mod/attach-evidence
@@ -29,6 +42,10 @@
(r e) (r e)
(mod/with-evidence r (append (mod/report-evidence r) (list 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) ── ;; ── substring search (the prolog-loaded env lacks includes?; slice/len do work) ──
(define (define
@@ -139,6 +156,12 @@
").")) ")."))
evs)))) evs))))
(define
mod/attr-facts
(fn
(id attrs)
(mod/join-with "\n" (map (fn (a) (str "attr(" id ", " a ").")) attrs))))
(define (define
mod/report-facts mod/report-facts
(fn (fn
@@ -149,11 +172,13 @@
(about (mod/pl-quote (mod/report-about r)))) (about (mod/pl-quote (mod/report-about r))))
(let (let
((cls (mod/classification-facts id (mod/classify-keywords r))) ((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 (mod/join-with
"\n" "\n"
(list (list
(str "report(" id ", " by ", " about ").") (str "report(" id ", " by ", " about ").")
(str "report_count(" about ", " count ").") (str "report_count(" about ", " count ").")
cls cls
evs)))))) evs
ats))))))

View File

@@ -1,13 +1,14 @@
{ {
"lang": "mod", "lang": "mod",
"total_passed": 132, "total_passed": 146,
"total_failed": 0, "total_failed": 0,
"total": 132, "total": 146,
"suites": [ "suites": [
{"name":"decide","passed":31,"failed":0,"total":31}, {"name":"decide","passed":31,"failed":0,"total":31},
{"name":"audit","passed":29,"failed":0,"total":29}, {"name":"audit","passed":29,"failed":0,"total":29},
{"name":"escalation","passed":46,"failed":0,"total":46}, {"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"
} }

View File

@@ -1,6 +1,6 @@
# mod scoreboard # mod scoreboard
**132 / 132 passing** (0 failure(s)). **146 / 146 passing** (0 failure(s)).
| Suite | Passed | Total | Status | | Suite | Passed | Total | Status |
|-------|--------|-------|--------| |-------|--------|-------|--------|
@@ -8,3 +8,4 @@
| audit | 29 | 29 | ok | | audit | 29 | 29 | ok |
| escalation | 46 | 46 | ok | | escalation | 46 | 46 | ok |
| fed | 26 | 26 | ok | | fed | 26 | 26 | ok |
| extensions | 14 | 14 | ok |

153
lib/mod/tests/extensions.sx Normal file
View File

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

View File

@@ -16,7 +16,7 @@ federation extension.
## Status (rolling) ## Status (rolling)
`bash lib/mod/conformance.sh`**132/132** (Phases 14 complete — roadmap done) `bash lib/mod/conformance.sh`**146/146** (roadmap done + extensions in progress)
## Ground rules ## Ground rules
@@ -129,8 +129,25 @@ lib/mod/fed.sx
ingest + origin, outbox sharing, advisory-vs-trusted apply, revocation + ingest + origin, outbox sharing, advisory-vs-trusted apply, revocation +
invalidation (exoneration flips hide→keep → revoked) 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 <cond>)` →
`not(<cond>)` (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 ## 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: - **Phase 4 complete — 132/132** (+26 fed). **Full roadmap done.** Federation:
cross-instance reports, decision sharing, advisory-by-default trust, revocation. 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 fed-sx is mocked behind `mod/fed-send!` (in-memory outbox) — the only seam a real