From c19f658cf26b955c6e7c535875973ff144a7ccf3 Mon Sep 17 00:00:00 2001 From: giles Date: Sat, 6 Jun 2026 19:37:12 +0000 Subject: [PATCH] =?UTF-8?q?mod:=20Ext=2018=20=E2=80=94=20ergonomic=20defru?= =?UTF-8?q?le=20/=20ruleset=20surface,=20375/375?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mod/defrule collects trailing conditions via &rest; mod/ruleset assembles rules. No macro needed — conditions are plain data, fn supports &rest here. Produces structurally identical rules to mk-rule (asserted) and works in the engine unchanged. Closes the roadmap's original defrule surface. +11 tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/mod/conformance.conf | 2 + lib/mod/defrule.sx | 16 +++++++ lib/mod/scoreboard.json | 9 ++-- lib/mod/scoreboard.md | 3 +- lib/mod/tests/defrule.sx | 95 ++++++++++++++++++++++++++++++++++++++++ plans/mod-on-sx.md | 12 ++++- 6 files changed, 131 insertions(+), 6 deletions(-) create mode 100644 lib/mod/defrule.sx create mode 100644 lib/mod/tests/defrule.sx diff --git a/lib/mod/conformance.conf b/lib/mod/conformance.conf index 4410b97f..fb122cb3 100644 --- a/lib/mod/conformance.conf +++ b/lib/mod/conformance.conf @@ -12,6 +12,7 @@ PRELOADS=( lib/prolog/compiler.sx lib/mod/schema.sx lib/mod/policy.sx + lib/mod/defrule.sx lib/mod/engine.sx lib/mod/explain.sx lib/mod/severity.sx @@ -53,4 +54,5 @@ SUITES=( "disjunction:lib/mod/tests/disjunction.sx:(mod-disjunction-tests-run!)" "activity:lib/mod/tests/activity.sx:(mod-activity-tests-run!)" "policies:lib/mod/tests/policies.sx:(mod-policies-tests-run!)" + "defrule:lib/mod/tests/defrule.sx:(mod-defrule-tests-run!)" ) diff --git a/lib/mod/defrule.sx b/lib/mod/defrule.sx new file mode 100644 index 00000000..922cbb7d --- /dev/null +++ b/lib/mod/defrule.sx @@ -0,0 +1,16 @@ +;; lib/mod/defrule.sx — ergonomic rule / ruleset construction. +;; +;; The roadmap sketched a (defrule action :when conditions) surface. Conditions +;; already evaluate to plain data, so this needs no macro — variadic functions +;; suffice: mod/defrule collects its trailing condition forms via &rest (dropping +;; the explicit outer (list ...)), and mod/ruleset assembles rules the same way. +;; +;; (mod/ruleset +;; (mod/defrule "spam-hide" :hide (list :classification "spam")) +;; (mod/defrule "default-keep" :keep)) + +(define + mod/defrule + (fn (name action &rest conds) (mod/mk-rule name action conds))) + +(define mod/ruleset (fn (&rest rules) rules)) diff --git a/lib/mod/scoreboard.json b/lib/mod/scoreboard.json index 3fff5615..f5de3c9c 100644 --- a/lib/mod/scoreboard.json +++ b/lib/mod/scoreboard.json @@ -1,8 +1,8 @@ { "lang": "mod", - "total_passed": 364, + "total_passed": 375, "total_failed": 0, - "total": 364, + "total": 375, "suites": [ {"name":"decide","passed":31,"failed":0,"total":31}, {"name":"audit","passed":29,"failed":0,"total":29}, @@ -22,7 +22,8 @@ {"name":"wire","passed":16,"failed":0,"total":16}, {"name":"disjunction","passed":10,"failed":0,"total":10}, {"name":"activity","passed":17,"failed":0,"total":17}, - {"name":"policies","passed":14,"failed":0,"total":14} + {"name":"policies","passed":14,"failed":0,"total":14}, + {"name":"defrule","passed":11,"failed":0,"total":11} ], - "generated": "2026-06-06T19:32:52+00:00" + "generated": "2026-06-06T19:36:45+00:00" } diff --git a/lib/mod/scoreboard.md b/lib/mod/scoreboard.md index 153e6112..08620704 100644 --- a/lib/mod/scoreboard.md +++ b/lib/mod/scoreboard.md @@ -1,6 +1,6 @@ # mod scoreboard -**364 / 364 passing** (0 failure(s)). +**375 / 375 passing** (0 failure(s)). | Suite | Passed | Total | Status | |-------|--------|-------|--------| @@ -23,3 +23,4 @@ | disjunction | 10 | 10 | ok | | activity | 17 | 17 | ok | | policies | 14 | 14 | ok | +| defrule | 11 | 11 | ok | diff --git a/lib/mod/tests/defrule.sx b/lib/mod/tests/defrule.sx new file mode 100644 index 00000000..894aee85 --- /dev/null +++ b/lib/mod/tests/defrule.sx @@ -0,0 +1,95 @@ +;; lib/mod/tests/defrule.sx — Ext 18: ergonomic defrule / ruleset. + +(define mod-dr-count 0) +(define mod-dr-pass 0) +(define mod-dr-fail 0) +(define mod-dr-failures (list)) + +(define + mod-dr-test! + (fn + (name got expected) + (begin + (set! mod-dr-count (+ mod-dr-count 1)) + (if + (= got expected) + (set! mod-dr-pass (+ mod-dr-pass 1)) + (begin + (set! mod-dr-fail (+ mod-dr-fail 1)) + (append! + mod-dr-failures + (str name "\n expected: " expected "\n got: " got))))))) + +;; ── defrule produces the same structure as mk-rule ── + +(define + mod-dr-r + (mod/defrule "spam-hide" :hide (list :classification "spam"))) +(mod-dr-test! "defrule name" (mod/rule-name mod-dr-r) "spam-hide") +(mod-dr-test! "defrule action" (mod/rule-action mod-dr-r) "hide") +(mod-dr-test! + "defrule when wraps the conditions" + (mod/rule-when mod-dr-r) + (list (list :classification "spam"))) +(mod-dr-test! + "defrule equals mk-rule equivalent" + (mod/rule-when mod-dr-r) + (mod/rule-when + (mod/mk-rule "spam-hide" :hide (list (list :classification "spam"))))) + +;; ── multi-condition + no-condition ── + +(define + mod-dr-multi + (mod/defrule + "strict" + :hide (list :classification "spam") + (list :not (list :attr "verified")))) +(mod-dr-test! + "defrule collects multiple conditions" + (len (mod/rule-when mod-dr-multi)) + 2) + +(define mod-dr-catch (mod/defrule "default-keep" :keep)) +(mod-dr-test! + "defrule with no conditions is unconditional" + (mod/rule-when mod-dr-catch) + (list)) + +;; ── ruleset assembles a list ── + +(define + mod-dr-rules + (mod/ruleset + (mod/defrule "spam-hide" :hide (list :classification "spam")) + (mod/defrule "default-keep" :keep))) + +(mod-dr-test! "ruleset length" (len mod-dr-rules) 2) +(mod-dr-test! + "ruleset first rule name" + (mod/rule-name (first mod-dr-rules)) + "spam-hide") + +;; ── engine works with defrule/ruleset-built policy ── + +(define mod-dr-spam (mod/mk-report "r1" "a" "b" "this is spam")) +(define mod-dr-clean (mod/mk-report "r2" "a" "b" "a fine post")) + +(mod-dr-test! + "defrule policy: spam → hide" + (get + (mod/decide-report mod-dr-spam (list mod-dr-spam) mod-dr-rules) + :action) + "hide") +(mod-dr-test! + "defrule policy: clean → keep" + (get + (mod/decide-report mod-dr-clean (list mod-dr-clean) mod-dr-rules) + :action) + "keep") +(mod-dr-test! + "defrule policy: spam names the rule" + (get (mod/decide-report mod-dr-spam (list mod-dr-spam) mod-dr-rules) :rule) + "spam-hide") + +(define mod-defrule-tests-run! (fn () {:failures mod-dr-failures :total mod-dr-count :passed mod-dr-pass :failed mod-dr-fail})) diff --git a/plans/mod-on-sx.md b/plans/mod-on-sx.md index c89e47ec..af551e59 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` → **364/364** (roadmap + 17 extensions complete) +`bash lib/mod/conformance.sh` → **375/375** (roadmap + 18 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 18 — ergonomic defrule / ruleset** (`lib/mod/defrule.sx`, +11). The + roadmap's `(defrule …)` surface, done with `&rest` variadics (no macro needed — + conditions are already plain data): `mod/defrule` collects trailing conditions, + `mod/ruleset` assembles rules. Produces structurally identical rules to `mk-rule` + and works in the engine unchanged. - [x] **Ext 17 — per-domain policy registry** (`lib/mod/policies.sx`, +14). `mod/register-policy! domain rules` + `mod/decide-in domain r reports` give each rose-ash domain (blog/market/events/…) its own rule set; unregistered domains @@ -229,6 +234,11 @@ lib/mod/fed.sx ## Progress log +- **Ext 18 — ergonomic defrule / ruleset, 375/375** (+11). Closes the roadmap's + original `defrule` surface. `fn` supports `&rest` here, and conditions evaluate + to plain data, so no macro is needed — variadic functions give the ergonomics + safely. Equivalence to `mk-rule` is asserted, so it's pure sugar with no new + semantics. - **Ext 17 — per-domain policy registry, 364/364** (+14). Multi-tenant policy: the engine already took `rules` as a parameter, so domain-scoping is just a registry + a default fallback — no engine change. Makes the whole policy