Compare commits
66 Commits
loops/host
...
loops/even
| Author | SHA1 | Date | |
|---|---|---|---|
| 7153e742c8 | |||
| 24d4db3f0d | |||
| 9adeff1431 | |||
| 80a2dee22f | |||
| 15e9503b05 | |||
| 4674b797cb | |||
| 540933bfca | |||
| 70aea21601 | |||
| 797c5f9147 | |||
| d446562ed1 | |||
| 9f8e4d995d | |||
| 4c8e732803 | |||
| 9437f99e28 | |||
| 98f5e1bf14 | |||
| 538b8a53e0 | |||
| 7e732b1933 | |||
| 739e743918 | |||
| c19f658cf2 | |||
| 2f75ab11fc | |||
| 9cfca1d008 | |||
| 82fbf01bb3 | |||
| 3cbf33d2d2 | |||
| 329b3c4903 | |||
| 919bd961d1 | |||
| b43901d297 | |||
| 68c8e39508 | |||
| 92addf5146 | |||
| 8292607e38 | |||
| bf65de7b24 | |||
| 3764b62206 | |||
| 062a76e64f | |||
| c2d628e9c3 | |||
| aabb950256 | |||
| 50eb7079e5 | |||
| c3668e4461 | |||
| 01be84b5d8 | |||
| 2b47b2925c | |||
| e53a292f1a | |||
| 3d2c1d94f2 | |||
| d9b9da3843 | |||
| 102c806451 | |||
| 0a1b89c975 | |||
| 779a592614 | |||
| 2ea87796a1 | |||
| 0e6ba55647 | |||
| ee9851c063 | |||
| c1d24eb9b3 | |||
| f4f34c1d33 | |||
| 16cb727406 | |||
| f8722b3b08 | |||
| e1f802cfff | |||
| 6e825e1283 | |||
| 8dfc987095 | |||
| 97c7623743 | |||
| e896deffc8 | |||
| 72174941aa | |||
| 40be9cd074 | |||
| 15c97119e4 | |||
| e762cc2e32 | |||
| 4674620d7e | |||
| f3da3b975a | |||
| 9261d69cc5 | |||
| 1731476dc6 | |||
| 65cbdb8387 | |||
| fe47334e52 | |||
| 91ffba9975 |
@@ -1 +1 @@
|
||||
{"sessionId":"bf20a443-9df8-4cb9-932e-8c6f4c4625c2","pid":1303602,"procStart":"253831081","acquiredAt":1779865895644}
|
||||
{"sessionId":"c4d97db1-361c-4a04-a99b-c838f9385469","pid":2426590,"procStart":"349789073","acquiredAt":1780789990975}
|
||||
45
lib/acl/api.sx
Normal file
45
lib/acl/api.sx
Normal file
@@ -0,0 +1,45 @@
|
||||
;; lib/acl/api.sx — public ACL surface over an implicit current db.
|
||||
;;
|
||||
;; Callers load a fact set once, then issue decisions without threading the db
|
||||
;; through every call. The current db is module state; (acl/load! facts) rebuilds
|
||||
;; it. This is the boundary the rest of rose-ash imports.
|
||||
|
||||
(define acl-current-db nil)
|
||||
|
||||
;; Replace the current fact base. Rebuilds the Datalog db under the active
|
||||
;; ruleset (see lib/acl/engine.sx).
|
||||
(define
|
||||
acl/load!
|
||||
(fn
|
||||
(facts)
|
||||
(do (set! acl-current-db (acl-build-db facts)) acl-current-db)))
|
||||
|
||||
;; Ensure a db exists, building an empty one on first use.
|
||||
(define
|
||||
acl-ensure-db!
|
||||
(fn
|
||||
()
|
||||
(do
|
||||
(when
|
||||
(= acl-current-db nil)
|
||||
(set! acl-current-db (acl-build-db (list))))
|
||||
acl-current-db)))
|
||||
|
||||
;; Public decision against the current db (pure, no logging).
|
||||
(define
|
||||
acl/permit?
|
||||
(fn (subj act res) (acl-permit? (acl-ensure-db!) subj act res)))
|
||||
|
||||
;; Decision-with-proof against the current db. See lib/acl/explain.sx.
|
||||
(define
|
||||
acl/explain
|
||||
(fn (subj act res) (acl-explain (acl-ensure-db!) subj act res)))
|
||||
|
||||
;; Audited decision: logs the outcome to the append-only audit log and returns
|
||||
;; the boolean. See lib/acl/audit.sx.
|
||||
(define
|
||||
acl/audit
|
||||
(fn (subj act res) (acl-audit-decide! (acl-ensure-db!) subj act res)))
|
||||
|
||||
;; Recent audited decisions (chronological).
|
||||
(define acl/audit-tail (fn (n) (acl-audit-tail n)))
|
||||
110
lib/acl/audit.sx
Normal file
110
lib/acl/audit.sx
Normal file
@@ -0,0 +1,110 @@
|
||||
;; lib/acl/audit.sx — append-only decision log.
|
||||
;;
|
||||
;; Every decision routed through acl-audit-decide! is appended to an in-memory
|
||||
;; log with a monotonic sequence number (no wall-clock — deterministic and
|
||||
;; testable; a host can stamp time at the serializer boundary). The log is
|
||||
;; append-only: there is no mutate or delete, only append, tail, clear,
|
||||
;; snapshot/restore, and serialize-for-disk.
|
||||
|
||||
(define acl-audit-log (list))
|
||||
(define acl-audit-seq 0)
|
||||
|
||||
;; Copy a list into a fresh, append!-able list. `map`/`rest`-derived lists are
|
||||
;; NOT extensible by append! in this runtime (it silently no-ops), so the live
|
||||
;; log must always be a list built with `list` + `append!`.
|
||||
(define
|
||||
acl-audit-copy
|
||||
(fn
|
||||
(xs)
|
||||
(let
|
||||
((fresh (list)))
|
||||
(do (for-each (fn (e) (append! fresh e)) xs) fresh))))
|
||||
|
||||
(define
|
||||
acl-audit-clear!
|
||||
(fn
|
||||
()
|
||||
(do (set! acl-audit-log (list)) (set! acl-audit-seq 0) nil)))
|
||||
|
||||
;; Append a decision record. Returns the record.
|
||||
(define
|
||||
acl-audit-record!
|
||||
(fn
|
||||
(subj act res allowed?)
|
||||
(let
|
||||
((entry {:allowed? allowed? :act act :subj subj :res res :seq acl-audit-seq}))
|
||||
(do
|
||||
(set! acl-audit-seq (+ acl-audit-seq 1))
|
||||
(append! acl-audit-log entry)
|
||||
entry))))
|
||||
|
||||
;; Decide against db, log the outcome, and return the boolean. This is the
|
||||
;; audited path; acl-permit? remains the pure, side-effect-free decision.
|
||||
(define
|
||||
acl-audit-decide!
|
||||
(fn
|
||||
(db subj act res)
|
||||
(let
|
||||
((allowed? (acl-permit? db subj act res)))
|
||||
(do (acl-audit-record! subj act res allowed?) allowed?))))
|
||||
|
||||
(define acl-audit-count (fn () (len acl-audit-log)))
|
||||
|
||||
;; Most recent n entries (in chronological order). n >= log size returns all.
|
||||
(define
|
||||
acl-audit-tail
|
||||
(fn
|
||||
(n)
|
||||
(let
|
||||
((total (len acl-audit-log)))
|
||||
(if
|
||||
(<= total n)
|
||||
acl-audit-log
|
||||
(acl-audit-drop acl-audit-log (- total n))))))
|
||||
|
||||
(define
|
||||
acl-audit-drop
|
||||
(fn
|
||||
(xs k)
|
||||
(if (<= k 0) xs (acl-audit-drop (rest xs) (- k 1)))))
|
||||
|
||||
;; Structured snapshot for save/restore — a {:seq :entries} value carrying a
|
||||
;; copy of the log (so later appends don't mutate a held snapshot).
|
||||
(define acl-audit-snapshot (fn () {:seq acl-audit-seq :entries (acl-audit-copy acl-audit-log)}))
|
||||
|
||||
;; Replace the live log from a snapshot. Restores both entries and the seq
|
||||
;; counter so subsequent records continue numbering correctly. The log is
|
||||
;; rebuilt as a fresh append!-able list (see acl-audit-copy).
|
||||
(define
|
||||
acl-audit-restore!
|
||||
(fn
|
||||
(snap)
|
||||
(do
|
||||
(set! acl-audit-log (acl-audit-copy (get snap :entries)))
|
||||
(set! acl-audit-seq (get snap :seq))
|
||||
nil)))
|
||||
|
||||
;; Serialize the whole log to a disk-ready string: one record per line,
|
||||
;; "seq\tsubj\tact\tres\tallowed?". A host writes this; structured reload is via
|
||||
;; snapshot/restore.
|
||||
(define
|
||||
acl-audit-serialize
|
||||
(fn
|
||||
()
|
||||
(reduce
|
||||
(fn
|
||||
(acc e)
|
||||
(str
|
||||
acc
|
||||
(get e :seq)
|
||||
"\t"
|
||||
(get e :subj)
|
||||
"\t"
|
||||
(get e :act)
|
||||
"\t"
|
||||
(get e :res)
|
||||
"\t"
|
||||
(get e :allowed?)
|
||||
"\n"))
|
||||
""
|
||||
acl-audit-log)))
|
||||
32
lib/acl/conformance.conf
Normal file
32
lib/acl/conformance.conf
Normal file
@@ -0,0 +1,32 @@
|
||||
# ACL conformance config — sourced by lib/guest/conformance.sh.
|
||||
|
||||
LANG_NAME=acl
|
||||
MODE=dict
|
||||
|
||||
PRELOADS=(
|
||||
lib/datalog/tokenizer.sx
|
||||
lib/datalog/parser.sx
|
||||
lib/datalog/unify.sx
|
||||
lib/datalog/db.sx
|
||||
lib/datalog/builtins.sx
|
||||
lib/datalog/aggregates.sx
|
||||
lib/datalog/strata.sx
|
||||
lib/datalog/eval.sx
|
||||
lib/datalog/api.sx
|
||||
lib/datalog/magic.sx
|
||||
lib/acl/schema.sx
|
||||
lib/acl/facts.sx
|
||||
lib/acl/engine.sx
|
||||
lib/acl/explain.sx
|
||||
lib/acl/audit.sx
|
||||
lib/acl/federation.sx
|
||||
lib/acl/api.sx
|
||||
)
|
||||
|
||||
SUITES=(
|
||||
"direct:lib/acl/tests/direct.sx:(acl-direct-tests-run!)"
|
||||
"inherit:lib/acl/tests/inherit.sx:(acl-inherit-tests-run!)"
|
||||
"explain:lib/acl/tests/explain.sx:(acl-explain-tests-run!)"
|
||||
"fed:lib/acl/tests/fed.sx:(acl-fed-tests-run!)"
|
||||
"harden:lib/acl/tests/harden.sx:(acl-harden-tests-run!)"
|
||||
)
|
||||
3
lib/acl/conformance.sh
Executable file
3
lib/acl/conformance.sh
Executable file
@@ -0,0 +1,3 @@
|
||||
#!/usr/bin/env bash
|
||||
# Thin wrapper — see lib/guest/conformance.sh and lib/acl/conformance.conf.
|
||||
exec bash "$(dirname "$0")/../guest/conformance.sh" "$(dirname "$0")/conformance.conf" "$@"
|
||||
72
lib/acl/engine.sx
Normal file
72
lib/acl/engine.sx
Normal file
@@ -0,0 +1,72 @@
|
||||
;; lib/acl/engine.sx — ACL ruleset + decision reducer over lib/datalog/.
|
||||
;;
|
||||
;; The engine is a thin layer: it owns the permit ruleset (SX data rules) and
|
||||
;; reduces a (subject, action, resource) decision to a Datalog query against a
|
||||
;; db built from EDB facts. The rule engine itself is Datalog's.
|
||||
;;
|
||||
;; Policy — inheritance + federation with deny-overrides:
|
||||
;;
|
||||
;; eff_grant(S,A,R) :- grant(S,A,R). ; direct
|
||||
;; eff_grant(S,A,R) :- member_of(S,G), eff_grant(G,A,R). ; group/role chain
|
||||
;; eff_grant(S,A,R) :- child_of(R,P), eff_grant(S,A,P). ; resource tree
|
||||
;; eff_grant(S,A,R) :- member_of(S,Role), role_grant(Role,A,R). ; role expansion
|
||||
;; eff_grant(S,A,R) :- delegate(Peer,S,A,R), ; federated grant
|
||||
;; trust(Peer,L), level_covers(L,A).
|
||||
;;
|
||||
;; eff_deny(S,A,R) :- deny(S,A,R). ; direct
|
||||
;; eff_deny(S,A,R) :- member_of(S,G), eff_deny(G,A,R). ; group chain
|
||||
;; eff_deny(S,A,R) :- child_of(R,P), eff_deny(S,A,P). ; resource tree
|
||||
;;
|
||||
;; permit(S,A,R) :- eff_grant(S,A,R), not eff_deny(S,A,R).
|
||||
;;
|
||||
;; DENY-OVERRIDES: an effective deny anywhere in the inheritance closure of
|
||||
;; (S,A,R) defeats any effective grant — including federated grants. Deny
|
||||
;; inherits through the *same* group and resource chains as grant, so a
|
||||
;; group-level or ancestor-resource deny is authoritative for members/
|
||||
;; descendants. This is the principled, fail-safe reading of "deny wins".
|
||||
;;
|
||||
;; FEDERATION — non-transitive trust: a peer's `delegate` fact only grants if a
|
||||
;; *local* `trust(Peer, L)` exists AND that level `level_covers` the action.
|
||||
;; Trust is re-checked on every query (it is a body literal), never baked in at
|
||||
;; fact-ingestion time, so revoking trust or narrowing a level takes effect
|
||||
;; immediately on the next decision.
|
||||
;;
|
||||
;; Termination & stratification:
|
||||
;; - eff_grant/eff_deny recurse only over member_of and child_of, which are
|
||||
;; EDB relations with no function symbols, so the closure is finite (cyclic
|
||||
;; membership/containment just reaches a fixpoint, never loops). The
|
||||
;; federation rule is non-recursive.
|
||||
;; - permit negates eff_deny; neither eff_grant nor eff_deny depends on
|
||||
;; permit, so the program is stratifiable (permit sits in a higher stratum).
|
||||
|
||||
(define
|
||||
acl-rules
|
||||
(quote
|
||||
((eff_grant S A R <- (grant S A R))
|
||||
(eff_grant S A R <- (member_of S G) (eff_grant G A R))
|
||||
(eff_grant S A R <- (child_of R P) (eff_grant S A P))
|
||||
(eff_grant S A R <- (member_of S Role) (role_grant Role A R))
|
||||
(eff_grant
|
||||
S
|
||||
A
|
||||
R
|
||||
<-
|
||||
(delegate Peer S A R)
|
||||
(trust Peer L)
|
||||
(level_covers L A))
|
||||
(eff_deny S A R <- (deny S A R))
|
||||
(eff_deny S A R <- (member_of S G) (eff_deny G A R))
|
||||
(eff_deny S A R <- (child_of R P) (eff_deny S A P))
|
||||
(permit S A R <- (eff_grant S A R) {:neg (eff_deny S A R)}))))
|
||||
|
||||
;; Build a Datalog db from a list of EDB facts under the ACL ruleset.
|
||||
(define acl-build-db (fn (facts) (dl-program-data facts acl-rules)))
|
||||
|
||||
;; Core decision: does the db permit subject S to perform action A on
|
||||
;; resource R? Reduces to a ground Datalog query on the derived `permit`
|
||||
;; relation — non-empty result means permitted.
|
||||
(define
|
||||
acl-permit?
|
||||
(fn
|
||||
(db subj act res)
|
||||
(> (len (dl-query db (list (quote permit) subj act res))) 0)))
|
||||
125
lib/acl/explain.sx
Normal file
125
lib/acl/explain.sx
Normal file
@@ -0,0 +1,125 @@
|
||||
;; lib/acl/explain.sx — proof-tree reconstruction over the saturated db.
|
||||
;;
|
||||
;; lib/datalog/ records derived facts but not their provenance, so the proof is
|
||||
;; reconstructed here by goal-directed search over the *saturated* db: for a
|
||||
;; ground goal we find the first ACL rule (in rule order) whose body holds, take
|
||||
;; the first solution binding its remaining variables, and recurse on each body
|
||||
;; literal. Negated literals are recorded as verified `:neg-ok` leaves.
|
||||
;;
|
||||
;; CANONICAL DERIVATION: the Datalog derivation graph is a DAG (a fact may hold
|
||||
;; many ways). We pick ONE canonical proof — first matching rule, first solution
|
||||
;; — matching the rule order in lib/acl/engine.sx (direct/EDB rules first). A
|
||||
;; depth cap guards against pathological cyclic data producing unbounded search.
|
||||
;;
|
||||
;; A proof node is one of:
|
||||
;; {:fact <lit> :via "edb"} — base EDB fact
|
||||
;; {:fact <lit> :rule <head> :body (<node|negleaf> ...)} — derived
|
||||
;; {:neg-ok <lit>} — negation verified to fail
|
||||
;; {:fact <lit> :truncated true} — depth cap hit
|
||||
|
||||
(define acl-proof-max-depth 64)
|
||||
|
||||
;; Substitute a body literal, descending into {:neg ...} dicts (dl-apply-subst
|
||||
;; does not recurse into dicts, which would leak the neg's free vars).
|
||||
(define
|
||||
acl-subst-lit
|
||||
(fn
|
||||
(lit s)
|
||||
(if
|
||||
(and (dict? lit) (has-key? lit :neg))
|
||||
{:neg (dl-apply-subst (get lit :neg) s)}
|
||||
(dl-apply-subst lit s))))
|
||||
|
||||
(define
|
||||
acl-lit-edb?
|
||||
(fn
|
||||
(lit)
|
||||
(and
|
||||
(list? lit)
|
||||
(> (len lit) 0)
|
||||
(symbol? (first lit))
|
||||
(has-key? acl-edb-arity (symbol->string (first lit))))))
|
||||
|
||||
(define
|
||||
acl-subst-zip!
|
||||
(fn
|
||||
(d ks vs)
|
||||
(when
|
||||
(> (len ks) 0)
|
||||
(do
|
||||
(dict-set! d (symbol->string (first ks)) (first vs))
|
||||
(acl-subst-zip! d (rest ks) (rest vs))))))
|
||||
|
||||
;; Bind a rule head's variables to a ground goal's arguments (positional).
|
||||
(define
|
||||
acl-bind-head
|
||||
(fn
|
||||
(head goal)
|
||||
(let
|
||||
((d {}))
|
||||
(do (acl-subst-zip! d (rest head) (rest goal)) d))))
|
||||
|
||||
(define
|
||||
acl-subst-union
|
||||
(fn
|
||||
(a b)
|
||||
(let
|
||||
((d {}))
|
||||
(do
|
||||
(for-each (fn (k) (dict-set! d k (get a k))) (keys a))
|
||||
(for-each (fn (k) (dict-set! d k (get b k))) (keys b))
|
||||
d))))
|
||||
|
||||
(define acl-prove (fn (db goal) (acl-prove-d db goal 0)))
|
||||
|
||||
(define
|
||||
acl-prove-d
|
||||
(fn
|
||||
(db goal depth)
|
||||
(cond
|
||||
((> depth acl-proof-max-depth) {:truncated true :fact goal})
|
||||
((acl-lit-edb? goal)
|
||||
(if (> (len (dl-query db goal)) 0) {:via "edb" :fact goal} nil))
|
||||
(else (acl-prove-rules db goal acl-rules depth)))))
|
||||
|
||||
(define
|
||||
acl-prove-rules
|
||||
(fn
|
||||
(db goal rules depth)
|
||||
(if
|
||||
(= (len rules) 0)
|
||||
nil
|
||||
(let
|
||||
((p (dl-rule-from-list (first rules))))
|
||||
(if
|
||||
(= (first (get p :head)) (first goal))
|
||||
(let
|
||||
((hs (acl-bind-head (get p :head) goal)))
|
||||
(let
|
||||
((qbody (map (fn (l) (acl-subst-lit l hs)) (get p :body))))
|
||||
(let
|
||||
((sols (dl-query db qbody)))
|
||||
(if
|
||||
(> (len sols) 0)
|
||||
(acl-prove-build db goal p hs (first sols) depth)
|
||||
(acl-prove-rules db goal (rest rules) depth)))))
|
||||
(acl-prove-rules db goal (rest rules) depth))))))
|
||||
|
||||
(define
|
||||
acl-prove-build
|
||||
(fn
|
||||
(db goal p hs sol depth)
|
||||
(let ((full (acl-subst-union hs sol))) {:body (map (fn (l) (let ((g (acl-subst-lit l full))) (if (and (dict? g) (has-key? g :neg)) {:neg-ok (get g :neg)} (acl-prove-d db g (+ depth 1))))) (get p :body)) :rule (get p :head) :fact goal})))
|
||||
|
||||
;; Public decision-with-proof. Returns:
|
||||
;; {:allowed? <bool> :proof <node|nil> :reason <eff_deny proof|nil>}
|
||||
;; When permitted, :proof is the permit derivation. When denied, :proof is nil
|
||||
;; and :reason carries the blocking eff_deny proof if one exists (an explicit or
|
||||
;; inherited deny), else nil (simply no grant).
|
||||
(define
|
||||
acl-explain
|
||||
(fn
|
||||
(db subj act res)
|
||||
(let
|
||||
((proof (acl-prove db (list (quote permit) subj act res))))
|
||||
(if (= proof nil) {:allowed? false :proof nil :reason (acl-prove db (list (quote eff_deny) subj act res))} {:allowed? true :proof proof :reason nil}))))
|
||||
47
lib/acl/facts.sx
Normal file
47
lib/acl/facts.sx
Normal file
@@ -0,0 +1,47 @@
|
||||
;; lib/acl/facts.sx — EDB fact constructors.
|
||||
;;
|
||||
;; Each constructor returns a Datalog fact tuple (a list whose head is the
|
||||
;; predicate symbol). These are the only shapes lib/acl/engine.sx feeds to
|
||||
;; lib/datalog/.
|
||||
;; Phase 1: actor/resource/grant/deny.
|
||||
;; Phase 2: member_of (subject -> group/role), child_of (resource -> parent),
|
||||
;; role_grant (role -> action,resource capability).
|
||||
;; Phase 4: peer/trust/delegate/level_covers (federation).
|
||||
|
||||
(define acl-actor (fn (id kind) (list (quote actor) id kind)))
|
||||
|
||||
(define acl-resource-fact (fn (id kind) (list (quote resource) id kind)))
|
||||
|
||||
(define acl-grant (fn (subj act res) (list (quote grant) subj act res)))
|
||||
|
||||
(define acl-deny (fn (subj act res) (list (quote deny) subj act res)))
|
||||
|
||||
;; subject S is a member of group/role G (one hop; transitivity is derived).
|
||||
(define acl-member-of (fn (subj grp) (list (quote member_of) subj grp)))
|
||||
|
||||
;; resource R is a child of parent P (one hop; transitivity is derived).
|
||||
(define acl-child-of (fn (res parent) (list (quote child_of) res parent)))
|
||||
|
||||
;; role confers capability (act on res) to every member of the role.
|
||||
(define
|
||||
acl-role-grant
|
||||
(fn (role act res) (list (quote role_grant) role act res)))
|
||||
|
||||
;; --- federation ---
|
||||
|
||||
;; a known peer instance at addr, of some kind (e.g. peer).
|
||||
(define acl-peer (fn (addr kind) (list (quote peer) addr kind)))
|
||||
|
||||
;; local trust in a peer at a named level. Gates delegated grants at query time.
|
||||
(define acl-trust (fn (peer level) (list (quote trust) peer level)))
|
||||
|
||||
;; a peer asserts that subject S may A on R. Only takes effect if local trust in
|
||||
;; that peer covers action A (see level_covers).
|
||||
(define
|
||||
acl-delegate
|
||||
(fn (peer subj act res) (list (quote delegate) peer subj act res)))
|
||||
|
||||
;; local policy: trust `level` authorises delegated grants for action `act`.
|
||||
(define
|
||||
acl-level-covers
|
||||
(fn (level act) (list (quote level_covers) level act)))
|
||||
61
lib/acl/federation.sx
Normal file
61
lib/acl/federation.sx
Normal file
@@ -0,0 +1,61 @@
|
||||
;; lib/acl/federation.sx — cross-instance ACL facts + revocation.
|
||||
;;
|
||||
;; fed-sx replicates ACL facts between instances; this module models the local
|
||||
;; side. A peer's authority arrives as `delegate(Peer, S, A, R)` facts, which
|
||||
;; only take effect when a local `trust(Peer, L)` and `level_covers(L, A)`
|
||||
;; authorise them (enforced by the engine rule, re-checked every query). The
|
||||
;; actual network transport is fed-sx's job and is mocked in tests as a dict.
|
||||
;;
|
||||
;; Trust is NOT transitive: trusting peer α does not extend to peers α trusts.
|
||||
;; Only delegate facts that α itself asserts, and that local trust covers, flow.
|
||||
|
||||
;; Mock fed-sx pull: `transport` is a dict mapping a peer address (its string
|
||||
;; name) to the list of delegate facts that peer asserts. Returns the facts for
|
||||
;; `addr`, or an empty list if the peer is unknown / unreachable.
|
||||
(define
|
||||
acl-fed-fetch
|
||||
(fn
|
||||
(transport addr)
|
||||
(let
|
||||
((k (if (symbol? addr) (symbol->string addr) addr)))
|
||||
(if (has-key? transport k) (get transport k) (list)))))
|
||||
|
||||
;; Gather delegate facts from every peer in `addrs` via the transport.
|
||||
(define
|
||||
acl-fed-collect
|
||||
(fn
|
||||
(transport addrs)
|
||||
(let
|
||||
((acc (list)))
|
||||
(do
|
||||
(for-each
|
||||
(fn
|
||||
(addr)
|
||||
(for-each
|
||||
(fn (f) (append! acc f))
|
||||
(acl-fed-fetch transport addr)))
|
||||
addrs)
|
||||
acc))))
|
||||
|
||||
;; Build a db from local facts plus delegate facts pulled from `peers`. Local
|
||||
;; facts must include the `trust`/`level_covers` policy; replicated delegate
|
||||
;; facts are gated against it by the engine rule at query time.
|
||||
(define
|
||||
acl-fed-build-db
|
||||
(fn
|
||||
(local-facts transport peers)
|
||||
(let
|
||||
((all (list)))
|
||||
(do
|
||||
(for-each (fn (f) (append! all f)) local-facts)
|
||||
(for-each
|
||||
(fn (f) (append! all f))
|
||||
(acl-fed-collect transport peers))
|
||||
(acl-build-db all)))))
|
||||
|
||||
;; Propagated revocation: retract a replicated fact (e.g. a peer's delegate, or
|
||||
;; local trust) from a live db. The next decision re-saturates and reflects it.
|
||||
(define acl-revoke! (fn (db fact) (do (dl-retract! db fact) db)))
|
||||
|
||||
;; Propagated assertion: ingest a newly replicated fact into a live db.
|
||||
(define acl-fed-assert! (fn (db fact) (do (dl-assert! db fact) db)))
|
||||
71
lib/acl/schema.sx
Normal file
71
lib/acl/schema.sx
Normal file
@@ -0,0 +1,71 @@
|
||||
;; lib/acl/schema.sx — ACL sorts and EDB predicate vocabulary.
|
||||
;;
|
||||
;; Datalog is untyped; this module is the schema-as-data layer. It declares
|
||||
;; the subject/resource/action sorts and the arity of every EDB predicate the
|
||||
;; ACL engine recognises, plus light validators. Facts that pass these checks
|
||||
;; are well-formed inputs to lib/acl/engine.sx.
|
||||
|
||||
(define acl-subject-kinds (quote (user group role service)))
|
||||
(define acl-resource-kinds (quote (page post thread peer)))
|
||||
|
||||
;; Actions are open-ended (a grant may name any action symbol), but these are
|
||||
;; the platform's well-known verbs.
|
||||
(define acl-actions (quote (read edit comment moderate federate)))
|
||||
|
||||
;; EDB predicate name -> arity.
|
||||
;; Phase 1: actor/resource/grant/deny.
|
||||
;; Phase 2: member_of (subject->group/role), child_of (resource->parent),
|
||||
;; role_grant (role->action,resource).
|
||||
;; Phase 4: peer (addr->kind), trust (peer->level),
|
||||
;; delegate (peer->subj,action,resource), level_covers (level->action).
|
||||
(define acl-edb-arity {:role_grant 3 :child_of 2 :trust 2 :peer 2 :actor 2 :level_covers 2 :delegate 4 :member_of 2 :deny 3 :grant 3 :resource 2})
|
||||
|
||||
(define
|
||||
acl-member?
|
||||
(fn
|
||||
(x xs)
|
||||
(cond
|
||||
((= (len xs) 0) false)
|
||||
((= (first xs) x) true)
|
||||
(else (acl-member? x (rest xs))))))
|
||||
|
||||
(define acl-subject-kind? (fn (k) (acl-member? k acl-subject-kinds)))
|
||||
|
||||
(define acl-resource-kind? (fn (k) (acl-member? k acl-resource-kinds)))
|
||||
|
||||
(define acl-known-action? (fn (a) (acl-member? a acl-actions)))
|
||||
|
||||
;; A fact is a list whose head is a predicate symbol. Valid when the predicate
|
||||
;; is known and the argument count matches the declared arity.
|
||||
(define
|
||||
acl-fact-valid?
|
||||
(fn
|
||||
(f)
|
||||
(and
|
||||
(list? f)
|
||||
(> (len f) 0)
|
||||
(symbol? (first f))
|
||||
(let
|
||||
((pred (symbol->string (first f))))
|
||||
(and
|
||||
(has-key? acl-edb-arity pred)
|
||||
(= (- (len f) 1) (get acl-edb-arity pred)))))))
|
||||
|
||||
;; Return the sublist of facts that fail acl-fact-valid?. Empty list means the
|
||||
;; whole set is well-formed. acl-build-db stays lenient (Datalog accepts any
|
||||
;; tuple, and custom action symbols are allowed); callers opt in to checking.
|
||||
(define
|
||||
acl-validate-facts
|
||||
(fn
|
||||
(facts)
|
||||
(let
|
||||
((bad (list)))
|
||||
(do
|
||||
(for-each
|
||||
(fn (f) (when (not (acl-fact-valid? f)) (append! bad f)))
|
||||
facts)
|
||||
bad))))
|
||||
|
||||
(define
|
||||
acl-facts-valid?
|
||||
(fn (facts) (= (len (acl-validate-facts facts)) 0)))
|
||||
14
lib/acl/scoreboard.json
Normal file
14
lib/acl/scoreboard.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"lang": "acl",
|
||||
"total_passed": 145,
|
||||
"total_failed": 0,
|
||||
"total": 145,
|
||||
"suites": [
|
||||
{"name":"direct","passed":24,"failed":0,"total":24},
|
||||
{"name":"inherit","passed":30,"failed":0,"total":30},
|
||||
{"name":"explain","passed":35,"failed":0,"total":35},
|
||||
{"name":"fed","passed":31,"failed":0,"total":31},
|
||||
{"name":"harden","passed":25,"failed":0,"total":25}
|
||||
],
|
||||
"generated": "2026-06-06T22:43:27+00:00"
|
||||
}
|
||||
11
lib/acl/scoreboard.md
Normal file
11
lib/acl/scoreboard.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# acl scoreboard
|
||||
|
||||
**145 / 145 passing** (0 failure(s)).
|
||||
|
||||
| Suite | Passed | Total | Status |
|
||||
|-------|--------|-------|--------|
|
||||
| direct | 24 | 24 | ok |
|
||||
| inherit | 30 | 30 | ok |
|
||||
| explain | 35 | 35 | ok |
|
||||
| fed | 31 | 31 | ok |
|
||||
| harden | 25 | 25 | ok |
|
||||
170
lib/acl/tests/direct.sx
Normal file
170
lib/acl/tests/direct.sx
Normal file
@@ -0,0 +1,170 @@
|
||||
;; lib/acl/tests/direct.sx — Phase 1: direct grants + deny-overrides.
|
||||
|
||||
(define acl-dt-pass 0)
|
||||
(define acl-dt-fail 0)
|
||||
(define acl-dt-failures (list))
|
||||
|
||||
(define
|
||||
acl-dt-check!
|
||||
(fn
|
||||
(name got expected)
|
||||
(if
|
||||
(= got expected)
|
||||
(set! acl-dt-pass (+ acl-dt-pass 1))
|
||||
(do
|
||||
(set! acl-dt-fail (+ acl-dt-fail 1))
|
||||
(append!
|
||||
acl-dt-failures
|
||||
(str name "\n expected: " expected "\n got: " got))))))
|
||||
|
||||
;; A small fixture used by most cases: alice can read page1, is denied edit on
|
||||
;; page1, and a service may federate peer1.
|
||||
(define
|
||||
acl-dt-fixture
|
||||
(fn
|
||||
()
|
||||
(acl-build-db
|
||||
(list
|
||||
(acl-actor (quote alice) (quote user))
|
||||
(acl-actor (quote svc1) (quote service))
|
||||
(acl-resource-fact (quote page1) (quote page))
|
||||
(acl-resource-fact (quote peer1) (quote peer))
|
||||
(acl-grant (quote alice) (quote read) (quote page1))
|
||||
(acl-grant (quote alice) (quote edit) (quote page1))
|
||||
(acl-deny (quote alice) (quote edit) (quote page1))
|
||||
(acl-grant (quote svc1) (quote federate) (quote peer1))))))
|
||||
|
||||
(define
|
||||
acl-dt-run-all!
|
||||
(fn
|
||||
()
|
||||
(let
|
||||
((db (acl-dt-fixture)))
|
||||
(do
|
||||
(acl-dt-check!
|
||||
"direct grant permits"
|
||||
(acl-permit? db (quote alice) (quote read) (quote page1))
|
||||
true)
|
||||
(acl-dt-check!
|
||||
"service grant permits federate"
|
||||
(acl-permit? db (quote svc1) (quote federate) (quote peer1))
|
||||
true)
|
||||
(acl-dt-check!
|
||||
"missing action denied"
|
||||
(acl-permit? db (quote alice) (quote comment) (quote page1))
|
||||
false)
|
||||
(acl-dt-check!
|
||||
"missing resource denied"
|
||||
(acl-permit? db (quote alice) (quote read) (quote page2))
|
||||
false)
|
||||
(acl-dt-check!
|
||||
"missing subject denied"
|
||||
(acl-permit? db (quote bob) (quote read) (quote page1))
|
||||
false)
|
||||
(acl-dt-check!
|
||||
"wrong subject for service grant denied"
|
||||
(acl-permit? db (quote alice) (quote federate) (quote peer1))
|
||||
false)
|
||||
(acl-dt-check!
|
||||
"grant plus deny -> deny wins"
|
||||
(acl-permit? db (quote alice) (quote edit) (quote page1))
|
||||
false)
|
||||
(acl-dt-check!
|
||||
"deny alone still denies"
|
||||
(acl-permit?
|
||||
(acl-build-db
|
||||
(list (acl-deny (quote alice) (quote read) (quote page1))))
|
||||
(quote alice)
|
||||
(quote read)
|
||||
(quote page1))
|
||||
false)
|
||||
(acl-dt-check!
|
||||
"deny on edit does not block read"
|
||||
(acl-permit? db (quote alice) (quote read) (quote page1))
|
||||
true)
|
||||
(acl-dt-check!
|
||||
"empty db denies"
|
||||
(acl-permit?
|
||||
(acl-build-db (list))
|
||||
(quote alice)
|
||||
(quote read)
|
||||
(quote page1))
|
||||
false)
|
||||
(let
|
||||
((db2 (acl-build-db (list (acl-grant (quote a) (quote read) (quote r)) (acl-grant (quote b) (quote read) (quote r)) (acl-deny (quote b) (quote read) (quote r))))))
|
||||
(do
|
||||
(acl-dt-check!
|
||||
"subject a allowed"
|
||||
(acl-permit? db2 (quote a) (quote read) (quote r))
|
||||
true)
|
||||
(acl-dt-check!
|
||||
"subject b denied by override"
|
||||
(acl-permit? db2 (quote b) (quote read) (quote r))
|
||||
false)))
|
||||
(let
|
||||
((db3 (acl-build-db (list (acl-actor (quote editors) (quote role)) (acl-grant (quote editors) (quote edit) (quote post1))))))
|
||||
(acl-dt-check!
|
||||
"role subject direct grant"
|
||||
(acl-permit? db3 (quote editors) (quote edit) (quote post1))
|
||||
true))
|
||||
(do
|
||||
(acl/load!
|
||||
(list
|
||||
(acl-grant (quote carol) (quote moderate) (quote thread1))))
|
||||
(acl-dt-check!
|
||||
"api permit via current db"
|
||||
(acl/permit? (quote carol) (quote moderate) (quote thread1))
|
||||
true)
|
||||
(acl-dt-check!
|
||||
"api deny via current db"
|
||||
(acl/permit? (quote carol) (quote read) (quote thread1))
|
||||
false))
|
||||
(do
|
||||
(acl/load! (list))
|
||||
(acl-dt-check!
|
||||
"api reload clears prior grants"
|
||||
(acl/permit? (quote carol) (quote moderate) (quote thread1))
|
||||
false))
|
||||
(acl-dt-check!
|
||||
"schema grant arity valid"
|
||||
(acl-fact-valid? (acl-grant (quote x) (quote read) (quote y)))
|
||||
true)
|
||||
(acl-dt-check!
|
||||
"schema bad arity invalid"
|
||||
(acl-fact-valid? (list (quote grant) (quote x)))
|
||||
false)
|
||||
(acl-dt-check!
|
||||
"schema unknown predicate invalid"
|
||||
(acl-fact-valid? (list (quote frobnicate) (quote x)))
|
||||
false)
|
||||
(acl-dt-check!
|
||||
"schema subject kind known"
|
||||
(acl-subject-kind? (quote service))
|
||||
true)
|
||||
(acl-dt-check!
|
||||
"schema resource kind unknown"
|
||||
(acl-resource-kind? (quote galaxy))
|
||||
false)
|
||||
(acl-dt-check!
|
||||
"schema known action"
|
||||
(acl-known-action? (quote moderate))
|
||||
true)
|
||||
(acl-dt-check!
|
||||
"grant constructor shape"
|
||||
(acl-grant (quote u) (quote read) (quote p))
|
||||
(list (quote grant) (quote u) (quote read) (quote p)))
|
||||
(acl-dt-check!
|
||||
"actor constructor shape"
|
||||
(acl-actor (quote u) (quote user))
|
||||
(list (quote actor) (quote u) (quote user)))))))
|
||||
|
||||
(define
|
||||
acl-direct-tests-run!
|
||||
(fn
|
||||
()
|
||||
(do
|
||||
(set! acl-dt-pass 0)
|
||||
(set! acl-dt-fail 0)
|
||||
(set! acl-dt-failures (list))
|
||||
(acl-dt-run-all!)
|
||||
{:failures acl-dt-failures :total (+ acl-dt-pass acl-dt-fail) :passed acl-dt-pass :failed acl-dt-fail})))
|
||||
316
lib/acl/tests/explain.sx
Normal file
316
lib/acl/tests/explain.sx
Normal file
@@ -0,0 +1,316 @@
|
||||
;; lib/acl/tests/explain.sx — Phase 3: proof correctness + audit completeness.
|
||||
|
||||
(define acl-et-pass 0)
|
||||
(define acl-et-fail 0)
|
||||
(define acl-et-failures (list))
|
||||
|
||||
;; Name-based deep equality. The host `=` compares symbols by interned
|
||||
;; identity, which is unstable across substitution/saturation; comparing by
|
||||
;; name (as the datalog suite does) makes structural assertions deterministic.
|
||||
(define
|
||||
acl-et-eq?
|
||||
(fn
|
||||
(a b)
|
||||
(cond
|
||||
((and (list? a) (list? b))
|
||||
(and (= (len a) (len b)) (acl-et-eq-l? a b 0)))
|
||||
((and (dict? a) (dict? b))
|
||||
(let
|
||||
((ka (keys a)) (kb (keys b)))
|
||||
(and (= (len ka) (len kb)) (acl-et-eq-d? a b ka 0))))
|
||||
((and (symbol? a) (symbol? b))
|
||||
(= (symbol->string a) (symbol->string b)))
|
||||
(else (= a b)))))
|
||||
|
||||
(define
|
||||
acl-et-eq-l?
|
||||
(fn
|
||||
(a b i)
|
||||
(cond
|
||||
((>= i (len a)) true)
|
||||
((not (acl-et-eq? (nth a i) (nth b i))) false)
|
||||
(else (acl-et-eq-l? a b (+ i 1))))))
|
||||
|
||||
(define
|
||||
acl-et-eq-d?
|
||||
(fn
|
||||
(a b ka i)
|
||||
(cond
|
||||
((>= i (len ka)) true)
|
||||
((let ((k (nth ka i))) (not (acl-et-eq? (get a k) (get b k))))
|
||||
false)
|
||||
(else (acl-et-eq-d? a b ka (+ i 1))))))
|
||||
|
||||
(define
|
||||
acl-et-check!
|
||||
(fn
|
||||
(name got expected)
|
||||
(if
|
||||
(acl-et-eq? got expected)
|
||||
(set! acl-et-pass (+ acl-et-pass 1))
|
||||
(do
|
||||
(set! acl-et-fail (+ acl-et-fail 1))
|
||||
(append!
|
||||
acl-et-failures
|
||||
(str name "\n expected: " expected "\n got: " got))))))
|
||||
|
||||
;; --- proof-tree walkers ---
|
||||
|
||||
;; True if EDB fact `target` appears as a base leaf anywhere in the proof.
|
||||
(define
|
||||
acl-et-has-leaf?
|
||||
(fn
|
||||
(node target)
|
||||
(cond
|
||||
((= node nil) false)
|
||||
((and (dict? node) (has-key? node :via))
|
||||
(acl-et-eq? (get node :fact) target))
|
||||
((and (dict? node) (has-key? node :body))
|
||||
(acl-et-any-leaf? (get node :body) target))
|
||||
(else false))))
|
||||
|
||||
(define
|
||||
acl-et-any-leaf?
|
||||
(fn
|
||||
(nodes target)
|
||||
(cond
|
||||
((= (len nodes) 0) false)
|
||||
((acl-et-has-leaf? (first nodes) target) true)
|
||||
(else (acl-et-any-leaf? (rest nodes) target)))))
|
||||
|
||||
;; True if the proof records a verified negation (deny did not fire).
|
||||
(define
|
||||
acl-et-has-negok?
|
||||
(fn
|
||||
(node)
|
||||
(cond
|
||||
((= node nil) false)
|
||||
((and (dict? node) (has-key? node :neg-ok)) true)
|
||||
((and (dict? node) (has-key? node :body))
|
||||
(acl-et-any-negok? (get node :body)))
|
||||
(else false))))
|
||||
|
||||
(define
|
||||
acl-et-any-negok?
|
||||
(fn
|
||||
(nodes)
|
||||
(cond
|
||||
((= (len nodes) 0) false)
|
||||
((acl-et-has-negok? (first nodes)) true)
|
||||
(else (acl-et-any-negok? (rest nodes))))))
|
||||
|
||||
(define
|
||||
acl-et-run-all!
|
||||
(fn
|
||||
()
|
||||
(do
|
||||
(let
|
||||
((db (acl-build-db (list (acl-grant (quote u) (quote read) (quote p))))))
|
||||
(let
|
||||
((e (acl-explain db (quote u) (quote read) (quote p))))
|
||||
(do
|
||||
(acl-et-check! "direct: allowed?" (get e :allowed?) true)
|
||||
(acl-et-check!
|
||||
"direct: proof root fact"
|
||||
(get (get e :proof) :fact)
|
||||
(list (quote permit) (quote u) (quote read) (quote p)))
|
||||
(acl-et-check!
|
||||
"direct: grant leaf present"
|
||||
(acl-et-has-leaf?
|
||||
(get e :proof)
|
||||
(list (quote grant) (quote u) (quote read) (quote p)))
|
||||
true)
|
||||
(acl-et-check!
|
||||
"direct: negation verified"
|
||||
(acl-et-has-negok? (get e :proof))
|
||||
true)
|
||||
(acl-et-check!
|
||||
"direct: reason nil when allowed"
|
||||
(get e :reason)
|
||||
nil))))
|
||||
(let
|
||||
((db (acl-build-db (list (acl-member-of (quote alice) (quote team)) (acl-member-of (quote team) (quote org)) (acl-grant (quote org) (quote read) (quote doc))))))
|
||||
(let
|
||||
((e (acl-explain db (quote alice) (quote read) (quote doc))))
|
||||
(do
|
||||
(acl-et-check! "group: allowed?" (get e :allowed?) true)
|
||||
(acl-et-check!
|
||||
"group: member_of alice leaf"
|
||||
(acl-et-has-leaf?
|
||||
(get e :proof)
|
||||
(list (quote member_of) (quote alice) (quote team)))
|
||||
true)
|
||||
(acl-et-check!
|
||||
"group: member_of team leaf"
|
||||
(acl-et-has-leaf?
|
||||
(get e :proof)
|
||||
(list (quote member_of) (quote team) (quote org)))
|
||||
true)
|
||||
(acl-et-check!
|
||||
"group: grant org leaf at base"
|
||||
(acl-et-has-leaf?
|
||||
(get e :proof)
|
||||
(list (quote grant) (quote org) (quote read) (quote doc)))
|
||||
true))))
|
||||
(let
|
||||
((db (acl-build-db (list (acl-child-of (quote sec) (quote book)) (acl-grant (quote u) (quote read) (quote book))))))
|
||||
(let
|
||||
((e (acl-explain db (quote u) (quote read) (quote sec))))
|
||||
(do
|
||||
(acl-et-check! "resource: allowed?" (get e :allowed?) true)
|
||||
(acl-et-check!
|
||||
"resource: child_of leaf"
|
||||
(acl-et-has-leaf?
|
||||
(get e :proof)
|
||||
(list (quote child_of) (quote sec) (quote book)))
|
||||
true)
|
||||
(acl-et-check!
|
||||
"resource: grant on parent leaf"
|
||||
(acl-et-has-leaf?
|
||||
(get e :proof)
|
||||
(list (quote grant) (quote u) (quote read) (quote book)))
|
||||
true))))
|
||||
(let
|
||||
((db (acl-build-db (list (acl-member-of (quote bob) (quote editor)) (acl-role-grant (quote editor) (quote edit) (quote page1))))))
|
||||
(let
|
||||
((e (acl-explain db (quote bob) (quote edit) (quote page1))))
|
||||
(do
|
||||
(acl-et-check! "role: allowed?" (get e :allowed?) true)
|
||||
(acl-et-check!
|
||||
"role: member_of leaf"
|
||||
(acl-et-has-leaf?
|
||||
(get e :proof)
|
||||
(list (quote member_of) (quote bob) (quote editor)))
|
||||
true)
|
||||
(acl-et-check!
|
||||
"role: role_grant leaf"
|
||||
(acl-et-has-leaf?
|
||||
(get e :proof)
|
||||
(list
|
||||
(quote role_grant)
|
||||
(quote editor)
|
||||
(quote edit)
|
||||
(quote page1)))
|
||||
true))))
|
||||
(let
|
||||
((db (acl-build-db (list (acl-grant (quote u) (quote edit) (quote p)) (acl-deny (quote u) (quote edit) (quote p))))))
|
||||
(let
|
||||
((e (acl-explain db (quote u) (quote edit) (quote p))))
|
||||
(do
|
||||
(acl-et-check! "deny: not allowed" (get e :allowed?) false)
|
||||
(acl-et-check! "deny: no proof" (get e :proof) nil)
|
||||
(acl-et-check!
|
||||
"deny: reason root is eff_deny"
|
||||
(get (get e :reason) :fact)
|
||||
(list (quote eff_deny) (quote u) (quote edit) (quote p)))
|
||||
(acl-et-check!
|
||||
"deny: reason has deny leaf"
|
||||
(acl-et-has-leaf?
|
||||
(get e :reason)
|
||||
(list (quote deny) (quote u) (quote edit) (quote p)))
|
||||
true))))
|
||||
(let
|
||||
((db (acl-build-db (list (acl-member-of (quote alice) (quote team)) (acl-grant (quote alice) (quote read) (quote doc)) (acl-deny (quote team) (quote read) (quote doc))))))
|
||||
(let
|
||||
((e (acl-explain db (quote alice) (quote read) (quote doc))))
|
||||
(do
|
||||
(acl-et-check!
|
||||
"inherited deny: not allowed"
|
||||
(get e :allowed?)
|
||||
false)
|
||||
(acl-et-check!
|
||||
"inherited deny: reason has member_of leaf"
|
||||
(acl-et-has-leaf?
|
||||
(get e :reason)
|
||||
(list (quote member_of) (quote alice) (quote team)))
|
||||
true)
|
||||
(acl-et-check!
|
||||
"inherited deny: reason has group deny leaf"
|
||||
(acl-et-has-leaf?
|
||||
(get e :reason)
|
||||
(list (quote deny) (quote team) (quote read) (quote doc)))
|
||||
true))))
|
||||
(let
|
||||
((db (acl-build-db (list))))
|
||||
(let
|
||||
((e (acl-explain db (quote u) (quote read) (quote p))))
|
||||
(do
|
||||
(acl-et-check! "no grant: not allowed" (get e :allowed?) false)
|
||||
(acl-et-check! "no grant: proof nil" (get e :proof) nil)
|
||||
(acl-et-check! "no grant: reason nil" (get e :reason) nil))))
|
||||
(let
|
||||
((db (acl-build-db (list (acl-grant (quote u) (quote read) (quote p)) (acl-deny (quote u) (quote edit) (quote p))))))
|
||||
(do
|
||||
(acl-audit-clear!)
|
||||
(acl-et-check! "audit: starts empty" (acl-audit-count) 0)
|
||||
(acl-et-check!
|
||||
"audit decide allowed returns true"
|
||||
(acl-audit-decide! db (quote u) (quote read) (quote p))
|
||||
true)
|
||||
(acl-et-check!
|
||||
"audit decide denied returns false"
|
||||
(acl-audit-decide! db (quote u) (quote edit) (quote p))
|
||||
false)
|
||||
(acl-audit-decide! db (quote u) (quote comment) (quote p))
|
||||
(acl-et-check!
|
||||
"audit: count after three decisions"
|
||||
(acl-audit-count)
|
||||
3)
|
||||
(acl-et-check!
|
||||
"audit: tail size respects n"
|
||||
(len (acl-audit-tail 2))
|
||||
2)
|
||||
(acl-et-check!
|
||||
"audit: tail returns most recent"
|
||||
(get (first (acl-audit-tail 1)) :act)
|
||||
(quote comment))
|
||||
(acl-et-check!
|
||||
"audit: first record seq is 0"
|
||||
(get (first (acl-audit-tail 3)) :seq)
|
||||
0)
|
||||
(acl-et-check!
|
||||
"audit: allowed flag recorded"
|
||||
(get (first (acl-audit-tail 3)) :allowed?)
|
||||
true)
|
||||
(acl-et-check!
|
||||
"audit: serialize line count"
|
||||
(len (acl-et-lines (acl-audit-serialize)))
|
||||
3)
|
||||
(acl-audit-clear!)
|
||||
(acl-et-check!
|
||||
"audit: clear resets count"
|
||||
(acl-audit-count)
|
||||
0))))))
|
||||
|
||||
;; count newline-terminated lines in a serialized log
|
||||
(define acl-et-lines (fn (s) (acl-et-count-nl s 0 0)))
|
||||
(define
|
||||
acl-et-count-nl
|
||||
(fn
|
||||
(s i n)
|
||||
(if
|
||||
(>= i (len s))
|
||||
(if (= n 0) (list) (acl-et-rangelist n))
|
||||
(acl-et-count-nl
|
||||
s
|
||||
(+ i 1)
|
||||
(if (= (slice s i (+ i 1)) "\n") (+ n 1) n)))))
|
||||
(define
|
||||
acl-et-rangelist
|
||||
(fn
|
||||
(n)
|
||||
(if
|
||||
(<= n 0)
|
||||
(list)
|
||||
(cons n (acl-et-rangelist (- n 1))))))
|
||||
|
||||
(define
|
||||
acl-explain-tests-run!
|
||||
(fn
|
||||
()
|
||||
(do
|
||||
(set! acl-et-pass 0)
|
||||
(set! acl-et-fail 0)
|
||||
(set! acl-et-failures (list))
|
||||
(acl-et-run-all!)
|
||||
{:failures acl-et-failures :total (+ acl-et-pass acl-et-fail) :passed acl-et-pass :failed acl-et-fail})))
|
||||
273
lib/acl/tests/fed.sx
Normal file
273
lib/acl/tests/fed.sx
Normal file
@@ -0,0 +1,273 @@
|
||||
;; lib/acl/tests/fed.sx — Phase 4: federation (peer trust, delegation,
|
||||
;; cross-instance chains, revocation). fed-sx transport is mocked as a dict.
|
||||
|
||||
(define acl-ft-pass 0)
|
||||
(define acl-ft-fail 0)
|
||||
(define acl-ft-failures (list))
|
||||
|
||||
;; Name-based deep equality (host `=` compares symbols by unstable interned
|
||||
;; identity; see lib/acl/tests/explain.sx).
|
||||
(define
|
||||
acl-ft-eq?
|
||||
(fn
|
||||
(a b)
|
||||
(cond
|
||||
((and (list? a) (list? b))
|
||||
(and (= (len a) (len b)) (acl-ft-eq-l? a b 0)))
|
||||
((and (symbol? a) (symbol? b))
|
||||
(= (symbol->string a) (symbol->string b)))
|
||||
(else (= a b)))))
|
||||
(define
|
||||
acl-ft-eq-l?
|
||||
(fn
|
||||
(a b i)
|
||||
(cond
|
||||
((>= i (len a)) true)
|
||||
((not (acl-ft-eq? (nth a i) (nth b i))) false)
|
||||
(else (acl-ft-eq-l? a b (+ i 1))))))
|
||||
|
||||
(define
|
||||
acl-ft-check!
|
||||
(fn
|
||||
(name got expected)
|
||||
(if
|
||||
(acl-ft-eq? got expected)
|
||||
(set! acl-ft-pass (+ acl-ft-pass 1))
|
||||
(do
|
||||
(set! acl-ft-fail (+ acl-ft-fail 1))
|
||||
(append!
|
||||
acl-ft-failures
|
||||
(str name "\n expected: " expected "\n got: " got))))))
|
||||
|
||||
;; proof leaf walker (federated proofs reconstruct through the engine rule).
|
||||
(define
|
||||
acl-ft-has-leaf?
|
||||
(fn
|
||||
(node target)
|
||||
(cond
|
||||
((= node nil) false)
|
||||
((and (dict? node) (has-key? node :via))
|
||||
(acl-ft-eq? (get node :fact) target))
|
||||
((and (dict? node) (has-key? node :body))
|
||||
(acl-ft-any-leaf? (get node :body) target))
|
||||
(else false))))
|
||||
(define
|
||||
acl-ft-any-leaf?
|
||||
(fn
|
||||
(nodes target)
|
||||
(cond
|
||||
((= (len nodes) 0) false)
|
||||
((acl-ft-has-leaf? (first nodes) target) true)
|
||||
(else (acl-ft-any-leaf? (rest nodes) target)))))
|
||||
|
||||
(define acl-ft-p? (fn (db s a r) (acl-permit? db s a r)))
|
||||
|
||||
;; A standard federation fixture: local trusts peer alpha at "readonly", which
|
||||
;; covers read+comment. alpha delegates several capabilities to alice.
|
||||
(define
|
||||
acl-ft-fixture
|
||||
(fn
|
||||
()
|
||||
(acl-build-db
|
||||
(list
|
||||
(acl-trust (quote alpha) (quote readonly))
|
||||
(acl-level-covers (quote readonly) (quote read))
|
||||
(acl-level-covers (quote readonly) (quote comment))
|
||||
(acl-delegate (quote alpha) (quote alice) (quote read) (quote doc))
|
||||
(acl-delegate (quote alpha) (quote alice) (quote edit) (quote doc))))))
|
||||
|
||||
(define
|
||||
acl-ft-run-all!
|
||||
(fn
|
||||
()
|
||||
(do
|
||||
(let
|
||||
((db (acl-ft-fixture)))
|
||||
(do
|
||||
(acl-ft-check!
|
||||
"trusted delegate, level covers action -> permit"
|
||||
(acl-ft-p? db (quote alice) (quote read) (quote doc))
|
||||
true)
|
||||
(acl-ft-check!
|
||||
"trusted delegate, level does NOT cover action -> deny"
|
||||
(acl-ft-p? db (quote alice) (quote edit) (quote doc))
|
||||
false)
|
||||
(acl-ft-check!
|
||||
"delegated but action class uncovered (comment has no delegate)"
|
||||
(acl-ft-p? db (quote alice) (quote comment) (quote doc))
|
||||
false)))
|
||||
(let
|
||||
((db (acl-build-db (list (acl-level-covers (quote readonly) (quote read)) (acl-delegate (quote beta) (quote bob) (quote read) (quote doc))))))
|
||||
(acl-ft-check!
|
||||
"untrusted peer delegate -> deny"
|
||||
(acl-ft-p? db (quote bob) (quote read) (quote doc))
|
||||
false))
|
||||
(let
|
||||
((db (acl-build-db (list (acl-trust (quote alpha) (quote readonly)) (acl-delegate (quote alpha) (quote alice) (quote read) (quote doc))))))
|
||||
(acl-ft-check!
|
||||
"trust but no level_covers -> deny"
|
||||
(acl-ft-p? db (quote alice) (quote read) (quote doc))
|
||||
false))
|
||||
(let
|
||||
((db (acl-build-db (list (acl-trust (quote alpha) (quote full)) (acl-level-covers (quote full) (quote read)) (acl-delegate (quote alpha) (quote alice) (quote read) (quote doc)) (acl-delegate (quote beta) (quote bob) (quote read) (quote doc))))))
|
||||
(do
|
||||
(acl-ft-check!
|
||||
"trust is per-peer: alpha's delegate applies"
|
||||
(acl-ft-p? db (quote alice) (quote read) (quote doc))
|
||||
true)
|
||||
(acl-ft-check!
|
||||
"trust not transitive: beta's delegate does not apply"
|
||||
(acl-ft-p? db (quote bob) (quote read) (quote doc))
|
||||
false)))
|
||||
(let
|
||||
((db (acl-build-db (list (acl-trust (quote alpha) (quote full)) (acl-level-covers (quote full) (quote read)) (acl-delegate (quote alpha) (quote alice) (quote read) (quote doc)) (acl-deny (quote alice) (quote read) (quote doc))))))
|
||||
(acl-ft-check!
|
||||
"local deny overrides federated grant"
|
||||
(acl-ft-p? db (quote alice) (quote read) (quote doc))
|
||||
false))
|
||||
(let
|
||||
((db (acl-build-db (list (acl-trust (quote alpha) (quote full)) (acl-level-covers (quote full) (quote read)) (acl-member-of (quote alice) (quote team)) (acl-delegate (quote alpha) (quote team) (quote read) (quote doc))))))
|
||||
(acl-ft-check!
|
||||
"federated grant to group reaches member"
|
||||
(acl-ft-p? db (quote alice) (quote read) (quote doc))
|
||||
true))
|
||||
(let
|
||||
((db (acl-build-db (list (acl-trust (quote alpha) (quote full)) (acl-level-covers (quote full) (quote read)) (acl-child-of (quote sec) (quote book)) (acl-delegate (quote alpha) (quote u) (quote read) (quote book))))))
|
||||
(acl-ft-check!
|
||||
"federated grant on parent resource reaches child"
|
||||
(acl-ft-p? db (quote u) (quote read) (quote sec))
|
||||
true))
|
||||
(let
|
||||
((transport {:gamma (list (acl-delegate (quote gamma) (quote carol) (quote read) (quote post))) :alpha (list (acl-delegate (quote alpha) (quote alice) (quote read) (quote doc)))}))
|
||||
(do
|
||||
(acl-ft-check!
|
||||
"fetch known peer returns its delegates"
|
||||
(len (acl-fed-fetch transport (quote alpha)))
|
||||
1)
|
||||
(acl-ft-check!
|
||||
"fetch unknown peer returns empty"
|
||||
(len (acl-fed-fetch transport (quote delta)))
|
||||
0)
|
||||
(acl-ft-check!
|
||||
"collect across peers"
|
||||
(len
|
||||
(acl-fed-collect transport (list (quote alpha) (quote gamma))))
|
||||
2)
|
||||
(let
|
||||
((db (acl-fed-build-db (list (acl-trust (quote alpha) (quote readonly)) (acl-trust (quote gamma) (quote readonly)) (acl-level-covers (quote readonly) (quote read))) transport (list (quote alpha) (quote gamma)))))
|
||||
(do
|
||||
(acl-ft-check!
|
||||
"fed-build-db: alpha delegate permits"
|
||||
(acl-ft-p? db (quote alice) (quote read) (quote doc))
|
||||
true)
|
||||
(acl-ft-check!
|
||||
"fed-build-db: gamma delegate permits"
|
||||
(acl-ft-p? db (quote carol) (quote read) (quote post))
|
||||
true)
|
||||
(acl-ft-check!
|
||||
"fed-build-db: untrusted action still denied"
|
||||
(acl-ft-p? db (quote alice) (quote edit) (quote doc))
|
||||
false)))))
|
||||
(let
|
||||
((db (acl-build-db (list (acl-trust (quote alpha) (quote full)) (acl-level-covers (quote full) (quote read)) (acl-delegate (quote alpha) (quote alice) (quote read) (quote doc))))))
|
||||
(do
|
||||
(acl-ft-check!
|
||||
"before revoke: permitted"
|
||||
(acl-ft-p? db (quote alice) (quote read) (quote doc))
|
||||
true)
|
||||
(acl-revoke!
|
||||
db
|
||||
(acl-delegate
|
||||
(quote alpha)
|
||||
(quote alice)
|
||||
(quote read)
|
||||
(quote doc)))
|
||||
(acl-ft-check!
|
||||
"after delegate revoked: denied"
|
||||
(acl-ft-p? db (quote alice) (quote read) (quote doc))
|
||||
false)))
|
||||
(let
|
||||
((db (acl-build-db (list (acl-trust (quote alpha) (quote full)) (acl-level-covers (quote full) (quote read)) (acl-delegate (quote alpha) (quote alice) (quote read) (quote doc))))))
|
||||
(do
|
||||
(acl-ft-check!
|
||||
"before trust revoke: permitted"
|
||||
(acl-ft-p? db (quote alice) (quote read) (quote doc))
|
||||
true)
|
||||
(acl-revoke! db (acl-trust (quote alpha) (quote full)))
|
||||
(acl-ft-check!
|
||||
"after trust revoked: denied"
|
||||
(acl-ft-p? db (quote alice) (quote read) (quote doc))
|
||||
false)))
|
||||
(let
|
||||
((db (acl-build-db (list (acl-level-covers (quote full) (quote read)) (acl-delegate (quote alpha) (quote alice) (quote read) (quote doc))))))
|
||||
(do
|
||||
(acl-ft-check!
|
||||
"delegate without trust: denied"
|
||||
(acl-ft-p? db (quote alice) (quote read) (quote doc))
|
||||
false)
|
||||
(acl-fed-assert! db (acl-trust (quote alpha) (quote full)))
|
||||
(acl-ft-check!
|
||||
"trust ingested then re-checked: permitted"
|
||||
(acl-ft-p? db (quote alice) (quote read) (quote doc))
|
||||
true)))
|
||||
(let
|
||||
((db (acl-ft-fixture)))
|
||||
(let
|
||||
((e (acl-explain db (quote alice) (quote read) (quote doc))))
|
||||
(do
|
||||
(acl-ft-check! "federated proof allowed?" (get e :allowed?) true)
|
||||
(acl-ft-check!
|
||||
"federated proof has delegate leaf"
|
||||
(acl-ft-has-leaf?
|
||||
(get e :proof)
|
||||
(list
|
||||
(quote delegate)
|
||||
(quote alpha)
|
||||
(quote alice)
|
||||
(quote read)
|
||||
(quote doc)))
|
||||
true)
|
||||
(acl-ft-check!
|
||||
"federated proof has trust leaf"
|
||||
(acl-ft-has-leaf?
|
||||
(get e :proof)
|
||||
(list (quote trust) (quote alpha) (quote readonly)))
|
||||
true)
|
||||
(acl-ft-check!
|
||||
"federated proof has level_covers leaf"
|
||||
(acl-ft-has-leaf?
|
||||
(get e :proof)
|
||||
(list (quote level_covers) (quote readonly) (quote read)))
|
||||
true))))
|
||||
(acl-ft-check!
|
||||
"schema delegate arity valid"
|
||||
(acl-fact-valid?
|
||||
(acl-delegate (quote p) (quote s) (quote a) (quote r)))
|
||||
true)
|
||||
(acl-ft-check!
|
||||
"schema trust arity valid"
|
||||
(acl-fact-valid? (acl-trust (quote p) (quote l)))
|
||||
true)
|
||||
(acl-ft-check!
|
||||
"schema peer arity valid"
|
||||
(acl-fact-valid? (acl-peer (quote p) (quote peer)))
|
||||
true)
|
||||
(acl-ft-check!
|
||||
"schema level_covers arity valid"
|
||||
(acl-fact-valid? (acl-level-covers (quote l) (quote read)))
|
||||
true)
|
||||
(acl-ft-check!
|
||||
"schema delegate bad arity invalid"
|
||||
(acl-fact-valid? (list (quote delegate) (quote p) (quote s)))
|
||||
false))))
|
||||
|
||||
(define
|
||||
acl-fed-tests-run!
|
||||
(fn
|
||||
()
|
||||
(do
|
||||
(set! acl-ft-pass 0)
|
||||
(set! acl-ft-fail 0)
|
||||
(set! acl-ft-failures (list))
|
||||
(acl-ft-run-all!)
|
||||
{:failures acl-ft-failures :total (+ acl-ft-pass acl-ft-fail) :passed acl-ft-pass :failed acl-ft-fail})))
|
||||
228
lib/acl/tests/harden.sx
Normal file
228
lib/acl/tests/harden.sx
Normal file
@@ -0,0 +1,228 @@
|
||||
;; lib/acl/tests/harden.sx — adversarial / cross-phase hardening.
|
||||
;;
|
||||
;; Diamond hierarchies, conflict resolution where deny must win through every
|
||||
;; path, chain inheritance, cycle termination, multi-peer delegation, fact
|
||||
;; validation, and audit save/restore.
|
||||
;;
|
||||
;; PROVER-FREE BY DESIGN: this suite calls only acl-permit? (which runs in
|
||||
;; compiled Datalog, safe at any depth) plus pure data ops — never acl-explain /
|
||||
;; acl-prove-d. The SX-side proof reconstructor recurses, and once the kernel
|
||||
;; JIT-compiles it (after the explain/fed suites warm the process) it loops on
|
||||
;; chains deeper than ~3 (substrate JIT bug — see plan Blockers). Proof
|
||||
;; reconstruction is covered by tests/explain.sx (and federated proofs by
|
||||
;; tests/fed.sx), both of which stay under the warm-process depth threshold.
|
||||
|
||||
(define acl-hd-pass 0)
|
||||
(define acl-hd-fail 0)
|
||||
(define acl-hd-failures (list))
|
||||
|
||||
(define
|
||||
acl-hd-check!
|
||||
(fn
|
||||
(name got expected)
|
||||
(if
|
||||
(= got expected)
|
||||
(set! acl-hd-pass (+ acl-hd-pass 1))
|
||||
(do
|
||||
(set! acl-hd-fail (+ acl-hd-fail 1))
|
||||
(append!
|
||||
acl-hd-failures
|
||||
(str name "\n expected: " expected "\n got: " got))))))
|
||||
|
||||
(define acl-hd-p? (fn (db s a r) (acl-permit? db s a r)))
|
||||
|
||||
(define
|
||||
acl-hd-run-all!
|
||||
(fn
|
||||
()
|
||||
(do
|
||||
(let
|
||||
((grant-deny (acl-build-db (list (acl-child-of (quote r) (quote p1)) (acl-child-of (quote r) (quote p2)) (acl-grant (quote u) (quote read) (quote p1)) (acl-deny (quote u) (quote read) (quote p2)))))
|
||||
(both-grant
|
||||
(acl-build-db
|
||||
(list
|
||||
(acl-child-of (quote r) (quote p1))
|
||||
(acl-child-of (quote r) (quote p2))
|
||||
(acl-grant (quote u) (quote read) (quote p1))
|
||||
(acl-grant (quote u) (quote read) (quote p2))))))
|
||||
(do
|
||||
(acl-hd-check!
|
||||
"diamond resource: grant+deny parents -> deny wins"
|
||||
(acl-hd-p? grant-deny (quote u) (quote read) (quote r))
|
||||
false)
|
||||
(acl-hd-check!
|
||||
"diamond resource: both grant -> permit"
|
||||
(acl-hd-p? both-grant (quote u) (quote read) (quote r))
|
||||
true)
|
||||
(acl-hd-check!
|
||||
"diamond resource: deny does not leak to other parent"
|
||||
(acl-hd-p? grant-deny (quote u) (quote read) (quote p1))
|
||||
true)))
|
||||
(let
|
||||
((grant-deny (acl-build-db (list (acl-member-of (quote alice) (quote g1)) (acl-member-of (quote alice) (quote g2)) (acl-grant (quote g1) (quote read) (quote doc)) (acl-deny (quote g2) (quote read) (quote doc)))))
|
||||
(both-grant
|
||||
(acl-build-db
|
||||
(list
|
||||
(acl-member-of (quote alice) (quote g1))
|
||||
(acl-member-of (quote alice) (quote g2))
|
||||
(acl-grant (quote g1) (quote read) (quote doc))
|
||||
(acl-grant (quote g2) (quote read) (quote doc))))))
|
||||
(do
|
||||
(acl-hd-check!
|
||||
"diamond group: grant+deny groups -> deny wins"
|
||||
(acl-hd-p? grant-deny (quote alice) (quote read) (quote doc))
|
||||
false)
|
||||
(acl-hd-check!
|
||||
"diamond group: both grant -> permit"
|
||||
(acl-hd-p? both-grant (quote alice) (quote read) (quote doc))
|
||||
true)))
|
||||
(let
|
||||
((chain (acl-build-db (list (acl-member-of (quote a0) (quote a1)) (acl-member-of (quote a1) (quote a2)) (acl-member-of (quote a2) (quote a3)) (acl-member-of (quote a3) (quote a4)) (acl-grant (quote a4) (quote read) (quote res)))))
|
||||
(chain-deny
|
||||
(acl-build-db
|
||||
(list
|
||||
(acl-member-of (quote a0) (quote a1))
|
||||
(acl-member-of (quote a1) (quote a2))
|
||||
(acl-member-of (quote a2) (quote a3))
|
||||
(acl-member-of (quote a3) (quote a4))
|
||||
(acl-grant (quote a4) (quote read) (quote res))
|
||||
(acl-deny (quote a0) (quote read) (quote res))))))
|
||||
(do
|
||||
(acl-hd-check!
|
||||
"chain: top-group grant reaches leaf member"
|
||||
(acl-hd-p? chain (quote a0) (quote read) (quote res))
|
||||
true)
|
||||
(acl-hd-check!
|
||||
"chain: intermediate also covered"
|
||||
(acl-hd-p? chain (quote a2) (quote read) (quote res))
|
||||
true)
|
||||
(acl-hd-check!
|
||||
"chain: leaf-member deny overrides top grant"
|
||||
(acl-hd-p? chain-deny (quote a0) (quote read) (quote res))
|
||||
false)
|
||||
(acl-hd-check!
|
||||
"chain: deny on leaf does not block sibling level"
|
||||
(acl-hd-p? chain-deny (quote a1) (quote read) (quote res))
|
||||
true)))
|
||||
(let
|
||||
((self-member (acl-build-db (list (acl-member-of (quote a) (quote a)) (acl-grant (quote a) (quote read) (quote r)))))
|
||||
(self-child
|
||||
(acl-build-db
|
||||
(list
|
||||
(acl-child-of (quote r) (quote r))
|
||||
(acl-grant (quote u) (quote read) (quote r)))))
|
||||
(two-cycle
|
||||
(acl-build-db
|
||||
(list
|
||||
(acl-member-of (quote x) (quote y))
|
||||
(acl-member-of (quote y) (quote x))
|
||||
(acl-grant (quote y) (quote read) (quote r))))))
|
||||
(do
|
||||
(acl-hd-check!
|
||||
"self-membership cycle terminates and grants"
|
||||
(acl-hd-p? self-member (quote a) (quote read) (quote r))
|
||||
true)
|
||||
(acl-hd-check!
|
||||
"self-child cycle terminates and grants"
|
||||
(acl-hd-p? self-child (quote u) (quote read) (quote r))
|
||||
true)
|
||||
(acl-hd-check!
|
||||
"two-node membership cycle terminates"
|
||||
(acl-hd-p? two-cycle (quote x) (quote read) (quote r))
|
||||
true)))
|
||||
(let
|
||||
((db (acl-build-db (list (acl-trust (quote alpha) (quote full)) (acl-level-covers (quote full) (quote read)) (acl-member-of (quote alice) (quote team)) (acl-delegate (quote alpha) (quote team) (quote read) (quote doc)) (acl-deny (quote alice) (quote read) (quote doc))))))
|
||||
(acl-hd-check!
|
||||
"federated group grant, local member deny -> deny wins"
|
||||
(acl-hd-p? db (quote alice) (quote read) (quote doc))
|
||||
false))
|
||||
(let
|
||||
((db (acl-build-db (list (acl-trust (quote alpha) (quote full)) (acl-level-covers (quote full) (quote read)) (acl-delegate (quote alpha) (quote bob) (quote read) (quote doc)) (acl-delegate (quote beta) (quote bob) (quote read) (quote doc))))))
|
||||
(acl-hd-check!
|
||||
"two peers delegate, one trusted -> permit"
|
||||
(acl-hd-p? db (quote bob) (quote read) (quote doc))
|
||||
true))
|
||||
(let
|
||||
((db (acl-build-db (list (acl-trust (quote alpha) (quote full)) (acl-trust (quote beta) (quote full)) (acl-level-covers (quote full) (quote read)) (acl-delegate (quote alpha) (quote bob) (quote read) (quote doc)) (acl-delegate (quote beta) (quote bob) (quote read) (quote doc))))))
|
||||
(acl-hd-check!
|
||||
"two peers both trusted -> permit"
|
||||
(acl-hd-p? db (quote bob) (quote read) (quote doc))
|
||||
true))
|
||||
(let
|
||||
((empty (acl-build-db (list))))
|
||||
(acl-hd-check!
|
||||
"empty db: nothing permitted"
|
||||
(acl-hd-p? empty (quote u) (quote read) (quote r))
|
||||
false))
|
||||
(do
|
||||
(acl-hd-check!
|
||||
"validate: clean set has no bad facts"
|
||||
(len
|
||||
(acl-validate-facts
|
||||
(list
|
||||
(acl-grant (quote u) (quote read) (quote p))
|
||||
(acl-member-of (quote u) (quote g))
|
||||
(acl-delegate (quote pe) (quote u) (quote read) (quote p)))))
|
||||
0)
|
||||
(acl-hd-check!
|
||||
"validate: facts-valid? true on clean set"
|
||||
(acl-facts-valid?
|
||||
(list (acl-grant (quote u) (quote read) (quote p))))
|
||||
true)
|
||||
(acl-hd-check!
|
||||
"validate: surfaces wrong-arity and unknown predicate"
|
||||
(len
|
||||
(acl-validate-facts
|
||||
(list
|
||||
(acl-grant (quote u) (quote read) (quote p))
|
||||
(list (quote grant) (quote u))
|
||||
(list (quote bogus) (quote x) (quote y)))))
|
||||
2)
|
||||
(acl-hd-check!
|
||||
"validate: empty set is valid"
|
||||
(acl-facts-valid? (list))
|
||||
true))
|
||||
(let
|
||||
((db (acl-build-db (list (acl-grant (quote u) (quote read) (quote p)) (acl-deny (quote u) (quote edit) (quote p))))))
|
||||
(do
|
||||
(acl-audit-clear!)
|
||||
(acl-audit-decide! db (quote u) (quote read) (quote p))
|
||||
(acl-audit-decide! db (quote u) (quote edit) (quote p))
|
||||
(let
|
||||
((snap (acl-audit-snapshot)))
|
||||
(do
|
||||
(acl-audit-clear!)
|
||||
(acl-hd-check!
|
||||
"audit: cleared count is 0"
|
||||
(acl-audit-count)
|
||||
0)
|
||||
(acl-audit-restore! snap)
|
||||
(acl-hd-check!
|
||||
"audit: restored count"
|
||||
(acl-audit-count)
|
||||
2)
|
||||
(acl-hd-check!
|
||||
"audit: restored last act"
|
||||
(get (first (acl-audit-tail 1)) :act)
|
||||
(quote edit))
|
||||
(acl-audit-decide! db (quote u) (quote comment) (quote p))
|
||||
(acl-hd-check!
|
||||
"audit: seq continues after restore"
|
||||
(get (first (acl-audit-tail 1)) :seq)
|
||||
2)
|
||||
(acl-hd-check!
|
||||
"audit: snapshot is an immutable copy"
|
||||
(len (get snap :entries))
|
||||
2)
|
||||
(acl-audit-clear!))))))))
|
||||
|
||||
(define
|
||||
acl-harden-tests-run!
|
||||
(fn
|
||||
()
|
||||
(do
|
||||
(set! acl-hd-pass 0)
|
||||
(set! acl-hd-fail 0)
|
||||
(set! acl-hd-failures (list))
|
||||
(acl-hd-run-all!)
|
||||
{:failures acl-hd-failures :total (+ acl-hd-pass acl-hd-fail) :passed acl-hd-pass :failed acl-hd-fail})))
|
||||
202
lib/acl/tests/inherit.sx
Normal file
202
lib/acl/tests/inherit.sx
Normal file
@@ -0,0 +1,202 @@
|
||||
;; lib/acl/tests/inherit.sx — Phase 2: inheritance (groups, resource trees,
|
||||
;; role expansion) with deny-overrides.
|
||||
|
||||
(define acl-it-pass 0)
|
||||
(define acl-it-fail 0)
|
||||
(define acl-it-failures (list))
|
||||
|
||||
(define
|
||||
acl-it-check!
|
||||
(fn
|
||||
(name got expected)
|
||||
(if
|
||||
(= got expected)
|
||||
(set! acl-it-pass (+ acl-it-pass 1))
|
||||
(do
|
||||
(set! acl-it-fail (+ acl-it-fail 1))
|
||||
(append!
|
||||
acl-it-failures
|
||||
(str name "\n expected: " expected "\n got: " got))))))
|
||||
|
||||
(define acl-it-p? (fn (db s a r) (acl-permit? db s a r)))
|
||||
|
||||
(define
|
||||
acl-it-run-all!
|
||||
(fn
|
||||
()
|
||||
(do
|
||||
(let
|
||||
((db (acl-build-db (list (acl-member-of (quote alice) (quote team)) (acl-grant (quote team) (quote read) (quote doc))))))
|
||||
(do
|
||||
(acl-it-check!
|
||||
"group grant reaches member"
|
||||
(acl-it-p? db (quote alice) (quote read) (quote doc))
|
||||
true)
|
||||
(acl-it-check!
|
||||
"group grant: non-member excluded"
|
||||
(acl-it-p? db (quote bob) (quote read) (quote doc))
|
||||
false)
|
||||
(acl-it-check!
|
||||
"group grant: wrong action"
|
||||
(acl-it-p? db (quote alice) (quote edit) (quote doc))
|
||||
false)))
|
||||
(let
|
||||
((db (acl-build-db (list (acl-member-of (quote alice) (quote team)) (acl-member-of (quote team) (quote org)) (acl-member-of (quote org) (quote company)) (acl-grant (quote company) (quote read) (quote doc))))))
|
||||
(do
|
||||
(acl-it-check!
|
||||
"deep nested group grant reaches leaf member"
|
||||
(acl-it-p? db (quote alice) (quote read) (quote doc))
|
||||
true)
|
||||
(acl-it-check!
|
||||
"intermediate group also covered"
|
||||
(acl-it-p? db (quote team) (quote read) (quote doc))
|
||||
true)
|
||||
(acl-it-check!
|
||||
"mid group org covered"
|
||||
(acl-it-p? db (quote org) (quote read) (quote doc))
|
||||
true)))
|
||||
(let
|
||||
((db (acl-build-db (list (acl-member-of (quote a) (quote b)) (acl-member-of (quote b) (quote a)) (acl-grant (quote b) (quote read) (quote r))))))
|
||||
(do
|
||||
(acl-it-check!
|
||||
"cyclic membership terminates and grants"
|
||||
(acl-it-p? db (quote a) (quote read) (quote r))
|
||||
true)
|
||||
(acl-it-check!
|
||||
"cyclic membership covers both"
|
||||
(acl-it-p? db (quote b) (quote read) (quote r))
|
||||
true)))
|
||||
(let
|
||||
((db (acl-build-db (list (acl-child-of (quote sec) (quote chap)) (acl-child-of (quote chap) (quote book)) (acl-grant (quote u) (quote read) (quote book))))))
|
||||
(do
|
||||
(acl-it-check!
|
||||
"parent grant reaches direct child"
|
||||
(acl-it-p? db (quote u) (quote read) (quote chap))
|
||||
true)
|
||||
(acl-it-check!
|
||||
"parent grant reaches deep descendant"
|
||||
(acl-it-p? db (quote u) (quote read) (quote sec))
|
||||
true)
|
||||
(acl-it-check!
|
||||
"parent grant covers parent itself"
|
||||
(acl-it-p? db (quote u) (quote read) (quote book))
|
||||
true)
|
||||
(acl-it-check!
|
||||
"child grant does not climb to parent"
|
||||
(acl-it-p?
|
||||
(acl-build-db
|
||||
(list
|
||||
(acl-child-of (quote sec) (quote book))
|
||||
(acl-grant (quote u) (quote read) (quote sec))))
|
||||
(quote u)
|
||||
(quote read)
|
||||
(quote book))
|
||||
false)))
|
||||
(let
|
||||
((db (acl-build-db (list (acl-member-of (quote alice) (quote team)) (acl-child-of (quote post1) (quote board)) (acl-grant (quote team) (quote comment) (quote board))))))
|
||||
(do
|
||||
(acl-it-check!
|
||||
"group + resource: member on child resource"
|
||||
(acl-it-p? db (quote alice) (quote comment) (quote post1))
|
||||
true)
|
||||
(acl-it-check!
|
||||
"group + resource: member on parent resource"
|
||||
(acl-it-p? db (quote alice) (quote comment) (quote board))
|
||||
true)))
|
||||
(let
|
||||
((db (acl-build-db (list (acl-member-of (quote bob) (quote editor)) (acl-role-grant (quote editor) (quote edit) (quote page1)) (acl-role-grant (quote editor) (quote read) (quote page1))))))
|
||||
(do
|
||||
(acl-it-check!
|
||||
"role confers edit to member"
|
||||
(acl-it-p? db (quote bob) (quote edit) (quote page1))
|
||||
true)
|
||||
(acl-it-check!
|
||||
"role confers read to member"
|
||||
(acl-it-p? db (quote bob) (quote read) (quote page1))
|
||||
true)
|
||||
(acl-it-check!
|
||||
"role: capability not in tuple denied"
|
||||
(acl-it-p? db (quote bob) (quote moderate) (quote page1))
|
||||
false)
|
||||
(acl-it-check!
|
||||
"role: non-member excluded"
|
||||
(acl-it-p? db (quote eve) (quote edit) (quote page1))
|
||||
false)))
|
||||
(let
|
||||
((db (acl-build-db (list (acl-member-of (quote bob) (quote editor)) (acl-child-of (quote draft) (quote page1)) (acl-role-grant (quote editor) (quote edit) (quote page1))))))
|
||||
(acl-it-check!
|
||||
"role grant flows to child resource"
|
||||
(acl-it-p? db (quote bob) (quote edit) (quote draft))
|
||||
true))
|
||||
(let
|
||||
((db (acl-build-db (list (acl-member-of (quote alice) (quote team)) (acl-grant (quote team) (quote read) (quote doc)) (acl-deny (quote alice) (quote read) (quote doc))))))
|
||||
(acl-it-check!
|
||||
"explicit deny beats inherited group allow"
|
||||
(acl-it-p? db (quote alice) (quote read) (quote doc))
|
||||
false))
|
||||
(let
|
||||
((db (acl-build-db (list (acl-member-of (quote alice) (quote team)) (acl-grant (quote alice) (quote read) (quote doc)) (acl-deny (quote team) (quote read) (quote doc))))))
|
||||
(do
|
||||
(acl-it-check!
|
||||
"group deny inherits and overrides direct grant"
|
||||
(acl-it-p? db (quote alice) (quote read) (quote doc))
|
||||
false)
|
||||
(acl-it-check!
|
||||
"group deny: another member also blocked"
|
||||
(acl-it-p? db (quote team) (quote read) (quote doc))
|
||||
false)))
|
||||
(let
|
||||
((db (acl-build-db (list (acl-child-of (quote sec) (quote book)) (acl-grant (quote u) (quote read) (quote sec)) (acl-deny (quote u) (quote read) (quote book))))))
|
||||
(acl-it-check!
|
||||
"ancestor deny overrides descendant grant"
|
||||
(acl-it-p? db (quote u) (quote read) (quote sec))
|
||||
false))
|
||||
(let
|
||||
((db (acl-build-db (list (acl-member-of (quote alice) (quote team)) (acl-grant (quote team) (quote read) (quote doc)) (acl-grant (quote team) (quote edit) (quote doc)) (acl-deny (quote alice) (quote edit) (quote doc))))))
|
||||
(do
|
||||
(acl-it-check!
|
||||
"deny on edit leaves inherited read intact"
|
||||
(acl-it-p? db (quote alice) (quote read) (quote doc))
|
||||
true)
|
||||
(acl-it-check!
|
||||
"deny on edit blocks edit"
|
||||
(acl-it-p? db (quote alice) (quote edit) (quote doc))
|
||||
false)))
|
||||
(let
|
||||
((db (acl-build-db (list (acl-member-of (quote alice) (quote team)) (acl-deny (quote team) (quote read) (quote doc))))))
|
||||
(acl-it-check!
|
||||
"inherited deny, no grant: denied"
|
||||
(acl-it-p? db (quote alice) (quote read) (quote doc))
|
||||
false))
|
||||
(let
|
||||
((db (acl-build-db (list (acl-child-of (quote a) (quote root)) (acl-child-of (quote b) (quote root)) (acl-grant (quote u) (quote read) (quote root)) (acl-deny (quote u) (quote read) (quote a))))))
|
||||
(do
|
||||
(acl-it-check!
|
||||
"deny on sibling a blocks a"
|
||||
(acl-it-p? db (quote u) (quote read) (quote a))
|
||||
false)
|
||||
(acl-it-check!
|
||||
"deny on sibling a leaves b permitted"
|
||||
(acl-it-p? db (quote u) (quote read) (quote b))
|
||||
true)
|
||||
(acl-it-check!
|
||||
"root itself still permitted"
|
||||
(acl-it-p? db (quote u) (quote read) (quote root))
|
||||
true)))
|
||||
(let
|
||||
((db (acl-build-db (list (acl-grant (quote x) (quote read) (quote y))))))
|
||||
(acl-it-check!
|
||||
"direct grant under inheritance ruleset"
|
||||
(acl-it-p? db (quote x) (quote read) (quote y))
|
||||
true)))))
|
||||
|
||||
(define
|
||||
acl-inherit-tests-run!
|
||||
(fn
|
||||
()
|
||||
(do
|
||||
(set! acl-it-pass 0)
|
||||
(set! acl-it-fail 0)
|
||||
(set! acl-it-failures (list))
|
||||
(acl-it-run-all!)
|
||||
{:failures acl-it-failures :total (+ acl-it-pass acl-it-fail) :passed acl-it-pass :failed acl-it-fail})))
|
||||
63
lib/apl/conformance.conf
Normal file
63
lib/apl/conformance.conf
Normal file
@@ -0,0 +1,63 @@
|
||||
# APL conformance config — sourced by lib/guest/conformance.sh.
|
||||
|
||||
LANG_NAME=apl
|
||||
MODE=counters
|
||||
COUNTERS_PASS=apl-test-pass
|
||||
COUNTERS_FAIL=apl-test-fail
|
||||
TIMEOUT_PER_SUITE=300
|
||||
|
||||
PRELOADS=(
|
||||
spec/stdlib.sx
|
||||
lib/r7rs.sx
|
||||
lib/apl/runtime.sx
|
||||
lib/apl/tokenizer.sx
|
||||
lib/apl/parser.sx
|
||||
lib/apl/transpile.sx
|
||||
lib/apl/test-harness.sx
|
||||
)
|
||||
|
||||
SUITES=(
|
||||
"structural:lib/apl/tests/structural.sx"
|
||||
"operators:lib/apl/tests/operators.sx"
|
||||
"dfn:lib/apl/tests/dfn.sx"
|
||||
"tradfn:lib/apl/tests/tradfn.sx"
|
||||
"valence:lib/apl/tests/valence.sx"
|
||||
"programs:lib/apl/tests/programs.sx"
|
||||
"system:lib/apl/tests/system.sx"
|
||||
"idioms:lib/apl/tests/idioms.sx"
|
||||
"eval-ops:lib/apl/tests/eval-ops.sx"
|
||||
"pipeline:lib/apl/tests/pipeline.sx"
|
||||
)
|
||||
|
||||
emit_scoreboard_json() {
|
||||
local n=${#GC_NAMES[@]} i sep
|
||||
printf '{\n'
|
||||
printf ' "suites": {\n'
|
||||
for ((i=0; i<n; i++)); do
|
||||
sep=","; [ $i -eq $((n-1)) ] && sep=""
|
||||
printf ' "%s": {"pass": %d, "fail": %d}%s\n' \
|
||||
"${GC_NAMES[$i]}" "${GC_PASS[$i]}" "${GC_FAIL[$i]}" "$sep"
|
||||
done
|
||||
printf ' },\n'
|
||||
printf ' "total_pass": %d,\n' "$GC_TOTAL_PASS"
|
||||
printf ' "total_fail": %d,\n' "$GC_TOTAL_FAIL"
|
||||
printf ' "total": %d\n' "$GC_TOTAL"
|
||||
printf '}\n'
|
||||
}
|
||||
|
||||
emit_scoreboard_md() {
|
||||
local n=${#GC_NAMES[@]} i
|
||||
printf '# APL Conformance Scoreboard\n\n'
|
||||
printf '_Generated by `lib/apl/conformance.sh`_\n\n'
|
||||
printf '| Suite | Pass | Fail | Total |\n'
|
||||
printf '|-------|-----:|-----:|------:|\n'
|
||||
for ((i=0; i<n; i++)); do
|
||||
printf '| %s | %d | %d | %d |\n' \
|
||||
"${GC_NAMES[$i]}" "${GC_PASS[$i]}" "${GC_FAIL[$i]}" "${GC_TOTAL_S[$i]}"
|
||||
done
|
||||
printf '| **Total** | **%d** | **%d** | **%d** |\n' "$GC_TOTAL_PASS" "$GC_TOTAL_FAIL" "$GC_TOTAL"
|
||||
printf '\n'
|
||||
printf '## Notes\n\n'
|
||||
printf '%s\n' '- Suites use the standard `apl-test name got expected` framework loaded against `lib/apl/runtime.sx` + `lib/apl/transpile.sx`.'
|
||||
printf '%s\n' '- `lib/apl/tests/parse.sx` and `lib/apl/tests/scalar.sx` use their own self-contained frameworks and are excluded from this scoreboard.'
|
||||
}
|
||||
@@ -1,116 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
# lib/apl/conformance.sh — run APL test suites, emit scoreboard.json + scoreboard.md.
|
||||
|
||||
set -uo pipefail
|
||||
cd "$(git rev-parse --show-toplevel)"
|
||||
|
||||
SX_SERVER="${SX_SERVER:-/root/rose-ash/hosts/ocaml/_build/default/bin/sx_server.exe}"
|
||||
if [ ! -x "$SX_SERVER" ]; then
|
||||
SX_SERVER="hosts/ocaml/_build/default/bin/sx_server.exe"
|
||||
fi
|
||||
if [ ! -x "$SX_SERVER" ]; then
|
||||
echo "ERROR: sx_server.exe not found." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SUITES=(structural operators dfn tradfn valence programs system idioms eval-ops pipeline)
|
||||
|
||||
OUT_JSON="lib/apl/scoreboard.json"
|
||||
OUT_MD="lib/apl/scoreboard.md"
|
||||
|
||||
run_suite() {
|
||||
local suite=$1
|
||||
local file="lib/apl/tests/${suite}.sx"
|
||||
local TMP
|
||||
TMP=$(mktemp)
|
||||
cat > "$TMP" << EPOCHS
|
||||
(epoch 1)
|
||||
(load "spec/stdlib.sx")
|
||||
(load "lib/r7rs.sx")
|
||||
(load "lib/apl/runtime.sx")
|
||||
(load "lib/apl/tokenizer.sx")
|
||||
(load "lib/apl/parser.sx")
|
||||
(load "lib/apl/transpile.sx")
|
||||
(epoch 2)
|
||||
(eval "(define apl-test-pass 0)")
|
||||
(eval "(define apl-test-fail 0)")
|
||||
(eval "(define apl-test (fn (name got expected) (if (= got expected) (set! apl-test-pass (+ apl-test-pass 1)) (set! apl-test-fail (+ apl-test-fail 1)))))")
|
||||
(epoch 3)
|
||||
(load "${file}")
|
||||
(epoch 4)
|
||||
(eval "(list apl-test-pass apl-test-fail)")
|
||||
EPOCHS
|
||||
|
||||
local OUTPUT
|
||||
OUTPUT=$(timeout 300 "$SX_SERVER" < "$TMP" 2>/dev/null)
|
||||
rm -f "$TMP"
|
||||
|
||||
local LINE
|
||||
LINE=$(echo "$OUTPUT" | awk '/^\(ok-len 4 / {getline; print; exit}')
|
||||
if [ -z "$LINE" ]; then
|
||||
LINE=$(echo "$OUTPUT" | grep -E '^\(ok 4 \([0-9]+ [0-9]+\)\)' | tail -1 \
|
||||
| sed -E 's/^\(ok 4 //; s/\)$//')
|
||||
fi
|
||||
|
||||
local P F
|
||||
P=$(echo "$LINE" | sed -E 's/^\(([0-9]+) ([0-9]+)\).*/\1/')
|
||||
F=$(echo "$LINE" | sed -E 's/^\(([0-9]+) ([0-9]+)\).*/\2/')
|
||||
P=${P:-0}
|
||||
F=${F:-0}
|
||||
echo "${P} ${F}"
|
||||
}
|
||||
|
||||
declare -A SUITE_PASS
|
||||
declare -A SUITE_FAIL
|
||||
TOTAL_PASS=0
|
||||
TOTAL_FAIL=0
|
||||
|
||||
echo "Running APL conformance suite..." >&2
|
||||
for s in "${SUITES[@]}"; do
|
||||
read -r p f < <(run_suite "$s")
|
||||
SUITE_PASS[$s]=$p
|
||||
SUITE_FAIL[$s]=$f
|
||||
TOTAL_PASS=$((TOTAL_PASS + p))
|
||||
TOTAL_FAIL=$((TOTAL_FAIL + f))
|
||||
printf " %-12s %d/%d\n" "$s" "$p" "$((p+f))" >&2
|
||||
done
|
||||
|
||||
# scoreboard.json
|
||||
{
|
||||
printf '{\n'
|
||||
printf ' "suites": {\n'
|
||||
first=1
|
||||
for s in "${SUITES[@]}"; do
|
||||
if [ $first -eq 0 ]; then printf ',\n'; fi
|
||||
printf ' "%s": {"pass": %d, "fail": %d}' "$s" "${SUITE_PASS[$s]}" "${SUITE_FAIL[$s]}"
|
||||
first=0
|
||||
done
|
||||
printf '\n },\n'
|
||||
printf ' "total_pass": %d,\n' "$TOTAL_PASS"
|
||||
printf ' "total_fail": %d,\n' "$TOTAL_FAIL"
|
||||
printf ' "total": %d\n' "$((TOTAL_PASS + TOTAL_FAIL))"
|
||||
printf '}\n'
|
||||
} > "$OUT_JSON"
|
||||
|
||||
# scoreboard.md
|
||||
{
|
||||
printf '# APL Conformance Scoreboard\n\n'
|
||||
printf '_Generated by `lib/apl/conformance.sh`_\n\n'
|
||||
printf '| Suite | Pass | Fail | Total |\n'
|
||||
printf '|-------|-----:|-----:|------:|\n'
|
||||
for s in "${SUITES[@]}"; do
|
||||
p=${SUITE_PASS[$s]}
|
||||
f=${SUITE_FAIL[$s]}
|
||||
printf '| %s | %d | %d | %d |\n' "$s" "$p" "$f" "$((p+f))"
|
||||
done
|
||||
printf '| **Total** | **%d** | **%d** | **%d** |\n' "$TOTAL_PASS" "$TOTAL_FAIL" "$((TOTAL_PASS + TOTAL_FAIL))"
|
||||
printf '\n'
|
||||
printf '## Notes\n\n'
|
||||
printf '%s\n' '- Suites use the standard `apl-test name got expected` framework loaded against `lib/apl/runtime.sx` + `lib/apl/transpile.sx`.'
|
||||
printf '%s\n' '- `lib/apl/tests/parse.sx` and `lib/apl/tests/scalar.sx` use their own self-contained frameworks and are excluded from this scoreboard.'
|
||||
} > "$OUT_MD"
|
||||
|
||||
echo "Wrote $OUT_JSON and $OUT_MD" >&2
|
||||
echo "Total: $TOTAL_PASS pass, $TOTAL_FAIL fail" >&2
|
||||
|
||||
[ "$TOTAL_FAIL" -eq 0 ]
|
||||
# lib/apl/conformance.sh — APL conformance via the shared guest driver.
|
||||
# Config lives in lib/apl/conformance.conf (MODE=counters). Override the binary
|
||||
# with SX_SERVER=path/to/sx_server.exe bash lib/apl/conformance.sh
|
||||
exec bash "$(dirname "$0")/../guest/conformance.sh" "$(dirname "$0")/conformance.conf" "$@"
|
||||
|
||||
@@ -9,9 +9,9 @@
|
||||
"system": {"pass": 13, "fail": 0},
|
||||
"idioms": {"pass": 64, "fail": 0},
|
||||
"eval-ops": {"pass": 14, "fail": 0},
|
||||
"pipeline": {"pass": 40, "fail": 0}
|
||||
"pipeline": {"pass": 152, "fail": 0}
|
||||
},
|
||||
"total_pass": 450,
|
||||
"total_pass": 562,
|
||||
"total_fail": 0,
|
||||
"total": 450
|
||||
"total": 562
|
||||
}
|
||||
|
||||
@@ -13,8 +13,8 @@ _Generated by `lib/apl/conformance.sh`_
|
||||
| system | 13 | 0 | 13 |
|
||||
| idioms | 64 | 0 | 64 |
|
||||
| eval-ops | 14 | 0 | 14 |
|
||||
| pipeline | 40 | 0 | 40 |
|
||||
| **Total** | **450** | **0** | **450** |
|
||||
| pipeline | 152 | 0 | 152 |
|
||||
| **Total** | **562** | **0** | **562** |
|
||||
|
||||
## Notes
|
||||
|
||||
|
||||
15
lib/apl/test-harness.sx
Normal file
15
lib/apl/test-harness.sx
Normal file
@@ -0,0 +1,15 @@
|
||||
; lib/apl/test-harness.sx — counters + assertion fn for the shared conformance
|
||||
; driver (lib/guest/conformance.sh, MODE=counters). Loaded as a PRELOAD so each
|
||||
; suite starts from a fresh 0/0; suites call (apl-test name got expected).
|
||||
|
||||
(define apl-test-pass 0)
|
||||
(define apl-test-fail 0)
|
||||
|
||||
(define
|
||||
apl-test
|
||||
(fn
|
||||
(name got expected)
|
||||
(if
|
||||
(= got expected)
|
||||
(set! apl-test-pass (+ apl-test-pass 1))
|
||||
(set! apl-test-fail (+ apl-test-fail 1)))))
|
||||
251
lib/events/api.sx
Normal file
251
lib/events/api.sx
Normal file
@@ -0,0 +1,251 @@
|
||||
;; lib/events/api.sx — public events surface over calendar + availability.
|
||||
;;
|
||||
;; A `store` is an immutable value holding scheduled events and (in-memory)
|
||||
;; bookings:
|
||||
;;
|
||||
;; {:events (event ...) :bookings ((actor key) ...)}
|
||||
;;
|
||||
;; The in-memory `:bookings` list supports pure, value-level queries. The
|
||||
;; DURABLE booking path (ev/*-occ! and ev/*-p) keeps bookings in persist
|
||||
;; streams via booking.sx — capacity-safe, cancellable, replayable — and
|
||||
;; derives availability from those streams. Use the persist path for real
|
||||
;; bookings; the in-memory path for projections and tests.
|
||||
;;
|
||||
;; All queries are windowed: agenda/free/next-free expand recurring events into
|
||||
;; concrete occurrences within an explicit (or derived) window before running
|
||||
;; the Datalog availability rules.
|
||||
|
||||
(define ev/store (fn (events bookings) {:bookings bookings :events events}))
|
||||
|
||||
(define ev/empty (fn () (ev/store (list) (list))))
|
||||
|
||||
(define ev/events (fn (store) (get store :events)))
|
||||
(define ev/bookings (fn (store) (get store :bookings)))
|
||||
|
||||
;; Add a (constructed) event to the store.
|
||||
(define
|
||||
ev/add-event
|
||||
(fn
|
||||
(store event)
|
||||
(ev/store (cons event (ev/events store)) (ev/bookings store))))
|
||||
|
||||
;; Schedule a fresh event from parts, returning the updated store. rrule may be
|
||||
;; nil for a one-off. (Booking is separate — see ev/book.)
|
||||
(define
|
||||
ev/schedule
|
||||
(fn
|
||||
(store id dtstart duration rrule capacity)
|
||||
(ev/add-event store (ev-event id dtstart duration rrule capacity))))
|
||||
|
||||
;; Record that `actor` holds the occurrence with `key` (in-memory only — see
|
||||
;; ev/book-occ! for the durable, capacity-safe path).
|
||||
(define
|
||||
ev/book
|
||||
(fn
|
||||
(store actor key)
|
||||
(ev/store
|
||||
(ev/events store)
|
||||
(cons (list actor key) (ev/bookings store)))))
|
||||
|
||||
;; The event with `id`, or nil.
|
||||
(define
|
||||
ev/event-by-id
|
||||
(fn
|
||||
(store id)
|
||||
(reduce
|
||||
(fn
|
||||
(found ev)
|
||||
(if (nil? found) (if (= (get ev :id) id) ev found) found))
|
||||
nil
|
||||
(ev/events store))))
|
||||
|
||||
;; Capacity of the event an occurrence belongs to (0 if unknown).
|
||||
(define
|
||||
ev/capacity-of
|
||||
(fn
|
||||
(store occ)
|
||||
(let
|
||||
((ev (ev/event-by-id store (get occ :id))))
|
||||
(if (nil? ev) 0 (get ev :capacity)))))
|
||||
|
||||
;; The maximum event duration in the store (0 when empty) — used to widen
|
||||
;; expansion windows so any occurrence overlapping a query is captured.
|
||||
(define
|
||||
ev/store-max-duration
|
||||
(fn
|
||||
(store)
|
||||
(reduce
|
||||
(fn (m ev) (max m (get ev :duration)))
|
||||
0
|
||||
(ev/events store))))
|
||||
|
||||
;; All occurrences across all events within [ws, we), ascending by start.
|
||||
(define
|
||||
ev/agenda
|
||||
(fn (store ws we) (ev-expand-all (ev/events store) ws we)))
|
||||
|
||||
(define
|
||||
ev-key-member?
|
||||
(fn
|
||||
(k keys)
|
||||
(cond
|
||||
((empty? keys) false)
|
||||
((= k (first keys)) true)
|
||||
(else (ev-key-member? k (rest keys))))))
|
||||
|
||||
;; Occurrence keys `actor` has booked (in-memory store).
|
||||
(define
|
||||
ev/actor-keys
|
||||
(fn
|
||||
(store actor)
|
||||
(reduce
|
||||
(fn
|
||||
(acc b)
|
||||
(if (= (first b) actor) (cons (first (rest b)) acc) acc))
|
||||
(list)
|
||||
(ev/bookings store))))
|
||||
|
||||
;; The agenda restricted to occurrences `actor` is booked into (in-memory).
|
||||
(define
|
||||
ev/agenda-for
|
||||
(fn
|
||||
(store actor ws we)
|
||||
(let
|
||||
((keys (ev/actor-keys store actor)))
|
||||
(filter
|
||||
(fn (o) (ev-key-member? (ev-occ-key o) keys))
|
||||
(ev/agenda store ws we)))))
|
||||
|
||||
;; Build an availability db over occurrences expanded in [ws, we) using the
|
||||
;; in-memory bookings.
|
||||
(define
|
||||
ev/avail-window-db
|
||||
(fn
|
||||
(store ws we)
|
||||
(ev-avail-db (ev/agenda store ws we) (ev/bookings store))))
|
||||
|
||||
;; Is `actor` free across [qs, qe)? Expands a window wide enough (back by the
|
||||
;; longest event) to capture any occurrence that could overlap.
|
||||
(define
|
||||
ev/free?
|
||||
(fn
|
||||
(store actor qs qe)
|
||||
(ev-free?
|
||||
(ev/avail-window-db store (- qs (ev/store-max-duration store)) qe)
|
||||
actor
|
||||
qs
|
||||
qe)))
|
||||
|
||||
;; Earliest free slot of `duration` for `actor` in [after, horizon), or nil.
|
||||
(define
|
||||
ev/next-free
|
||||
(fn
|
||||
(store actor after duration horizon)
|
||||
(ev-next-free
|
||||
(ev/avail-window-db
|
||||
store
|
||||
(- after (ev/store-max-duration store))
|
||||
horizon)
|
||||
actor
|
||||
after
|
||||
duration
|
||||
horizon)))
|
||||
|
||||
;; Overlapping double-bookings for `actor` among occurrences in [ws, we).
|
||||
(define
|
||||
ev/conflicts
|
||||
(fn
|
||||
(store actor ws we)
|
||||
(ev-conflicts (ev/avail-window-db store ws we) actor)))
|
||||
|
||||
(define
|
||||
ev/has-conflict?
|
||||
(fn
|
||||
(store actor ws we)
|
||||
(> (len (ev/conflicts store actor ws we)) 0)))
|
||||
|
||||
;; ---- durable, persist-backed booking path ----
|
||||
;; These take a persist backend `b` (persist/open) plus the schedule `store`.
|
||||
;; Bookings live in per-occurrence streams (booking.sx); availability is derived
|
||||
;; by replaying those streams for the occurrences in the query window.
|
||||
|
||||
;; Durably book `actor` into occurrence `occ` (dict {:id :start :end}),
|
||||
;; capacity-safe. Returns the booking.sx result (:booked / :full / :already).
|
||||
(define
|
||||
ev/book-occ!
|
||||
(fn
|
||||
(b store actor occ)
|
||||
(ev/book! b (ev-occ-key occ) (ev/capacity-of store occ) actor)))
|
||||
|
||||
;; Durably cancel `actor`'s seat on `occ`, freeing capacity.
|
||||
(define
|
||||
ev/cancel-occ!
|
||||
(fn (b store actor occ) (ev/cancel! b (ev-occ-key occ) actor)))
|
||||
|
||||
;; Live roster / seats-left for a specific occurrence from persist.
|
||||
(define ev/roster-occ (fn (b occ) (ev/roster b (ev-occ-key occ))))
|
||||
|
||||
(define
|
||||
ev/seats-left-occ
|
||||
(fn
|
||||
(b store occ)
|
||||
(ev/seats-left b (ev-occ-key occ) (ev/capacity-of store occ))))
|
||||
|
||||
;; Derive (actor key) booking pairs from the persist rosters of `occs`.
|
||||
(define
|
||||
ev/persist-bookings
|
||||
(fn
|
||||
(b occs)
|
||||
(reduce
|
||||
(fn
|
||||
(acc occ)
|
||||
(let
|
||||
((key (ev-occ-key occ)))
|
||||
(append
|
||||
acc
|
||||
(map (fn (actor) (list actor key)) (ev/roster b key)))))
|
||||
(list)
|
||||
occs)))
|
||||
|
||||
;; Availability db over [ws, we) with bookings sourced from persist streams.
|
||||
(define
|
||||
ev/avail-db-p
|
||||
(fn
|
||||
(b store ws we)
|
||||
(let
|
||||
((occs (ev/agenda store ws we)))
|
||||
(ev-avail-db occs (ev/persist-bookings b occs)))))
|
||||
|
||||
;; Persist-backed availability queries (mirror the in-memory ev/free? etc).
|
||||
(define
|
||||
ev/free-p?
|
||||
(fn
|
||||
(b store actor qs qe)
|
||||
(ev-free?
|
||||
(ev/avail-db-p b store (- qs (ev/store-max-duration store)) qe)
|
||||
actor
|
||||
qs
|
||||
qe)))
|
||||
|
||||
(define
|
||||
ev/next-free-p
|
||||
(fn
|
||||
(b store actor after duration horizon)
|
||||
(ev-next-free
|
||||
(ev/avail-db-p b store (- after (ev/store-max-duration store)) horizon)
|
||||
actor
|
||||
after
|
||||
duration
|
||||
horizon)))
|
||||
|
||||
(define
|
||||
ev/conflicts-p
|
||||
(fn
|
||||
(b store actor ws we)
|
||||
(ev-conflicts (ev/avail-db-p b store ws we) actor)))
|
||||
|
||||
(define
|
||||
ev/has-conflict-p?
|
||||
(fn
|
||||
(b store actor ws we)
|
||||
(> (len (ev/conflicts-p b store actor ws we)) 0)))
|
||||
177
lib/events/availability.sx
Normal file
177
lib/events/availability.sx
Normal file
@@ -0,0 +1,177 @@
|
||||
;; lib/events/availability.sx — free/busy + conflict detection on Datalog.
|
||||
;;
|
||||
;; Availability is per-actor and is forward-chained Datalog over two EDB
|
||||
;; relations:
|
||||
;;
|
||||
;; (occurrence Key EventId Start End) ; an expanded calendar occurrence
|
||||
;; (booking Actor Key) ; actor attends/holds that occurrence
|
||||
;;
|
||||
;; The derived relations are the whole policy:
|
||||
;;
|
||||
;; busy(A,S,E) — A is committed for [S,E) (a booked occurrence)
|
||||
;; conflict(A,O1,O2) — A double-booked into two overlapping occurrences
|
||||
;; busy_in(A,QS,QE) — A is busy somewhere inside query window [QS,QE)
|
||||
;;
|
||||
;; Intervals are half-open [Start,End) in epoch minutes (see calendar.sx), so
|
||||
;; adjacent slots (E == next start) do NOT conflict. Conflict pairs are
|
||||
;; canonical (O1 < O2 by key) so each overlap is reported once. The same `busy`
|
||||
;; rule answers "is A free in [QS,QE)?" (busy_in is empty) and feeds "when is A
|
||||
;; next free?" (ev-next-free probes candidate slots with the same rule).
|
||||
|
||||
;; A stable key for an occurrence dict {:id :start :end}.
|
||||
(define ev-occ-key (fn (occ) (str (get occ :id) "@" (get occ :start))))
|
||||
|
||||
(define
|
||||
ev-occurrence-fact
|
||||
(fn
|
||||
(occ)
|
||||
(list
|
||||
(quote occurrence)
|
||||
(ev-occ-key occ)
|
||||
(get occ :id)
|
||||
(get occ :start)
|
||||
(get occ :end))))
|
||||
|
||||
(define ev-occurrence-facts (fn (occs) (map ev-occurrence-fact occs)))
|
||||
|
||||
(define ev-booking-fact (fn (actor key) (list (quote booking) actor key)))
|
||||
|
||||
(define ev-qwindow-fact (fn (qs qe) (list (quote qwindow) qs qe)))
|
||||
|
||||
;; Range restriction: each comparison's variables are bound by an earlier
|
||||
;; positive literal (qwindow / busy precede the < tests). Conflict uses
|
||||
;; (< O1 O2) on the keys so each overlapping pair is reported once.
|
||||
(define
|
||||
ev-avail-rules
|
||||
(quote
|
||||
((busy A S E <- (booking A O) (occurrence O _ S E))
|
||||
(conflict
|
||||
A
|
||||
O1
|
||||
O2
|
||||
<-
|
||||
(booking A O1)
|
||||
(booking A O2)
|
||||
(occurrence O1 _ S1 E1)
|
||||
(occurrence O2 _ S2 E2)
|
||||
(< O1 O2)
|
||||
(< S1 E2)
|
||||
(< S2 E1))
|
||||
(busy_in A QS QE <- (qwindow QS QE) (busy A S E) (< S QE) (< QS E)))))
|
||||
|
||||
;; Build a Datalog db from EDB facts under the availability ruleset.
|
||||
(define ev-build-avail (fn (facts) (dl-program-data facts ev-avail-rules)))
|
||||
|
||||
;; Convenience: build a db from occurrence dicts + booking pairs.
|
||||
;; bookings is a list of (actor key) pairs.
|
||||
(define
|
||||
ev-avail-db
|
||||
(fn
|
||||
(occs bookings)
|
||||
(ev-build-avail
|
||||
(append
|
||||
(ev-occurrence-facts occs)
|
||||
(map
|
||||
(fn (b) (ev-booking-fact (first b) (first (rest b))))
|
||||
bookings)))))
|
||||
|
||||
;; Helper: insertion sort a list of (S E ...) lists ascending by S then E.
|
||||
(define
|
||||
ev-list-before?
|
||||
(fn
|
||||
(a b)
|
||||
(cond
|
||||
((< (first a) (first b)) true)
|
||||
((> (first a) (first b)) false)
|
||||
(else (< (first (rest a)) (first (rest b)))))))
|
||||
|
||||
(define
|
||||
ev-list-insert
|
||||
(fn
|
||||
(x sorted)
|
||||
(cond
|
||||
((empty? sorted) (list x))
|
||||
((ev-list-before? x (first sorted)) (cons x sorted))
|
||||
(else (cons (first sorted) (ev-list-insert x (rest sorted)))))))
|
||||
|
||||
(define
|
||||
ev-sort-lists
|
||||
(fn (xs) (reduce (fn (acc x) (ev-list-insert x acc)) (list) xs)))
|
||||
|
||||
(define
|
||||
ev-dedup-sorted
|
||||
(fn
|
||||
(xs)
|
||||
(cond
|
||||
((empty? xs) xs)
|
||||
((empty? (rest xs)) xs)
|
||||
((= (first xs) (first (rest xs))) (ev-dedup-sorted (rest xs)))
|
||||
(else (cons (first xs) (ev-dedup-sorted (rest xs)))))))
|
||||
|
||||
;; All busy intervals (list S E) for an actor, ascending by start.
|
||||
(define
|
||||
ev-busy
|
||||
(fn
|
||||
(db actor)
|
||||
(let
|
||||
((rows (dl-query db (list (quote busy) actor (quote S) (quote E)))))
|
||||
(ev-sort-lists (map (fn (b) (list (get b :S) (get b :E))) rows)))))
|
||||
|
||||
;; Distinct conflicting occurrence-key pairs for an actor (each pair once).
|
||||
(define
|
||||
ev-conflicts
|
||||
(fn
|
||||
(db actor)
|
||||
(dl-query db (list (quote conflict) actor (quote O1) (quote O2)))))
|
||||
|
||||
(define
|
||||
ev-has-conflict?
|
||||
(fn (db actor) (> (len (ev-conflicts db actor)) 0)))
|
||||
|
||||
;; Is `actor` free across the whole window [qs,qe)? (no booked occurrence
|
||||
;; overlaps it). Asserts a transient qwindow fact, queries, retracts.
|
||||
(define
|
||||
ev-free?
|
||||
(fn
|
||||
(db actor qs qe)
|
||||
(do
|
||||
(dl-assert! db (ev-qwindow-fact qs qe))
|
||||
(let
|
||||
((rows (dl-query db (list (quote busy_in) actor (quote QS) (quote QE)))))
|
||||
(begin (dl-retract! db (ev-qwindow-fact qs qe)) (empty? rows))))))
|
||||
|
||||
;; ---- next-free slot search ----
|
||||
;; The earliest start s >= `after` such that [s, s+duration) is entirely free
|
||||
;; for `actor` and ends at or before `horizon`, or nil if none. The earliest
|
||||
;; such slot must begin either at `after` or immediately after some busy
|
||||
;; interval ends (classic interval packing), so those are the only candidates
|
||||
;; we probe — each probe reuses the busy_in rule via ev-free?.
|
||||
|
||||
(define
|
||||
ev-first-free
|
||||
(fn
|
||||
(db actor cands duration horizon)
|
||||
(cond
|
||||
((empty? cands) nil)
|
||||
(else
|
||||
(let
|
||||
((s (first cands)))
|
||||
(if
|
||||
(and
|
||||
(<= (+ s duration) horizon)
|
||||
(ev-free? db actor s (+ s duration)))
|
||||
s
|
||||
(ev-first-free db actor (rest cands) duration horizon)))))))
|
||||
|
||||
(define
|
||||
ev-next-free
|
||||
(fn
|
||||
(db actor after duration horizon)
|
||||
(let
|
||||
((ends (filter (fn (e) (>= e after)) (map (fn (iv) (first (rest iv))) (ev-busy db actor)))))
|
||||
(ev-first-free
|
||||
db
|
||||
actor
|
||||
(ev-dedup-sorted (sort (cons after ends)))
|
||||
duration
|
||||
horizon))))
|
||||
271
lib/events/booking.sx
Normal file
271
lib/events/booking.sx
Normal file
@@ -0,0 +1,271 @@
|
||||
;; lib/events/booking.sx — transactional, capacity-safe booking on persist.
|
||||
;;
|
||||
;; Each bookable occurrence has an append-only stream of booking events:
|
||||
;;
|
||||
;; :booking free booking — actor immediately holds a confirmed seat
|
||||
;; :hold provisional hold — seat reserved while payment is pending
|
||||
;; :confirm a held seat becomes confirmed (payment succeeded)
|
||||
;; :release a held seat is abandoned (payment failed/expired) — seat freed
|
||||
;; :cancel a held or confirmed seat is given up — seat freed
|
||||
;;
|
||||
;; The live state is the stream FOLDED in order into per-actor seat states
|
||||
;; (:held / :confirmed); an actor in ANY state occupies a seat, so both held and
|
||||
;; confirmed seats count toward capacity — a pending payment cannot be
|
||||
;; oversold. A freed seat (release/cancel) reopens capacity.
|
||||
;;
|
||||
;; Capacity safety is the contract: two writers racing for the last seat must
|
||||
;; NEVER both succeed. Seat-ACQUIRING writes (:booking, :hold) go through
|
||||
;; persist's optimistic concurrency — `persist/append-expect` appends only if
|
||||
;; the stream's last-seq still equals what the writer observed; else it returns
|
||||
;; a conflict the writer retries. Seat-FREEING writes (:cancel, :release) and
|
||||
;; the state transition (:confirm) never oversell, so they append directly.
|
||||
|
||||
(define ev-booking-stream (fn (occ-key) (str "booking:" occ-key)))
|
||||
|
||||
(define
|
||||
ev-bk-member?
|
||||
(fn
|
||||
(x xs)
|
||||
(cond
|
||||
((empty? xs) false)
|
||||
((= x (first xs)) true)
|
||||
(else (ev-bk-member? x (rest xs))))))
|
||||
|
||||
(define
|
||||
ev-bk-index
|
||||
(fn
|
||||
(xs x i)
|
||||
(cond
|
||||
((empty? xs) -1)
|
||||
((= (first xs) x) i)
|
||||
(else (ev-bk-index (rest xs) x (+ i 1))))))
|
||||
|
||||
;; ---- per-actor state association list: ((actor state) ...) in join order ----
|
||||
|
||||
(define
|
||||
ev-state-has?
|
||||
(fn
|
||||
(states actor)
|
||||
(cond
|
||||
((empty? states) false)
|
||||
((= (first (first states)) actor) true)
|
||||
(else (ev-state-has? (rest states) actor)))))
|
||||
|
||||
(define
|
||||
ev-state-get
|
||||
(fn
|
||||
(states actor)
|
||||
(cond
|
||||
((empty? states) :none)
|
||||
((= (first (first states)) actor) (first (rest (first states))))
|
||||
(else (ev-state-get (rest states) actor)))))
|
||||
|
||||
(define
|
||||
ev-state-del
|
||||
(fn (states actor) (filter (fn (p) (not (= (first p) actor))) states)))
|
||||
|
||||
(define
|
||||
ev-state-set
|
||||
(fn
|
||||
(states actor st)
|
||||
(if
|
||||
(ev-state-has? states actor)
|
||||
(map (fn (p) (if (= (first p) actor) (list actor st) p)) states)
|
||||
(append states (list (list actor st))))))
|
||||
|
||||
;; Fold the booking stream into per-actor seat states (join order preserved).
|
||||
(define
|
||||
ev-fold-states
|
||||
(fn
|
||||
(events)
|
||||
(reduce
|
||||
(fn
|
||||
(acc e)
|
||||
(let
|
||||
((typ (persist/event-type e))
|
||||
(actor (get (persist/event-data e) :actor)))
|
||||
(cond
|
||||
((= typ :booking) (ev-state-set acc actor :confirmed))
|
||||
((= typ :hold) (ev-state-set acc actor :held))
|
||||
((= typ :confirm)
|
||||
(if
|
||||
(ev-state-has? acc actor)
|
||||
(ev-state-set acc actor :confirmed)
|
||||
acc))
|
||||
((= typ :cancel) (ev-state-del acc actor))
|
||||
((= typ :release) (ev-state-del acc actor))
|
||||
(else acc))))
|
||||
(list)
|
||||
events)))
|
||||
|
||||
(define
|
||||
ev-states-of
|
||||
(fn
|
||||
(b occ-key)
|
||||
(ev-fold-states (persist/read b (ev-booking-stream occ-key)))))
|
||||
|
||||
;; Live roster (actors holding a seat — held or confirmed), oldest active first.
|
||||
(define
|
||||
ev-booked-actors
|
||||
(fn (b occ-key) (map (fn (p) (first p)) (ev-states-of b occ-key))))
|
||||
|
||||
(define
|
||||
ev-actor-booked?
|
||||
(fn (b occ-key actor) (ev-bk-member? actor (ev-booked-actors b occ-key))))
|
||||
|
||||
;; Live seat count (folded roster size — both held and confirmed seats).
|
||||
(define
|
||||
ev-booking-count
|
||||
(fn (b occ-key) (len (ev-booked-actors b occ-key))))
|
||||
|
||||
;; Seat state for an actor: :held / :confirmed / :none.
|
||||
(define
|
||||
ev/seat-state
|
||||
(fn (b occ-key actor) (ev-state-get (ev-states-of b occ-key) actor)))
|
||||
|
||||
;; 1-based seat number for an actor on the roster (0 if not booked).
|
||||
(define
|
||||
ev-seat-of
|
||||
(fn
|
||||
(actors actor)
|
||||
(let
|
||||
((i (ev-bk-index actors actor 0)))
|
||||
(if (< i 0) 0 (+ i 1)))))
|
||||
|
||||
;; ---- seat-acquiring writes (capacity-guarded via append-expect) ----
|
||||
|
||||
;; One seat-acquiring attempt of `kind` (:booking or :hold) against an OBSERVED
|
||||
;; snapshot (roster the writer saw + the last-seq). Returns :already / :full /
|
||||
;; :conflict, or a success dict tagged with `ok-status`. :conflict means a
|
||||
;; concurrent append landed since the snapshot — the caller must re-observe.
|
||||
(define
|
||||
ev-acquire-with-observed
|
||||
(fn
|
||||
(b occ-key capacity actor observed-actors expected kind ok-status)
|
||||
(cond
|
||||
((ev-bk-member? actor observed-actors) {:seat (ev-seat-of observed-actors actor) :actor actor :status :already})
|
||||
((>= (len observed-actors) capacity) {:actor actor :capacity capacity :status :full})
|
||||
(else
|
||||
(let
|
||||
((r (persist/append-expect b (ev-booking-stream occ-key) expected kind 0 {:actor actor})))
|
||||
(if (persist/conflict? r) {:actual (persist/conflict-actual r) :actor actor :status :conflict} {:seat (+ (len observed-actors) 1) :actor actor :status ok-status}))))))
|
||||
|
||||
(define
|
||||
ev-acquire!
|
||||
(fn
|
||||
(b occ-key capacity actor kind ok-status)
|
||||
(let
|
||||
((res (ev-acquire-with-observed b occ-key capacity actor (ev-booked-actors b occ-key) (persist/last-seq b (ev-booking-stream occ-key)) kind ok-status)))
|
||||
(if
|
||||
(= (get res :status) :conflict)
|
||||
(ev-acquire! b occ-key capacity actor kind ok-status)
|
||||
res))))
|
||||
|
||||
;; Capacity-safe confirmed booking (retrying on conflict).
|
||||
(define
|
||||
ev/book!
|
||||
(fn
|
||||
(b occ-key capacity actor)
|
||||
(ev-acquire! b occ-key capacity actor :booking :booked)))
|
||||
|
||||
;; Capacity-safe provisional hold (retrying on conflict). The seat is reserved
|
||||
;; (counts toward capacity) until confirmed or released.
|
||||
(define
|
||||
ev/hold!
|
||||
(fn
|
||||
(b occ-key capacity actor)
|
||||
(ev-acquire! b occ-key capacity actor :hold :held)))
|
||||
|
||||
;; Test seam: one attempt against a caller-supplied snapshot (book or hold).
|
||||
(define
|
||||
ev/book-with-observed
|
||||
(fn
|
||||
(b occ-key capacity actor observed-actors expected)
|
||||
(ev-acquire-with-observed
|
||||
b
|
||||
occ-key
|
||||
capacity
|
||||
actor
|
||||
observed-actors
|
||||
expected
|
||||
:booking :booked)))
|
||||
|
||||
(define
|
||||
ev/hold-with-observed
|
||||
(fn
|
||||
(b occ-key capacity actor observed-actors expected)
|
||||
(ev-acquire-with-observed
|
||||
b
|
||||
occ-key
|
||||
capacity
|
||||
actor
|
||||
observed-actors
|
||||
expected
|
||||
:hold :held)))
|
||||
|
||||
;; ---- state transitions / seat-freeing writes (no oversell, append direct) ----
|
||||
|
||||
;; Confirm a held seat (payment succeeded). :confirmed on success,
|
||||
;; :already-confirmed if it was confirmed, :not-held otherwise.
|
||||
(define
|
||||
ev/confirm!
|
||||
(fn
|
||||
(b occ-key actor)
|
||||
(let
|
||||
((st (ev/seat-state b occ-key actor)))
|
||||
(cond
|
||||
((= st :held)
|
||||
(begin
|
||||
(persist/append
|
||||
b
|
||||
(ev-booking-stream occ-key)
|
||||
:confirm 0
|
||||
{:actor actor})
|
||||
{:actor actor :status :confirmed}))
|
||||
((= st :confirmed) {:actor actor :status :already-confirmed})
|
||||
(else {:actor actor :status :not-held})))))
|
||||
|
||||
;; Release a held seat (payment failed/expired), freeing it. Only valid for a
|
||||
;; held seat — confirmed bookings are given up via ev/cancel!.
|
||||
(define
|
||||
ev/release!
|
||||
(fn
|
||||
(b occ-key actor)
|
||||
(let
|
||||
((st (ev/seat-state b occ-key actor)))
|
||||
(if
|
||||
(= st :held)
|
||||
(begin
|
||||
(persist/append
|
||||
b
|
||||
(ev-booking-stream occ-key)
|
||||
:release 0
|
||||
{:actor actor})
|
||||
{:actor actor :status :released})
|
||||
{:actor actor :status :not-held}))))
|
||||
|
||||
;; Cancel a held or confirmed seat, freeing it. :cancelled or :not-booked.
|
||||
(define
|
||||
ev/cancel!
|
||||
(fn
|
||||
(b occ-key actor)
|
||||
(if
|
||||
(ev-bk-member? actor (ev-booked-actors b occ-key))
|
||||
(begin
|
||||
(persist/append
|
||||
b
|
||||
(ev-booking-stream occ-key)
|
||||
:cancel 0
|
||||
{:actor actor})
|
||||
{:actor actor :status :cancelled})
|
||||
{:actor actor :status :not-booked})))
|
||||
|
||||
;; The roster as a plain list of actors (oldest active first).
|
||||
(define ev/roster (fn (b occ-key) (ev-booked-actors b occ-key)))
|
||||
|
||||
;; Seats remaining for an occurrence of the given capacity.
|
||||
(define
|
||||
ev/seats-left
|
||||
(fn
|
||||
(b occ-key capacity)
|
||||
(max 0 (- capacity (ev-booking-count b occ-key)))))
|
||||
468
lib/events/calendar.sx
Normal file
468
lib/events/calendar.sx
Normal file
@@ -0,0 +1,468 @@
|
||||
;; lib/events/calendar.sx — civil date arithmetic + RRULE expansion in a window.
|
||||
;;
|
||||
;; Datetimes are integer "epoch minutes": days-since-1970-01-01 * 1440 plus
|
||||
;; minute-of-day. Ordering, window bounds, and durations are plain integer
|
||||
;; arithmetic. Civil <-> day-number conversion uses Howard Hinnant's algorithm
|
||||
;; (exact, branch-free, correct for the proleptic Gregorian calendar).
|
||||
;;
|
||||
;; RRULE expansion is the bridge to Datalog: a recurring event expands to a
|
||||
;; bounded list of occurrence dicts within an explicit (win-start, win-end)
|
||||
;; window. Expansion is ALWAYS windowed — an RRULE without a window is an
|
||||
;; infinite computation and is never permitted. Supported subset (RFC 5545):
|
||||
;; FREQ=DAILY|WEEKLY|MONTHLY, INTERVAL, COUNT, UNTIL, BYDAY (weekly: weekday
|
||||
;; numbers; monthly: {:ord N :wd W} ordinal weekdays), BYMONTHDAY (monthly,
|
||||
;; negative = from month end). YEARLY and the rest are deferred.
|
||||
|
||||
;; ---- integer helpers ----
|
||||
|
||||
;; Floored integer division (modulo is already floored, so the remainder
|
||||
;; subtraction makes the quotient exact and floor-correct for any sign).
|
||||
(define ev-floor-div (fn (a b) (quotient (- a (modulo a b)) b)))
|
||||
|
||||
(define ev-or (fn (x d) (if (nil? x) d x)))
|
||||
|
||||
(define ev-filter-nil (fn (xs) (filter (fn (x) (not (nil? x))) xs)))
|
||||
|
||||
;; ---- civil date core (Hinnant) ----
|
||||
|
||||
;; Days since 1970-01-01 for civil (y, m, d). m in [1,12], d in [1,31].
|
||||
(define
|
||||
ev-days-from-civil
|
||||
(fn
|
||||
(y0 m d)
|
||||
(let
|
||||
((y (if (<= m 2) (- y0 1) y0)))
|
||||
(let
|
||||
((era (ev-floor-div (if (>= y 0) y (- y 399)) 400)))
|
||||
(let
|
||||
((yoe (- y (* era 400)))
|
||||
(doy
|
||||
(+
|
||||
(ev-floor-div
|
||||
(+
|
||||
(*
|
||||
153
|
||||
(+ m (if (> m 2) -3 9)))
|
||||
2)
|
||||
5)
|
||||
(- d 1))))
|
||||
(let
|
||||
((doe (+ (* yoe 365) (ev-floor-div yoe 4) (- (ev-floor-div yoe 100)) doy)))
|
||||
(+ (* era 146097) doe -719468)))))))
|
||||
|
||||
;; Civil (y m d) list from a day-number.
|
||||
(define
|
||||
ev-civil-from-days
|
||||
(fn
|
||||
(z0)
|
||||
(let
|
||||
((z (+ z0 719468)))
|
||||
(let
|
||||
((era (ev-floor-div (if (>= z 0) z (- z 146096)) 146097)))
|
||||
(let
|
||||
((doe (- z (* era 146097))))
|
||||
(let
|
||||
((yoe (ev-floor-div (+ (- doe (ev-floor-div doe 1460)) (ev-floor-div doe 36524) (- (ev-floor-div doe 146096))) 365)))
|
||||
(let
|
||||
((y (+ yoe (* era 400)))
|
||||
(doy
|
||||
(-
|
||||
doe
|
||||
(+
|
||||
(* 365 yoe)
|
||||
(ev-floor-div yoe 4)
|
||||
(- (ev-floor-div yoe 100))))))
|
||||
(let
|
||||
((mp (ev-floor-div (+ (* 5 doy) 2) 153)))
|
||||
(let
|
||||
((d (+ (- doy (ev-floor-div (+ (* 153 mp) 2) 5)) 1))
|
||||
(m
|
||||
(if
|
||||
(< mp 10)
|
||||
(+ mp 3)
|
||||
(- mp 9))))
|
||||
(list (if (<= m 2) (+ y 1) y) m d))))))))))
|
||||
|
||||
;; Weekday of a day-number: 0=Mon .. 6=Sun (1970-01-01 is Thursday = 3).
|
||||
(define ev-weekday-of-days (fn (z) (modulo (+ z 3) 7)))
|
||||
|
||||
(define
|
||||
ev-days-in-month
|
||||
(fn
|
||||
(y m)
|
||||
(-
|
||||
(ev-days-from-civil
|
||||
(if (= m 12) (+ y 1) y)
|
||||
(if (= m 12) 1 (+ m 1))
|
||||
1)
|
||||
(ev-days-from-civil y m 1))))
|
||||
|
||||
;; Add k months to (y,m), returning (list y2 m2).
|
||||
(define
|
||||
ev-add-months
|
||||
(fn
|
||||
(y m k)
|
||||
(let
|
||||
((total (+ (* y 12) (- m 1) k)))
|
||||
(list
|
||||
(ev-floor-div total 12)
|
||||
(+ (modulo total 12) 1)))))
|
||||
|
||||
;; ---- datetime (epoch minutes) ----
|
||||
|
||||
(define
|
||||
ev-dt
|
||||
(fn
|
||||
(y m d hh mm)
|
||||
(+ (* (ev-days-from-civil y m d) 1440) (* hh 60) mm)))
|
||||
|
||||
(define ev-date (fn (y m d) (ev-dt y m d 0 0)))
|
||||
|
||||
(define ev-dt->days (fn (t) (ev-floor-div t 1440)))
|
||||
|
||||
(define ev-dt->civil (fn (t) (ev-civil-from-days (ev-dt->days t))))
|
||||
|
||||
(define ev-dt-weekday (fn (t) (ev-weekday-of-days (ev-dt->days t))))
|
||||
|
||||
(define ev-dt-tod (fn (t) (modulo t 1440)))
|
||||
|
||||
(define ev-civ-y (fn (c) (first c)))
|
||||
(define ev-civ-m (fn (c) (first (rest c))))
|
||||
(define ev-civ-d (fn (c) (first (rest (rest c)))))
|
||||
|
||||
;; ---- event + occurrence constructors ----
|
||||
|
||||
;; rrule is nil (single event) or a dict:
|
||||
;; {:freq :daily|:weekly|:monthly :interval N :count N|nil :until DT|nil
|
||||
;; :byday ...|nil :bymonthday (list 15 -1)|nil}
|
||||
;; weekly :byday -> (list 0 2 4) weekday numbers, 0=Mon
|
||||
;; monthly :byday -> (list {:ord 2 :wd 1}) nth weekday (ord<0 from end)
|
||||
;; monthly :bymonthday -> (list 15 -1) day of month (negative from end)
|
||||
(define ev-event (fn (id dtstart duration rrule capacity) {:duration duration :id id :dtstart dtstart :capacity capacity :rrule rrule}))
|
||||
|
||||
(define ev-occ (fn (id start dur) {:id id :start start :end (+ start dur)}))
|
||||
|
||||
;; ---- DAILY expansion ----
|
||||
;; occ starts at dtstart; n counts every generated occurrence (window-
|
||||
;; independent, so COUNT/UNTIL bound the rule, not the view). Emits only
|
||||
;; occurrences inside [win-start, win-end].
|
||||
(define
|
||||
ev-daily-loop
|
||||
(fn
|
||||
(id occ duration step count until dtstart win-start win-end acc n)
|
||||
(cond
|
||||
((> occ win-end) acc)
|
||||
((and (not (nil? count)) (>= n count)) acc)
|
||||
((and (not (nil? until)) (> occ until)) acc)
|
||||
(else
|
||||
(begin
|
||||
(when (>= occ win-start) (append! acc (ev-occ id occ duration)))
|
||||
(ev-daily-loop
|
||||
id
|
||||
(+ occ step)
|
||||
duration
|
||||
step
|
||||
count
|
||||
until
|
||||
dtstart
|
||||
win-start
|
||||
win-end
|
||||
acc
|
||||
(+ n 1)))))))
|
||||
|
||||
;; ---- shared per-period emit ----
|
||||
;; Walk a start-ascending list of candidate occurrence datetimes for one
|
||||
;; period, generating (count toward COUNT) those >= dtstart within UNTIL, and
|
||||
;; emitting those also inside the window. Returns the updated running n.
|
||||
(define
|
||||
ev-emit-occs
|
||||
(fn
|
||||
(id occs duration count until dtstart win-start win-end acc n)
|
||||
(if
|
||||
(empty? occs)
|
||||
n
|
||||
(let
|
||||
((occ (first occs)))
|
||||
(let
|
||||
((generates? (and (>= occ dtstart) (or (nil? until) (<= occ until)) (or (nil? count) (< n count)))))
|
||||
(begin
|
||||
(when
|
||||
(and generates? (>= occ win-start) (<= occ win-end))
|
||||
(append! acc (ev-occ id occ duration)))
|
||||
(ev-emit-occs
|
||||
id
|
||||
(rest occs)
|
||||
duration
|
||||
count
|
||||
until
|
||||
dtstart
|
||||
win-start
|
||||
win-end
|
||||
acc
|
||||
(if generates? (+ n 1) n))))))))
|
||||
|
||||
;; ---- WEEKLY expansion ----
|
||||
;; Iterate week by week from the Monday of dtstart's week; within each active
|
||||
;; week emit each BYDAY (sorted). n counts every generated occurrence.
|
||||
|
||||
(define
|
||||
ev-week0-days
|
||||
(fn (dtstart) (- (ev-dt->days dtstart) (ev-dt-weekday dtstart))))
|
||||
|
||||
(define
|
||||
ev-byday-default
|
||||
(fn
|
||||
(byday dtstart)
|
||||
(if (nil? byday) (list (ev-dt-weekday dtstart)) (sort byday))))
|
||||
|
||||
(define
|
||||
ev-weekly-loop
|
||||
(fn
|
||||
(id
|
||||
week-days
|
||||
tod
|
||||
duration
|
||||
week-step
|
||||
bd
|
||||
count
|
||||
until
|
||||
dtstart
|
||||
win-start
|
||||
win-end
|
||||
acc
|
||||
n)
|
||||
(let
|
||||
((week-start-dt (* week-days 1440)))
|
||||
(cond
|
||||
((> week-start-dt win-end) acc)
|
||||
((and (not (nil? count)) (>= n count)) acc)
|
||||
(else
|
||||
(let
|
||||
((occs (map (fn (wd) (+ (* (+ week-days wd) 1440) tod)) bd)))
|
||||
(let
|
||||
((n2 (ev-emit-occs id occs duration count until dtstart win-start win-end acc n)))
|
||||
(ev-weekly-loop
|
||||
id
|
||||
(+ week-days week-step)
|
||||
tod
|
||||
duration
|
||||
week-step
|
||||
bd
|
||||
count
|
||||
until
|
||||
dtstart
|
||||
win-start
|
||||
win-end
|
||||
acc
|
||||
n2))))))))
|
||||
|
||||
;; ---- MONTHLY expansion ----
|
||||
;; Iterate month by month from dtstart's month, stepping by INTERVAL months.
|
||||
;; Candidate days per month come from BYMONTHDAY, then ordinal BYDAY, else the
|
||||
;; day-of-month of dtstart (skipped in months too short to contain it).
|
||||
|
||||
;; Resolve a BYMONTHDAY value to a valid day-of-month, or nil.
|
||||
(define
|
||||
ev-resolve-monthday
|
||||
(fn
|
||||
(y m bmd)
|
||||
(let
|
||||
((dim (ev-days-in-month y m)))
|
||||
(let
|
||||
((day (if (< bmd 0) (+ dim 1 bmd) bmd)))
|
||||
(if (and (>= day 1) (<= day dim)) day nil)))))
|
||||
|
||||
;; Resolve an ordinal weekday {:ord :wd} to a day-of-month, or nil.
|
||||
(define
|
||||
ev-resolve-nth-weekday
|
||||
(fn
|
||||
(y m ord wd)
|
||||
(let
|
||||
((dim (ev-days-in-month y m)))
|
||||
(if
|
||||
(> ord 0)
|
||||
(let
|
||||
((first-wd (ev-weekday-of-days (ev-days-from-civil y m 1))))
|
||||
(let
|
||||
((day (+ 1 (modulo (- wd first-wd) 7) (* (- ord 1) 7))))
|
||||
(if (<= day dim) day nil)))
|
||||
(let
|
||||
((last-wd (ev-weekday-of-days (ev-days-from-civil y m dim))))
|
||||
(let
|
||||
((day (- dim (modulo (- last-wd wd) 7) (* (- (- ord) 1) 7))))
|
||||
(if (>= day 1) day nil)))))))
|
||||
|
||||
(define
|
||||
ev-month-candidates
|
||||
(fn
|
||||
(y m rrule dtstart)
|
||||
(let
|
||||
((bmd (get rrule :bymonthday)) (byday (get rrule :byday)))
|
||||
(cond
|
||||
((not (nil? bmd))
|
||||
(ev-filter-nil (map (fn (d) (ev-resolve-monthday y m d)) bmd)))
|
||||
((not (nil? byday))
|
||||
(ev-filter-nil
|
||||
(map
|
||||
(fn
|
||||
(e)
|
||||
(ev-resolve-nth-weekday y m (get e :ord) (get e :wd)))
|
||||
byday)))
|
||||
(else
|
||||
(ev-filter-nil
|
||||
(list
|
||||
(ev-resolve-monthday y m (ev-civ-d (ev-dt->civil dtstart))))))))))
|
||||
|
||||
(define
|
||||
ev-monthly-loop
|
||||
(fn
|
||||
(id
|
||||
y
|
||||
m
|
||||
rrule
|
||||
duration
|
||||
tod
|
||||
interval
|
||||
count
|
||||
until
|
||||
dtstart
|
||||
win-start
|
||||
win-end
|
||||
acc
|
||||
n)
|
||||
(let
|
||||
((month-start (ev-dt y m 1 0 0)))
|
||||
(cond
|
||||
((> month-start win-end) acc)
|
||||
((and (not (nil? count)) (>= n count)) acc)
|
||||
(else
|
||||
(let
|
||||
((days (sort (ev-month-candidates y m rrule dtstart))))
|
||||
(let
|
||||
((occs (map (fn (d) (+ (* (ev-days-from-civil y m d) 1440) tod)) days)))
|
||||
(let
|
||||
((n2 (ev-emit-occs id occs duration count until dtstart win-start win-end acc n))
|
||||
(nm (ev-add-months y m interval)))
|
||||
(ev-monthly-loop
|
||||
id
|
||||
(ev-civ-y nm)
|
||||
(ev-civ-m nm)
|
||||
rrule
|
||||
duration
|
||||
tod
|
||||
interval
|
||||
count
|
||||
until
|
||||
dtstart
|
||||
win-start
|
||||
win-end
|
||||
acc
|
||||
n2)))))))))
|
||||
|
||||
;; ---- top-level expansion ----
|
||||
;; Returns a list of occurrence dicts {:id :start :end} within the window.
|
||||
(define
|
||||
ev-expand
|
||||
(fn
|
||||
(event win-start win-end)
|
||||
(let
|
||||
((id (get event :id))
|
||||
(dtstart (get event :dtstart))
|
||||
(duration (get event :duration))
|
||||
(rrule (get event :rrule)))
|
||||
(if
|
||||
(nil? rrule)
|
||||
(if
|
||||
(and (>= dtstart win-start) (<= dtstart win-end))
|
||||
(list (ev-occ id dtstart duration))
|
||||
(list))
|
||||
(let
|
||||
((freq (get rrule :freq))
|
||||
(interval (ev-or (get rrule :interval) 1))
|
||||
(count (get rrule :count))
|
||||
(until (get rrule :until))
|
||||
(byday (get rrule :byday))
|
||||
(acc (list)))
|
||||
(begin
|
||||
(cond
|
||||
((= freq :daily)
|
||||
(ev-daily-loop
|
||||
id
|
||||
dtstart
|
||||
duration
|
||||
(* interval 1440)
|
||||
count
|
||||
until
|
||||
dtstart
|
||||
win-start
|
||||
win-end
|
||||
acc
|
||||
0))
|
||||
((= freq :weekly)
|
||||
(ev-weekly-loop
|
||||
id
|
||||
(ev-week0-days dtstart)
|
||||
(ev-dt-tod dtstart)
|
||||
duration
|
||||
(* interval 7)
|
||||
(ev-byday-default byday dtstart)
|
||||
count
|
||||
until
|
||||
dtstart
|
||||
win-start
|
||||
win-end
|
||||
acc
|
||||
0))
|
||||
((= freq :monthly)
|
||||
(let
|
||||
((civ (ev-dt->civil dtstart)))
|
||||
(ev-monthly-loop
|
||||
id
|
||||
(ev-civ-y civ)
|
||||
(ev-civ-m civ)
|
||||
rrule
|
||||
duration
|
||||
(ev-dt-tod dtstart)
|
||||
interval
|
||||
count
|
||||
until
|
||||
dtstart
|
||||
win-start
|
||||
win-end
|
||||
acc
|
||||
0)))
|
||||
(else (error (str "ev-expand: unsupported freq: " freq))))
|
||||
acc))))))
|
||||
|
||||
;; ---- multi-event expansion (sorted by start) ----
|
||||
|
||||
;; Insertion of one occurrence into a start-ascending list.
|
||||
(define
|
||||
ev-occ-insert
|
||||
(fn
|
||||
(o sorted)
|
||||
(cond
|
||||
((empty? sorted) (list o))
|
||||
((<= (get o :start) (get (first sorted) :start)) (cons o sorted))
|
||||
(else (cons (first sorted) (ev-occ-insert o (rest sorted)))))))
|
||||
|
||||
(define
|
||||
ev-sort-occs
|
||||
(fn (occs) (reduce (fn (acc o) (ev-occ-insert o acc)) (list) occs)))
|
||||
|
||||
;; Expand many events into one occurrence list, ascending by start.
|
||||
(define
|
||||
ev-expand-all
|
||||
(fn
|
||||
(events win-start win-end)
|
||||
(let
|
||||
((acc (list)))
|
||||
(begin
|
||||
(for-each
|
||||
(fn
|
||||
(ev)
|
||||
(for-each
|
||||
(fn (o) (append! acc o))
|
||||
(ev-expand ev win-start win-end)))
|
||||
events)
|
||||
(ev-sort-occs acc)))))
|
||||
37
lib/events/conformance.conf
Normal file
37
lib/events/conformance.conf
Normal file
@@ -0,0 +1,37 @@
|
||||
# events-on-sx conformance config — sourced by lib/guest/conformance.sh.
|
||||
|
||||
LANG_NAME=events
|
||||
MODE=dict
|
||||
SCOREBOARD_DIR=lib/events
|
||||
|
||||
PRELOADS=(
|
||||
spec/stdlib.sx
|
||||
lib/r7rs.sx
|
||||
lib/datalog/tokenizer.sx
|
||||
lib/datalog/parser.sx
|
||||
lib/datalog/unify.sx
|
||||
lib/datalog/db.sx
|
||||
lib/datalog/builtins.sx
|
||||
lib/datalog/aggregates.sx
|
||||
lib/datalog/strata.sx
|
||||
lib/datalog/eval.sx
|
||||
lib/datalog/api.sx
|
||||
lib/datalog/magic.sx
|
||||
lib/events/calendar.sx
|
||||
lib/events/availability.sx
|
||||
lib/persist/event.sx
|
||||
lib/persist/backend.sx
|
||||
lib/persist/log.sx
|
||||
lib/persist/kv.sx
|
||||
lib/persist/concurrency.sx
|
||||
lib/persist/api.sx
|
||||
lib/events/booking.sx
|
||||
lib/events/api.sx
|
||||
)
|
||||
|
||||
SUITES=(
|
||||
"calendar:lib/events/tests/calendar.sx:(ev-calendar-tests-run!)"
|
||||
"availability:lib/events/tests/availability.sx:(ev-availability-tests-run!)"
|
||||
"api:lib/events/tests/api.sx:(ev-api-tests-run!)"
|
||||
"booking:lib/events/tests/booking.sx:(ev-booking-tests-run!)"
|
||||
)
|
||||
3
lib/events/conformance.sh
Executable file
3
lib/events/conformance.sh
Executable file
@@ -0,0 +1,3 @@
|
||||
#!/usr/bin/env bash
|
||||
# Thin wrapper — see lib/guest/conformance.sh and lib/events/conformance.conf.
|
||||
exec bash "$(dirname "$0")/../guest/conformance.sh" "$(dirname "$0")/conformance.conf" "$@"
|
||||
13
lib/events/scoreboard.json
Normal file
13
lib/events/scoreboard.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"lang": "events",
|
||||
"total_passed": 144,
|
||||
"total_failed": 0,
|
||||
"total": 144,
|
||||
"suites": [
|
||||
{"name":"calendar","passed":37,"failed":0,"total":37},
|
||||
{"name":"availability","passed":22,"failed":0,"total":22},
|
||||
{"name":"api","passed":24,"failed":0,"total":24},
|
||||
{"name":"booking","passed":61,"failed":0,"total":61}
|
||||
],
|
||||
"generated": "2026-06-07T03:07:09+00:00"
|
||||
}
|
||||
10
lib/events/scoreboard.md
Normal file
10
lib/events/scoreboard.md
Normal file
@@ -0,0 +1,10 @@
|
||||
# events scoreboard
|
||||
|
||||
**144 / 144 passing** (0 failure(s)).
|
||||
|
||||
| Suite | Passed | Total | Status |
|
||||
|-------|--------|-------|--------|
|
||||
| calendar | 37 | 37 | ok |
|
||||
| availability | 22 | 22 | ok |
|
||||
| api | 24 | 24 | ok |
|
||||
| booking | 61 | 61 | ok |
|
||||
271
lib/events/tests/api.sx
Normal file
271
lib/events/tests/api.sx
Normal file
@@ -0,0 +1,271 @@
|
||||
;; lib/events/tests/api.sx — public events facade (schedule/agenda/free/book).
|
||||
|
||||
(define ev-api-pass 0)
|
||||
(define ev-api-fail 0)
|
||||
(define ev-api-failures (list))
|
||||
|
||||
(define
|
||||
ev-api-check!
|
||||
(fn
|
||||
(name got expected)
|
||||
(if
|
||||
(= got expected)
|
||||
(set! ev-api-pass (+ ev-api-pass 1))
|
||||
(do
|
||||
(set! ev-api-fail (+ ev-api-fail 1))
|
||||
(append!
|
||||
ev-api-failures
|
||||
(str name "\n expected: " expected "\n got: " got))))))
|
||||
|
||||
;; A store with a weekly yoga class (Mon+Wed 18:00, 60m, 4 occurrences).
|
||||
(define
|
||||
ev-api-store
|
||||
(fn
|
||||
()
|
||||
(ev/schedule
|
||||
(ev/empty)
|
||||
(quote yoga)
|
||||
(ev-dt 2026 6 1 18 0)
|
||||
60
|
||||
{:freq :weekly :count 4 :byday (list 0 2)}
|
||||
20)))
|
||||
|
||||
(define
|
||||
ev-api-run-all!
|
||||
(fn
|
||||
()
|
||||
(let
|
||||
((s0 (ev-api-store)))
|
||||
(let
|
||||
((occs (ev/agenda s0 (ev-date 2026 6 1) (ev-date 2026 7 1))))
|
||||
(let
|
||||
((s1 (ev/book (ev/book s0 (quote nia) (ev-occ-key (first occs))) (quote nia) (ev-occ-key (first (rest occs))))))
|
||||
(do
|
||||
(ev-api-check!
|
||||
"agenda expands weekly class to four occurrences"
|
||||
(map (fn (o) (ev-dt->civil (get o :start))) occs)
|
||||
(list
|
||||
(list 2026 6 1)
|
||||
(list 2026 6 3)
|
||||
(list 2026 6 8)
|
||||
(list 2026 6 10)))
|
||||
(ev-api-check!
|
||||
"empty store has empty agenda"
|
||||
(ev/agenda
|
||||
(ev/empty)
|
||||
(ev-date 2026 6 1)
|
||||
(ev-date 2026 7 1))
|
||||
(list))
|
||||
(ev-api-check!
|
||||
"max duration reflects scheduled events"
|
||||
(ev/store-max-duration s0)
|
||||
60)
|
||||
(ev-api-check!
|
||||
"max duration of empty store is zero"
|
||||
(ev/store-max-duration (ev/empty))
|
||||
0)
|
||||
(ev-api-check!
|
||||
"event-by-id finds the scheduled event"
|
||||
(get (ev/event-by-id s0 (quote yoga)) :capacity)
|
||||
20)
|
||||
(ev-api-check!
|
||||
"event-by-id is nil for unknown id"
|
||||
(ev/event-by-id s0 (quote nope))
|
||||
nil)
|
||||
(ev-api-check!
|
||||
"agenda-for lists only booked occurrences"
|
||||
(map
|
||||
(fn (o) (ev-dt->civil (get o :start)))
|
||||
(ev/agenda-for
|
||||
s1
|
||||
(quote nia)
|
||||
(ev-date 2026 6 1)
|
||||
(ev-date 2026 7 1)))
|
||||
(list
|
||||
(list 2026 6 1)
|
||||
(list 2026 6 3)))
|
||||
(ev-api-check!
|
||||
"agenda-for empty for unbooked actor"
|
||||
(ev/agenda-for
|
||||
s1
|
||||
(quote zed)
|
||||
(ev-date 2026 6 1)
|
||||
(ev-date 2026 7 1))
|
||||
(list))
|
||||
(ev-api-check!
|
||||
"free? false during a booked occurrence"
|
||||
(ev/free?
|
||||
s1
|
||||
(quote nia)
|
||||
(ev-dt 2026 6 1 18 30)
|
||||
(ev-dt 2026 6 1 19 0))
|
||||
false)
|
||||
(ev-api-check!
|
||||
"free? true in an open window"
|
||||
(ev/free?
|
||||
s1
|
||||
(quote nia)
|
||||
(ev-dt 2026 6 1 9 0)
|
||||
(ev-dt 2026 6 1 10 0))
|
||||
true)
|
||||
(ev-api-check!
|
||||
"free? half-open at occurrence end"
|
||||
(ev/free?
|
||||
s1
|
||||
(quote nia)
|
||||
(ev-dt 2026 6 1 19 0)
|
||||
(ev-dt 2026 6 1 20 0))
|
||||
true)
|
||||
(ev-api-check!
|
||||
"free? true for an actor who booked nothing"
|
||||
(ev/free?
|
||||
s1
|
||||
(quote zed)
|
||||
(ev-dt 2026 6 1 18 0)
|
||||
(ev-dt 2026 6 1 19 0))
|
||||
true)
|
||||
(ev-api-check!
|
||||
"next-free skips the booked slot to the hour after"
|
||||
(ev-dt-tod
|
||||
(ev/next-free
|
||||
s1
|
||||
(quote nia)
|
||||
(ev-dt
|
||||
2026
|
||||
6
|
||||
1
|
||||
18
|
||||
0)
|
||||
60
|
||||
(ev-dt
|
||||
2026
|
||||
6
|
||||
1
|
||||
23
|
||||
0)))
|
||||
(* 19 60))
|
||||
(ev-api-check!
|
||||
"next-free returns `after` when already open"
|
||||
(ev/next-free
|
||||
s1
|
||||
(quote nia)
|
||||
(ev-dt 2026 6 1 9 0)
|
||||
60
|
||||
(ev-dt 2026 6 1 18 0))
|
||||
(ev-dt 2026 6 1 9 0))
|
||||
(ev-api-check!
|
||||
"no conflict among disjoint bookings"
|
||||
(ev/has-conflict?
|
||||
s1
|
||||
(quote nia)
|
||||
(ev-date 2026 6 1)
|
||||
(ev-date 2026 7 1))
|
||||
false)
|
||||
(let
|
||||
((sc (ev/book (ev/schedule s1 (quote talk) (ev-dt 2026 6 1 18 30) 60 nil 5) (quote nia) (ev-occ-key (ev-occ (quote talk) (ev-dt 2026 6 1 18 30) 60)))))
|
||||
(ev-api-check!
|
||||
"overlapping second booking creates a conflict"
|
||||
(ev/has-conflict?
|
||||
sc
|
||||
(quote nia)
|
||||
(ev-date 2026 6 1)
|
||||
(ev-date 2026 7 1))
|
||||
true))
|
||||
(let
|
||||
((b (persist/open)) (occ1 (first occs)))
|
||||
(do
|
||||
(let
|
||||
((sp (ev/schedule (ev/empty) (quote clinic) (ev-dt 2026 6 5 9 0) 30 nil 2)))
|
||||
(let
|
||||
((occ (ev-occ (quote clinic) (ev-dt 2026 6 5 9 0) 30)))
|
||||
(do
|
||||
(ev-api-check!
|
||||
"durable book returns booked"
|
||||
(get (ev/book-occ! b sp (quote a) occ) :status)
|
||||
:booked)
|
||||
(ev/book-occ! b sp (quote c) occ)
|
||||
(ev-api-check!
|
||||
"durable book past capacity is full"
|
||||
(get (ev/book-occ! b sp (quote d) occ) :status)
|
||||
:full)
|
||||
(ev-api-check!
|
||||
"durable roster reflects persisted bookings"
|
||||
(ev/roster-occ b occ)
|
||||
(list (quote a) (quote c)))
|
||||
(ev-api-check!
|
||||
"durable seats-left honours capacity"
|
||||
(ev/seats-left-occ b sp occ)
|
||||
0)
|
||||
(ev-api-check!
|
||||
"persist free? false during a durable booking"
|
||||
(ev/free-p?
|
||||
b
|
||||
sp
|
||||
(quote a)
|
||||
(ev-dt
|
||||
2026
|
||||
6
|
||||
5
|
||||
9
|
||||
10)
|
||||
(ev-dt
|
||||
2026
|
||||
6
|
||||
5
|
||||
9
|
||||
20))
|
||||
false)
|
||||
(ev-api-check!
|
||||
"persist free? true in an open window"
|
||||
(ev/free-p?
|
||||
b
|
||||
sp
|
||||
(quote a)
|
||||
(ev-dt
|
||||
2026
|
||||
6
|
||||
5
|
||||
10
|
||||
0)
|
||||
(ev-dt
|
||||
2026
|
||||
6
|
||||
5
|
||||
10
|
||||
30))
|
||||
true)
|
||||
(ev/cancel-occ! b sp (quote a) occ)
|
||||
(ev-api-check!
|
||||
"durable cancel frees a seat"
|
||||
(ev/seats-left-occ b sp occ)
|
||||
1)
|
||||
(ev-api-check!
|
||||
"persist free? true after cancellation"
|
||||
(ev/free-p?
|
||||
b
|
||||
sp
|
||||
(quote a)
|
||||
(ev-dt
|
||||
2026
|
||||
6
|
||||
5
|
||||
9
|
||||
10)
|
||||
(ev-dt
|
||||
2026
|
||||
6
|
||||
5
|
||||
9
|
||||
20))
|
||||
true))))))))))))
|
||||
|
||||
(define
|
||||
ev-api-tests-run!
|
||||
(fn
|
||||
()
|
||||
(do
|
||||
(set! ev-api-pass 0)
|
||||
(set! ev-api-fail 0)
|
||||
(set! ev-api-failures (list))
|
||||
(ev-api-run-all!)
|
||||
{:failures ev-api-failures :total (+ ev-api-pass ev-api-fail) :passed ev-api-pass :failed ev-api-fail})))
|
||||
331
lib/events/tests/availability.sx
Normal file
331
lib/events/tests/availability.sx
Normal file
@@ -0,0 +1,331 @@
|
||||
;; lib/events/tests/availability.sx — free/busy + conflict rules on Datalog.
|
||||
|
||||
(define ev-av-pass 0)
|
||||
(define ev-av-fail 0)
|
||||
(define ev-av-failures (list))
|
||||
|
||||
(define
|
||||
ev-av-check!
|
||||
(fn
|
||||
(name got expected)
|
||||
(if
|
||||
(= got expected)
|
||||
(set! ev-av-pass (+ ev-av-pass 1))
|
||||
(do
|
||||
(set! ev-av-fail (+ ev-av-fail 1))
|
||||
(append!
|
||||
ev-av-failures
|
||||
(str name "\n expected: " expected "\n got: " got))))))
|
||||
|
||||
;; Fixture: three occurrences on 2026-06-01.
|
||||
;; standup 09:00–09:30 review 09:15–10:15 (overlaps standup)
|
||||
;; lunch 12:00–13:00
|
||||
(define
|
||||
ev-av-occs
|
||||
(fn
|
||||
()
|
||||
(list
|
||||
(ev-occ
|
||||
(quote standup)
|
||||
(ev-dt 2026 6 1 9 0)
|
||||
30)
|
||||
(ev-occ
|
||||
(quote review)
|
||||
(ev-dt 2026 6 1 9 15)
|
||||
60)
|
||||
(ev-occ
|
||||
(quote lunch)
|
||||
(ev-dt 2026 6 1 12 0)
|
||||
60))))
|
||||
|
||||
(define ev-av-key (fn (id start) (str id "@" start)))
|
||||
|
||||
;; alice: standup + review (overlap → conflict). bob: lunch only.
|
||||
(define
|
||||
ev-av-db
|
||||
(fn
|
||||
()
|
||||
(ev-avail-db
|
||||
(ev-av-occs)
|
||||
(list
|
||||
(list
|
||||
(quote alice)
|
||||
(ev-av-key
|
||||
(quote standup)
|
||||
(ev-dt 2026 6 1 9 0)))
|
||||
(list
|
||||
(quote alice)
|
||||
(ev-av-key
|
||||
(quote review)
|
||||
(ev-dt 2026 6 1 9 15)))
|
||||
(list
|
||||
(quote bob)
|
||||
(ev-av-key
|
||||
(quote lunch)
|
||||
(ev-dt 2026 6 1 12 0)))))))
|
||||
|
||||
;; Disjoint fixture for slot search: 09:00–10:00 then 10:30–11:30 (a 30m gap).
|
||||
(define
|
||||
ev-av-gap-db
|
||||
(fn
|
||||
()
|
||||
(ev-avail-db
|
||||
(list
|
||||
(ev-occ
|
||||
(quote a)
|
||||
(ev-dt 2026 6 1 9 0)
|
||||
60)
|
||||
(ev-occ
|
||||
(quote b)
|
||||
(ev-dt 2026 6 1 10 30)
|
||||
60))
|
||||
(list
|
||||
(list
|
||||
(quote sam)
|
||||
(ev-av-key
|
||||
(quote a)
|
||||
(ev-dt 2026 6 1 9 0)))
|
||||
(list
|
||||
(quote sam)
|
||||
(ev-av-key
|
||||
(quote b)
|
||||
(ev-dt 2026 6 1 10 30)))))))
|
||||
|
||||
(define
|
||||
ev-av-run-all!
|
||||
(fn
|
||||
()
|
||||
(let
|
||||
((db (ev-av-db)))
|
||||
(do
|
||||
(ev-av-check!
|
||||
"busy lists alice committed intervals ascending"
|
||||
(ev-busy db (quote alice))
|
||||
(list
|
||||
(list
|
||||
(ev-dt 2026 6 1 9 0)
|
||||
(ev-dt 2026 6 1 9 30))
|
||||
(list
|
||||
(ev-dt 2026 6 1 9 15)
|
||||
(ev-dt 2026 6 1 10 15))))
|
||||
(ev-av-check!
|
||||
"busy lists bob single interval"
|
||||
(ev-busy db (quote bob))
|
||||
(list
|
||||
(list
|
||||
(ev-dt 2026 6 1 12 0)
|
||||
(ev-dt 2026 6 1 13 0))))
|
||||
(ev-av-check!
|
||||
"busy empty for unknown actor"
|
||||
(ev-busy db (quote carol))
|
||||
(list))
|
||||
(ev-av-check!
|
||||
"alice has an overlap conflict"
|
||||
(ev-has-conflict? db (quote alice))
|
||||
true)
|
||||
(ev-av-check!
|
||||
"alice conflict reported once (canonical pair)"
|
||||
(len (ev-conflicts db (quote alice)))
|
||||
1)
|
||||
(ev-av-check!
|
||||
"bob has no conflict"
|
||||
(ev-has-conflict? db (quote bob))
|
||||
false)
|
||||
(ev-av-check!
|
||||
"non-overlapping bookings do not conflict"
|
||||
(ev-has-conflict?
|
||||
(ev-avail-db
|
||||
(list
|
||||
(ev-occ
|
||||
(quote a)
|
||||
(ev-dt
|
||||
2026
|
||||
6
|
||||
1
|
||||
9
|
||||
0)
|
||||
30)
|
||||
(ev-occ
|
||||
(quote b)
|
||||
(ev-dt
|
||||
2026
|
||||
6
|
||||
1
|
||||
9
|
||||
30)
|
||||
30))
|
||||
(list
|
||||
(list
|
||||
(quote dave)
|
||||
(ev-av-key
|
||||
(quote a)
|
||||
(ev-dt
|
||||
2026
|
||||
6
|
||||
1
|
||||
9
|
||||
0)))
|
||||
(list
|
||||
(quote dave)
|
||||
(ev-av-key
|
||||
(quote b)
|
||||
(ev-dt
|
||||
2026
|
||||
6
|
||||
1
|
||||
9
|
||||
30)))))
|
||||
(quote dave))
|
||||
false)
|
||||
(ev-av-check!
|
||||
"alice free in an empty window"
|
||||
(ev-free?
|
||||
db
|
||||
(quote alice)
|
||||
(ev-dt 2026 6 1 13 0)
|
||||
(ev-dt 2026 6 1 14 0))
|
||||
true)
|
||||
(ev-av-check!
|
||||
"alice not free overlapping a booking"
|
||||
(ev-free?
|
||||
db
|
||||
(quote alice)
|
||||
(ev-dt 2026 6 1 9 20)
|
||||
(ev-dt 2026 6 1 9 40))
|
||||
false)
|
||||
(ev-av-check!
|
||||
"free? is half-open at the trailing edge"
|
||||
(ev-free?
|
||||
db
|
||||
(quote alice)
|
||||
(ev-dt 2026 6 1 10 15)
|
||||
(ev-dt 2026 6 1 11 0))
|
||||
true)
|
||||
(ev-av-check!
|
||||
"free? is half-open at the leading edge"
|
||||
(ev-free?
|
||||
db
|
||||
(quote bob)
|
||||
(ev-dt 2026 6 1 11 0)
|
||||
(ev-dt 2026 6 1 12 0))
|
||||
true)
|
||||
(ev-av-check!
|
||||
"free? false when window straddles a booking edge"
|
||||
(ev-free?
|
||||
db
|
||||
(quote bob)
|
||||
(ev-dt 2026 6 1 11 0)
|
||||
(ev-dt 2026 6 1 12 1))
|
||||
false)
|
||||
(ev-av-check!
|
||||
"free? query leaves db reusable (no leaked qwindow)"
|
||||
(do
|
||||
(ev-free?
|
||||
db
|
||||
(quote alice)
|
||||
(ev-dt 2026 6 1 9 0)
|
||||
(ev-dt 2026 6 1 9 30))
|
||||
(ev-busy db (quote bob)))
|
||||
(list
|
||||
(list
|
||||
(ev-dt 2026 6 1 12 0)
|
||||
(ev-dt 2026 6 1 13 0))))
|
||||
(let
|
||||
((gdb (ev-av-gap-db)))
|
||||
(do
|
||||
(ev-av-check!
|
||||
"next-free finds the gap between bookings"
|
||||
(ev-next-free
|
||||
gdb
|
||||
(quote sam)
|
||||
(ev-dt 2026 6 1 9 0)
|
||||
30
|
||||
(ev-dt 2026 6 1 18 0))
|
||||
(ev-dt 2026 6 1 10 0))
|
||||
(ev-av-check!
|
||||
"next-free skips a gap too short for the duration"
|
||||
(ev-next-free
|
||||
gdb
|
||||
(quote sam)
|
||||
(ev-dt 2026 6 1 9 0)
|
||||
60
|
||||
(ev-dt 2026 6 1 18 0))
|
||||
(ev-dt 2026 6 1 11 30))
|
||||
(ev-av-check!
|
||||
"next-free returns `after` when already free"
|
||||
(ev-next-free
|
||||
gdb
|
||||
(quote sam)
|
||||
(ev-dt 2026 6 1 14 0)
|
||||
60
|
||||
(ev-dt 2026 6 1 18 0))
|
||||
(ev-dt 2026 6 1 14 0))
|
||||
(ev-av-check!
|
||||
"next-free returns nil when nothing fits before horizon"
|
||||
(ev-next-free
|
||||
gdb
|
||||
(quote sam)
|
||||
(ev-dt 2026 6 1 9 0)
|
||||
120
|
||||
(ev-dt 2026 6 1 11 0))
|
||||
nil)
|
||||
(ev-av-check!
|
||||
"next-free for actor with no bookings is `after`"
|
||||
(ev-next-free
|
||||
gdb
|
||||
(quote nobody)
|
||||
(ev-dt 2026 6 1 9 0)
|
||||
60
|
||||
(ev-dt 2026 6 1 18 0))
|
||||
(ev-dt 2026 6 1 9 0))
|
||||
(ev-av-check!
|
||||
"next-free at exact edge of a booking (half-open)"
|
||||
(ev-next-free
|
||||
gdb
|
||||
(quote sam)
|
||||
(ev-dt 2026 6 1 10 0)
|
||||
30
|
||||
(ev-dt 2026 6 1 18 0))
|
||||
(ev-dt 2026 6 1 10 0))))
|
||||
(let
|
||||
((daily (ev-expand (ev-event (quote class) (ev-dt 2026 6 1 9 0) 60 {:freq :daily :count 3} 1) (ev-date 2026 6 1) (ev-date 2026 7 1))))
|
||||
(let
|
||||
((db2 (ev-avail-db daily (map (fn (o) (list (quote sam) (ev-occ-key o))) daily))))
|
||||
(do
|
||||
(ev-av-check!
|
||||
"expanded daily occurrences become busy intervals"
|
||||
(len (ev-busy db2 (quote sam)))
|
||||
3)
|
||||
(ev-av-check!
|
||||
"no conflicts among disjoint daily occurrences"
|
||||
(ev-has-conflict? db2 (quote sam))
|
||||
false)
|
||||
(ev-av-check!
|
||||
"busy on day two of the series"
|
||||
(ev-free?
|
||||
db2
|
||||
(quote sam)
|
||||
(ev-dt
|
||||
2026
|
||||
6
|
||||
2
|
||||
9
|
||||
30)
|
||||
(ev-dt
|
||||
2026
|
||||
6
|
||||
2
|
||||
9
|
||||
45))
|
||||
false))))))))
|
||||
|
||||
(define
|
||||
ev-availability-tests-run!
|
||||
(fn
|
||||
()
|
||||
(do
|
||||
(set! ev-av-pass 0)
|
||||
(set! ev-av-fail 0)
|
||||
(set! ev-av-failures (list))
|
||||
(ev-av-run-all!)
|
||||
{:failures ev-av-failures :total (+ ev-av-pass ev-av-fail) :passed ev-av-pass :failed ev-av-fail})))
|
||||
371
lib/events/tests/booking.sx
Normal file
371
lib/events/tests/booking.sx
Normal file
@@ -0,0 +1,371 @@
|
||||
;; lib/events/tests/booking.sx — capacity-safe booking, cancel, and holds.
|
||||
|
||||
(define ev-bk-pass 0)
|
||||
(define ev-bk-fail 0)
|
||||
(define ev-bk-failures (list))
|
||||
|
||||
(define
|
||||
ev-bk-check!
|
||||
(fn
|
||||
(name got expected)
|
||||
(if
|
||||
(= got expected)
|
||||
(set! ev-bk-pass (+ ev-bk-pass 1))
|
||||
(do
|
||||
(set! ev-bk-fail (+ ev-bk-fail 1))
|
||||
(append!
|
||||
ev-bk-failures
|
||||
(str name "\n expected: " expected "\n got: " got))))))
|
||||
|
||||
;; Take a consistent (roster, last-seq) snapshot of an occurrence's stream.
|
||||
(define ev-bk-snap (fn (b k) (ev-booked-actors b k)))
|
||||
(define ev-bk-seq (fn (b k) (persist/last-seq b (ev-booking-stream k))))
|
||||
|
||||
(define
|
||||
ev-bk-run-all!
|
||||
(fn
|
||||
()
|
||||
(do
|
||||
(let
|
||||
((b (persist/open)))
|
||||
(do
|
||||
(ev-bk-check!
|
||||
"first booking takes seat 1"
|
||||
(get (ev/book! b "o1" 3 (quote a)) :seat)
|
||||
1)
|
||||
(ev-bk-check!
|
||||
"second booking takes seat 2"
|
||||
(get (ev/book! b "o1" 3 (quote c)) :seat)
|
||||
2)
|
||||
(ev-bk-check!
|
||||
"booked status reported"
|
||||
(get (ev/book! b "o1" 3 (quote d)) :status)
|
||||
:booked)
|
||||
(ev-bk-check!
|
||||
"roster is oldest-first"
|
||||
(ev/roster b "o1")
|
||||
(list (quote a) (quote c) (quote d)))
|
||||
(ev-bk-check!
|
||||
"seats-left is zero when full"
|
||||
(ev/seats-left b "o1" 3)
|
||||
0)
|
||||
(ev-bk-check!
|
||||
"free booking is confirmed state"
|
||||
(ev/seat-state b "o1" (quote a))
|
||||
:confirmed)))
|
||||
(let
|
||||
((b (persist/open)))
|
||||
(do
|
||||
(ev/book! b "o2" 1 (quote a))
|
||||
(ev-bk-check!
|
||||
"booking past capacity is refused"
|
||||
(get (ev/book! b "o2" 1 (quote c)) :status)
|
||||
:full)
|
||||
(ev-bk-check!
|
||||
"full does not grow the roster"
|
||||
(ev/roster b "o2")
|
||||
(list (quote a)))
|
||||
(ev-bk-check!
|
||||
"seats-left zero at capacity"
|
||||
(ev/seats-left b "o2" 1)
|
||||
0)))
|
||||
(let
|
||||
((b (persist/open)))
|
||||
(do
|
||||
(ev/book! b "o3" 5 (quote a))
|
||||
(ev-bk-check!
|
||||
"re-booking the same actor is idempotent"
|
||||
(get (ev/book! b "o3" 5 (quote a)) :status)
|
||||
:already)
|
||||
(ev-bk-check!
|
||||
"idempotent re-book reports existing seat"
|
||||
(get (ev/book! b "o3" 5 (quote a)) :seat)
|
||||
1)
|
||||
(ev-bk-check!
|
||||
"roster unchanged after re-book"
|
||||
(ev/roster b "o3")
|
||||
(list (quote a)))
|
||||
(ev-bk-check!
|
||||
"count unchanged after re-book"
|
||||
(ev-booking-count b "o3")
|
||||
1)))
|
||||
(let
|
||||
((b (persist/open)))
|
||||
(do
|
||||
(ev/book! b "last" 2 (quote x))
|
||||
(let
|
||||
((snap (ev-bk-snap b "last")) (exp (ev-bk-seq b "last")))
|
||||
(let
|
||||
((ra (ev/book-with-observed b "last" 2 (quote a) snap exp))
|
||||
(rb
|
||||
(ev/book-with-observed
|
||||
b
|
||||
"last"
|
||||
2
|
||||
(quote bee)
|
||||
snap
|
||||
exp)))
|
||||
(do
|
||||
(ev-bk-check!
|
||||
"race winner is booked"
|
||||
(get ra :status)
|
||||
:booked)
|
||||
(ev-bk-check!
|
||||
"race winner takes the last seat"
|
||||
(get ra :seat)
|
||||
2)
|
||||
(ev-bk-check!
|
||||
"race loser is rejected with a conflict"
|
||||
(get rb :status)
|
||||
:conflict)
|
||||
(ev-bk-check!
|
||||
"conflict reports the advanced seq"
|
||||
(get rb :actual)
|
||||
(+ exp 1))
|
||||
(ev-bk-check!
|
||||
"no overbooking: exactly two on roster"
|
||||
(ev-booking-count b "last")
|
||||
2)
|
||||
(ev-bk-check!
|
||||
"race loser is NOT on the roster"
|
||||
(ev-bk-member? (quote bee) (ev/roster b "last"))
|
||||
false)
|
||||
(ev-bk-check!
|
||||
"race loser retrying gets full"
|
||||
(get (ev/book! b "last" 2 (quote bee)) :status)
|
||||
:full))))))
|
||||
(let
|
||||
((b (persist/open)))
|
||||
(do
|
||||
(ev/book! b "room" 3 (quote x))
|
||||
(let
|
||||
((snap (ev-bk-snap b "room")) (exp (ev-bk-seq b "room")))
|
||||
(let
|
||||
((ra (ev/book-with-observed b "room" 3 (quote a) snap exp))
|
||||
(rb
|
||||
(ev/book-with-observed
|
||||
b
|
||||
"room"
|
||||
3
|
||||
(quote bee)
|
||||
snap
|
||||
exp)))
|
||||
(do
|
||||
(ev-bk-check!
|
||||
"room winner booked seat 2"
|
||||
(get ra :seat)
|
||||
2)
|
||||
(ev-bk-check!
|
||||
"room loser first conflicts"
|
||||
(get rb :status)
|
||||
:conflict)
|
||||
(ev-bk-check!
|
||||
"room loser retry books seat 3"
|
||||
(get (ev/book! b "room" 3 (quote bee)) :seat)
|
||||
3)
|
||||
(ev-bk-check!
|
||||
"room roster is x,a,bee"
|
||||
(ev/roster b "room")
|
||||
(list (quote x) (quote a) (quote bee)))
|
||||
(ev-bk-check!
|
||||
"room is now full"
|
||||
(ev/seats-left b "room" 3)
|
||||
0))))))
|
||||
(let
|
||||
((b (persist/open)))
|
||||
(do
|
||||
(ev/book! b "cx" 2 (quote a))
|
||||
(ev/book! b "cx" 2 (quote c))
|
||||
(ev-bk-check!
|
||||
"occupied to capacity before cancel"
|
||||
(ev/seats-left b "cx" 2)
|
||||
0)
|
||||
(ev-bk-check!
|
||||
"booking when full (pre-cancel) is refused"
|
||||
(get (ev/book! b "cx" 2 (quote d)) :status)
|
||||
:full)
|
||||
(ev-bk-check!
|
||||
"cancel reports cancelled"
|
||||
(get (ev/cancel! b "cx" (quote a)) :status)
|
||||
:cancelled)
|
||||
(ev-bk-check!
|
||||
"cancel removes actor from roster"
|
||||
(ev/roster b "cx")
|
||||
(list (quote c)))
|
||||
(ev-bk-check!
|
||||
"cancel frees a seat"
|
||||
(ev/seats-left b "cx" 2)
|
||||
1)
|
||||
(ev-bk-check!
|
||||
"freed seat is bookable again"
|
||||
(get (ev/book! b "cx" 2 (quote d)) :status)
|
||||
:booked)
|
||||
(ev-bk-check!
|
||||
"roster after rebook is c,d"
|
||||
(ev/roster b "cx")
|
||||
(list (quote c) (quote d)))))
|
||||
(let
|
||||
((b (persist/open)))
|
||||
(do
|
||||
(ev/book! b "ce" 3 (quote a))
|
||||
(ev-bk-check!
|
||||
"cancelling an unbooked actor is a no-op"
|
||||
(get (ev/cancel! b "ce" (quote z)) :status)
|
||||
:not-booked)
|
||||
(ev-bk-check!
|
||||
"no-op cancel leaves roster intact"
|
||||
(ev/roster b "ce")
|
||||
(list (quote a)))
|
||||
(ev/cancel! b "ce" (quote a))
|
||||
(ev-bk-check!
|
||||
"double cancel is not-booked the second time"
|
||||
(get (ev/cancel! b "ce" (quote a)) :status)
|
||||
:not-booked)
|
||||
(ev-bk-check!
|
||||
"empty roster after cancel"
|
||||
(ev/roster b "ce")
|
||||
(list))
|
||||
(ev-bk-check!
|
||||
"cancelled actor may re-book"
|
||||
(get (ev/book! b "ce" 3 (quote a)) :status)
|
||||
:booked)
|
||||
(ev-bk-check!
|
||||
"re-booked actor back on roster"
|
||||
(ev/roster b "ce")
|
||||
(list (quote a)))))
|
||||
(let
|
||||
((b (persist/open)))
|
||||
(do
|
||||
(ev/book! b "h" 2 (quote a))
|
||||
(ev-bk-check!
|
||||
"hold reports held"
|
||||
(get (ev/hold! b "h" 2 (quote p)) :status)
|
||||
:held)
|
||||
(ev-bk-check!
|
||||
"held seat is :held state"
|
||||
(ev/seat-state b "h" (quote p))
|
||||
:held)
|
||||
(ev-bk-check!
|
||||
"held actor is on the roster"
|
||||
(ev/roster b "h")
|
||||
(list (quote a) (quote p)))
|
||||
(ev-bk-check!
|
||||
"held seat blocks the last booking"
|
||||
(get (ev/book! b "h" 2 (quote x)) :status)
|
||||
:full)
|
||||
(ev-bk-check!
|
||||
"no seats left with one held"
|
||||
(ev/seats-left b "h" 2)
|
||||
0)))
|
||||
(let
|
||||
((b (persist/open)))
|
||||
(do
|
||||
(ev/hold! b "hc" 3 (quote p))
|
||||
(ev-bk-check!
|
||||
"confirm reports confirmed"
|
||||
(get (ev/confirm! b "hc" (quote p)) :status)
|
||||
:confirmed)
|
||||
(ev-bk-check!
|
||||
"confirmed seat is :confirmed state"
|
||||
(ev/seat-state b "hc" (quote p))
|
||||
:confirmed)
|
||||
(ev-bk-check!
|
||||
"re-confirm is already-confirmed"
|
||||
(get (ev/confirm! b "hc" (quote p)) :status)
|
||||
:already-confirmed)
|
||||
(ev-bk-check!
|
||||
"confirming a non-holder is not-held"
|
||||
(get (ev/confirm! b "hc" (quote z)) :status)
|
||||
:not-held)
|
||||
(ev-bk-check!
|
||||
"confirmed seat still occupies"
|
||||
(ev/seats-left b "hc" 3)
|
||||
2)))
|
||||
(let
|
||||
((b (persist/open)))
|
||||
(do
|
||||
(ev/book! b "hr" 2 (quote a))
|
||||
(ev/hold! b "hr" 2 (quote p))
|
||||
(ev-bk-check!
|
||||
"full while hold pending"
|
||||
(ev/seats-left b "hr" 2)
|
||||
0)
|
||||
(ev-bk-check!
|
||||
"release reports released"
|
||||
(get (ev/release! b "hr" (quote p)) :status)
|
||||
:released)
|
||||
(ev-bk-check!
|
||||
"release frees the held seat"
|
||||
(ev/seats-left b "hr" 2)
|
||||
1)
|
||||
(ev-bk-check!
|
||||
"released actor off the roster"
|
||||
(ev/roster b "hr")
|
||||
(list (quote a)))
|
||||
(ev-bk-check!
|
||||
"freed seat bookable after release"
|
||||
(get (ev/book! b "hr" 2 (quote x)) :status)
|
||||
:booked)
|
||||
(ev/hold! b "hr2" 1 (quote q))
|
||||
(ev/confirm! b "hr2" (quote q))
|
||||
(ev-bk-check!
|
||||
"release on a confirmed seat is not-held"
|
||||
(get (ev/release! b "hr2" (quote q)) :status)
|
||||
:not-held)
|
||||
(ev-bk-check!
|
||||
"cancel frees a confirmed-from-hold seat"
|
||||
(get (ev/cancel! b "hr2" (quote q)) :status)
|
||||
:cancelled)))
|
||||
(let
|
||||
((b (persist/open)))
|
||||
(do
|
||||
(ev/book! b "hlast" 2 (quote x))
|
||||
(let
|
||||
((snap (ev-bk-snap b "hlast")) (exp (ev-bk-seq b "hlast")))
|
||||
(let
|
||||
((ra (ev/hold-with-observed b "hlast" 2 (quote p) snap exp))
|
||||
(rb
|
||||
(ev/hold-with-observed
|
||||
b
|
||||
"hlast"
|
||||
2
|
||||
(quote q)
|
||||
snap
|
||||
exp)))
|
||||
(do
|
||||
(ev-bk-check! "hold race winner held" (get ra :status) :held)
|
||||
(ev-bk-check!
|
||||
"hold race loser conflicts"
|
||||
(get rb :status)
|
||||
:conflict)
|
||||
(ev-bk-check!
|
||||
"no oversell via concurrent holds"
|
||||
(ev-booking-count b "hlast")
|
||||
2)
|
||||
(ev-bk-check!
|
||||
"hold loser retry gets full"
|
||||
(get (ev/hold! b "hlast" 2 (quote q)) :status)
|
||||
:full))))))
|
||||
(let
|
||||
((b (persist/open)))
|
||||
(do
|
||||
(ev/hold! b "hi" 4 (quote p))
|
||||
(ev-bk-check!
|
||||
"re-holding the same actor is idempotent"
|
||||
(get (ev/hold! b "hi" 4 (quote p)) :status)
|
||||
:already)
|
||||
(ev-bk-check!
|
||||
"hold idempotency keeps one seat"
|
||||
(ev-booking-count b "hi")
|
||||
1))))))
|
||||
|
||||
(define
|
||||
ev-booking-tests-run!
|
||||
(fn
|
||||
()
|
||||
(do
|
||||
(set! ev-bk-pass 0)
|
||||
(set! ev-bk-fail 0)
|
||||
(set! ev-bk-failures (list))
|
||||
(ev-bk-run-all!)
|
||||
{:failures ev-bk-failures :total (+ ev-bk-pass ev-bk-fail) :passed ev-bk-pass :failed ev-bk-fail})))
|
||||
433
lib/events/tests/calendar.sx
Normal file
433
lib/events/tests/calendar.sx
Normal file
@@ -0,0 +1,433 @@
|
||||
;; lib/events/tests/calendar.sx — civil date core + RRULE window expansion.
|
||||
|
||||
(define ev-cal-pass 0)
|
||||
(define ev-cal-fail 0)
|
||||
(define ev-cal-failures (list))
|
||||
|
||||
(define
|
||||
ev-cal-check!
|
||||
(fn
|
||||
(name got expected)
|
||||
(if
|
||||
(= got expected)
|
||||
(set! ev-cal-pass (+ ev-cal-pass 1))
|
||||
(do
|
||||
(set! ev-cal-fail (+ ev-cal-fail 1))
|
||||
(append!
|
||||
ev-cal-failures
|
||||
(str name "\n expected: " expected "\n got: " got))))))
|
||||
|
||||
;; Project occurrences to (civil weekday) pairs for legible assertions.
|
||||
(define
|
||||
ev-cal-shape
|
||||
(fn
|
||||
(occs)
|
||||
(map
|
||||
(fn
|
||||
(o)
|
||||
(list (ev-dt->civil (get o :start)) (ev-dt-weekday (get o :start))))
|
||||
occs)))
|
||||
|
||||
(define
|
||||
ev-cal-starts
|
||||
(fn (occs) (map (fn (o) (ev-dt->civil (get o :start))) occs)))
|
||||
|
||||
(define
|
||||
ev-cal-run-all!
|
||||
(fn
|
||||
()
|
||||
(do
|
||||
(ev-cal-check!
|
||||
"epoch day zero"
|
||||
(ev-days-from-civil 1970 1 1)
|
||||
0)
|
||||
(ev-cal-check!
|
||||
"y2k day number"
|
||||
(ev-days-from-civil 2000 1 1)
|
||||
10957)
|
||||
(ev-cal-check!
|
||||
"leap day round trip"
|
||||
(ev-civil-from-days
|
||||
(ev-days-from-civil 2024 2 29))
|
||||
(list 2024 2 29))
|
||||
(ev-cal-check!
|
||||
"pre-epoch round trip"
|
||||
(ev-civil-from-days
|
||||
(ev-days-from-civil 1969 12 31))
|
||||
(list 1969 12 31))
|
||||
(ev-cal-check!
|
||||
"epoch is thursday"
|
||||
(ev-weekday-of-days 0)
|
||||
3)
|
||||
(ev-cal-check!
|
||||
"2026-06-06 is saturday"
|
||||
(ev-dt-weekday (ev-date 2026 6 6))
|
||||
5)
|
||||
(ev-cal-check!
|
||||
"dt carries time of day"
|
||||
(ev-dt-tod
|
||||
(ev-dt 2026 6 1 9 30))
|
||||
570)
|
||||
(ev-cal-check!
|
||||
"civil from dt"
|
||||
(ev-dt->civil
|
||||
(ev-dt 2026 12 25 8 0))
|
||||
(list 2026 12 25))
|
||||
(ev-cal-check!
|
||||
"days in feb 2024 (leap)"
|
||||
(ev-days-in-month 2024 2)
|
||||
29)
|
||||
(ev-cal-check!
|
||||
"days in feb 2026"
|
||||
(ev-days-in-month 2026 2)
|
||||
28)
|
||||
(ev-cal-check!
|
||||
"add months wraps year"
|
||||
(ev-add-months 2026 11 3)
|
||||
(list 2027 2))
|
||||
(ev-cal-check!
|
||||
"add months within year"
|
||||
(ev-add-months 2026 1 5)
|
||||
(list 2026 6))
|
||||
(let
|
||||
((ev (ev-event (quote one) (ev-dt 2026 6 10 14 0) 60 nil 1)))
|
||||
(do
|
||||
(ev-cal-check!
|
||||
"single inside window emits once"
|
||||
(len
|
||||
(ev-expand
|
||||
ev
|
||||
(ev-date 2026 6 1)
|
||||
(ev-date 2026 7 1)))
|
||||
1)
|
||||
(ev-cal-check!
|
||||
"single before window omitted"
|
||||
(len
|
||||
(ev-expand
|
||||
ev
|
||||
(ev-date 2026 7 1)
|
||||
(ev-date 2026 8 1)))
|
||||
0)
|
||||
(ev-cal-check!
|
||||
"single after window omitted"
|
||||
(len
|
||||
(ev-expand
|
||||
ev
|
||||
(ev-date 2026 1 1)
|
||||
(ev-date 2026 2 1)))
|
||||
0)
|
||||
(ev-cal-check!
|
||||
"occurrence end is start plus duration"
|
||||
(get
|
||||
(first
|
||||
(ev-expand
|
||||
ev
|
||||
(ev-date 2026 6 1)
|
||||
(ev-date 2026 7 1)))
|
||||
:end)
|
||||
(+
|
||||
(ev-dt 2026 6 10 14 0)
|
||||
60))))
|
||||
(let
|
||||
((daily (ev-event (quote d) (ev-dt 2026 6 1 9 0) 30 {:freq :daily :count 5} 1)))
|
||||
(do
|
||||
(ev-cal-check!
|
||||
"daily count caps occurrences"
|
||||
(ev-cal-starts
|
||||
(ev-expand
|
||||
daily
|
||||
(ev-date 2026 6 1)
|
||||
(ev-date 2026 7 1)))
|
||||
(list
|
||||
(list 2026 6 1)
|
||||
(list 2026 6 2)
|
||||
(list 2026 6 3)
|
||||
(list 2026 6 4)
|
||||
(list 2026 6 5)))
|
||||
(ev-cal-check!
|
||||
"daily preserves time of day"
|
||||
(ev-dt-tod
|
||||
(get
|
||||
(first
|
||||
(ev-expand
|
||||
daily
|
||||
(ev-date 2026 6 1)
|
||||
(ev-date 2026 7 1)))
|
||||
:start))
|
||||
540)))
|
||||
(let
|
||||
((di (ev-event (quote di) (ev-dt 2026 6 1 0 0) 30 {:interval 3 :freq :daily :until (ev-date 2026 6 30)} 1)))
|
||||
(ev-cal-check!
|
||||
"daily interval 3 steps by three days"
|
||||
(ev-cal-starts
|
||||
(ev-expand
|
||||
di
|
||||
(ev-date 2026 6 1)
|
||||
(ev-date 2026 6 13)))
|
||||
(list
|
||||
(list 2026 6 1)
|
||||
(list 2026 6 4)
|
||||
(list 2026 6 7)
|
||||
(list 2026 6 10)
|
||||
(list 2026 6 13))))
|
||||
(let
|
||||
((dc (ev-event (quote dc) (ev-dt 2026 6 1 0 0) 30 {:freq :daily :count 10} 1)))
|
||||
(ev-cal-check!
|
||||
"count is window-independent (clip middle)"
|
||||
(ev-cal-starts
|
||||
(ev-expand
|
||||
dc
|
||||
(ev-date 2026 6 5)
|
||||
(ev-date 2026 6 8)))
|
||||
(list
|
||||
(list 2026 6 5)
|
||||
(list 2026 6 6)
|
||||
(list 2026 6 7)
|
||||
(list 2026 6 8))))
|
||||
(let
|
||||
((dc2 (ev-event (quote dc2) (ev-dt 2026 6 1 0 0) 30 {:freq :daily :count 3} 1)))
|
||||
(ev-cal-check!
|
||||
"count exhausted before window yields nothing"
|
||||
(len
|
||||
(ev-expand
|
||||
dc2
|
||||
(ev-date 2026 6 10)
|
||||
(ev-date 2026 6 20)))
|
||||
0))
|
||||
(let
|
||||
((wk (ev-event (quote w) (ev-dt 2026 6 1 18 0) 90 {:freq :weekly :byday (list 0 2 4)} 1)))
|
||||
(ev-cal-check!
|
||||
"weekly byday mon/wed/fri first two weeks"
|
||||
(ev-cal-shape
|
||||
(ev-expand
|
||||
wk
|
||||
(ev-date 2026 6 1)
|
||||
(ev-date 2026 6 13)))
|
||||
(list
|
||||
(list (list 2026 6 1) 0)
|
||||
(list (list 2026 6 3) 2)
|
||||
(list (list 2026 6 5) 4)
|
||||
(list (list 2026 6 8) 0)
|
||||
(list (list 2026 6 10) 2)
|
||||
(list (list 2026 6 12) 4))))
|
||||
(let
|
||||
((wu (ev-event (quote wu) (ev-dt 2026 6 1 18 0) 90 {:freq :weekly :until (ev-dt 2026 6 10 23 0) :byday (list 0 2)} 1)))
|
||||
(ev-cal-check!
|
||||
"weekly until clips trailing occurrences"
|
||||
(ev-cal-starts
|
||||
(ev-expand
|
||||
wu
|
||||
(ev-date 2026 6 1)
|
||||
(ev-date 2026 7 1)))
|
||||
(list
|
||||
(list 2026 6 1)
|
||||
(list 2026 6 3)
|
||||
(list 2026 6 8)
|
||||
(list 2026 6 10))))
|
||||
(let
|
||||
((wi (ev-event (quote wi) (ev-dt 2026 6 1 18 0) 90 {:interval 2 :freq :weekly :byday (list 0)} 1)))
|
||||
(ev-cal-check!
|
||||
"weekly interval 2 skips alternate weeks"
|
||||
(ev-cal-starts
|
||||
(ev-expand
|
||||
wi
|
||||
(ev-date 2026 6 1)
|
||||
(ev-date 2026 7 6)))
|
||||
(list
|
||||
(list 2026 6 1)
|
||||
(list 2026 6 15)
|
||||
(list 2026 6 29))))
|
||||
(let
|
||||
((wd (ev-event (quote wd) (ev-dt 2026 6 3 12 0) 60 {:freq :weekly :count 3} 1)))
|
||||
(ev-cal-check!
|
||||
"weekly default byday is dtstart weekday"
|
||||
(ev-cal-shape
|
||||
(ev-expand
|
||||
wd
|
||||
(ev-date 2026 6 1)
|
||||
(ev-date 2026 8 1)))
|
||||
(list
|
||||
(list (list 2026 6 3) 2)
|
||||
(list (list 2026 6 10) 2)
|
||||
(list (list 2026 6 17) 2))))
|
||||
(let
|
||||
((wc (ev-event (quote wc) (ev-dt 2026 6 1 18 0) 90 {:freq :weekly :count 10 :byday (list 0 2)} 1)))
|
||||
(ev-cal-check!
|
||||
"weekly count window-independent (clip middle)"
|
||||
(ev-cal-starts
|
||||
(ev-expand
|
||||
wc
|
||||
(ev-date 2026 6 15)
|
||||
(ev-date 2026 7 5)))
|
||||
(list
|
||||
(list 2026 6 15)
|
||||
(list 2026 6 17)
|
||||
(list 2026 6 22)
|
||||
(list 2026 6 24)
|
||||
(list 2026 6 29)
|
||||
(list 2026 7 1))))
|
||||
(let
|
||||
((wf (ev-event (quote wf) (ev-dt 2026 6 3 18 0) 90 {:freq :weekly :count 4 :byday (list 0 2 4)} 1)))
|
||||
(ev-cal-check!
|
||||
"first week skips byday earlier than dtstart"
|
||||
(ev-cal-starts
|
||||
(ev-expand
|
||||
wf
|
||||
(ev-date 2026 6 1)
|
||||
(ev-date 2026 7 1)))
|
||||
(list
|
||||
(list 2026 6 3)
|
||||
(list 2026 6 5)
|
||||
(list 2026 6 8)
|
||||
(list 2026 6 10))))
|
||||
(let
|
||||
((md (ev-event (quote md) (ev-dt 2026 1 15 9 0) 60 {:bymonthday (list 15) :freq :monthly} 1)))
|
||||
(do
|
||||
(ev-cal-check!
|
||||
"monthly bymonthday 15th"
|
||||
(ev-cal-starts
|
||||
(ev-expand
|
||||
md
|
||||
(ev-date 2026 1 1)
|
||||
(ev-date 2026 4 1)))
|
||||
(list
|
||||
(list 2026 1 15)
|
||||
(list 2026 2 15)
|
||||
(list 2026 3 15)))
|
||||
(ev-cal-check!
|
||||
"monthly preserves time of day"
|
||||
(ev-dt-tod
|
||||
(get
|
||||
(first
|
||||
(ev-expand
|
||||
md
|
||||
(ev-date 2026 1 1)
|
||||
(ev-date 2026 4 1)))
|
||||
:start))
|
||||
540)))
|
||||
(let
|
||||
((mm (ev-event (quote mm) (ev-dt 2026 1 1 9 0) 60 {:bymonthday (list 1 15) :freq :monthly :count 4} 1)))
|
||||
(ev-cal-check!
|
||||
"monthly multiple bymonthday sorted within month"
|
||||
(ev-cal-starts
|
||||
(ev-expand
|
||||
mm
|
||||
(ev-date 2026 1 1)
|
||||
(ev-date 2026 12 1)))
|
||||
(list
|
||||
(list 2026 1 1)
|
||||
(list 2026 1 15)
|
||||
(list 2026 2 1)
|
||||
(list 2026 2 15))))
|
||||
(let
|
||||
((ml (ev-event (quote ml) (ev-dt 2026 1 31 9 0) 60 {:bymonthday (list -1) :freq :monthly} 1)))
|
||||
(ev-cal-check!
|
||||
"monthly bymonthday -1 is last day"
|
||||
(ev-cal-starts
|
||||
(ev-expand
|
||||
ml
|
||||
(ev-date 2026 1 1)
|
||||
(ev-date 2026 4 1)))
|
||||
(list
|
||||
(list 2026 1 31)
|
||||
(list 2026 2 28)
|
||||
(list 2026 3 31))))
|
||||
(let
|
||||
((mn (ev-event (quote mn) (ev-dt 2026 1 1 9 0) 60 {:freq :monthly :byday (list {:ord 2 :wd 1})} 1)))
|
||||
(ev-cal-check!
|
||||
"monthly 2nd tuesday"
|
||||
(ev-cal-shape
|
||||
(ev-expand
|
||||
mn
|
||||
(ev-date 2026 1 1)
|
||||
(ev-date 2026 4 1)))
|
||||
(list
|
||||
(list (list 2026 1 13) 1)
|
||||
(list (list 2026 2 10) 1)
|
||||
(list (list 2026 3 10) 1))))
|
||||
(let
|
||||
((mz (ev-event (quote mz) (ev-dt 2026 1 1 9 0) 60 {:freq :monthly :byday (list {:ord -1 :wd 4})} 1)))
|
||||
(ev-cal-check!
|
||||
"monthly last friday"
|
||||
(ev-cal-shape
|
||||
(ev-expand
|
||||
mz
|
||||
(ev-date 2026 1 1)
|
||||
(ev-date 2026 4 1)))
|
||||
(list
|
||||
(list (list 2026 1 30) 4)
|
||||
(list (list 2026 2 27) 4)
|
||||
(list (list 2026 3 27) 4))))
|
||||
(let
|
||||
((m31 (ev-event (quote m31) (ev-dt 2026 1 31 9 0) 60 {:freq :monthly :count 4} 1)))
|
||||
(ev-cal-check!
|
||||
"monthly default day-of-month skips short months"
|
||||
(ev-cal-starts
|
||||
(ev-expand
|
||||
m31
|
||||
(ev-date 2026 1 1)
|
||||
(ev-date 2026 12 1)))
|
||||
(list
|
||||
(list 2026 1 31)
|
||||
(list 2026 3 31)
|
||||
(list 2026 5 31)
|
||||
(list 2026 7 31))))
|
||||
(let
|
||||
((mi (ev-event (quote mi) (ev-dt 2026 1 10 9 0) 60 {:interval 3 :freq :monthly :count 3} 1)))
|
||||
(ev-cal-check!
|
||||
"monthly interval 3 steps by quarter"
|
||||
(ev-cal-starts
|
||||
(ev-expand
|
||||
mi
|
||||
(ev-date 2026 1 1)
|
||||
(ev-date 2027 1 1)))
|
||||
(list
|
||||
(list 2026 1 10)
|
||||
(list 2026 4 10)
|
||||
(list 2026 7 10))))
|
||||
(let
|
||||
((mc (ev-event (quote mc) (ev-dt 2026 1 5 9 0) 60 {:freq :monthly :count 12} 1)))
|
||||
(ev-cal-check!
|
||||
"monthly count window-independent (clip middle)"
|
||||
(ev-cal-starts
|
||||
(ev-expand
|
||||
mc
|
||||
(ev-date 2026 4 1)
|
||||
(ev-date 2026 6 30)))
|
||||
(list
|
||||
(list 2026 4 5)
|
||||
(list 2026 5 5)
|
||||
(list 2026 6 5))))
|
||||
(let
|
||||
((a (ev-event (quote a) (ev-dt 2026 6 2 10 0) 30 {:freq :daily :count 2} 1))
|
||||
(b
|
||||
(ev-event
|
||||
(quote b)
|
||||
(ev-dt 2026 6 1 9 0)
|
||||
30
|
||||
{:freq :daily :count 2}
|
||||
1)))
|
||||
(ev-cal-check!
|
||||
"expand-all sorts merged occurrences by start"
|
||||
(map
|
||||
(fn (o) (list (get o :id) (ev-dt->civil (get o :start))))
|
||||
(ev-expand-all
|
||||
(list a b)
|
||||
(ev-date 2026 6 1)
|
||||
(ev-date 2026 7 1)))
|
||||
(list
|
||||
(list (quote b) (list 2026 6 1))
|
||||
(list (quote b) (list 2026 6 2))
|
||||
(list (quote a) (list 2026 6 2))
|
||||
(list (quote a) (list 2026 6 3))))))))
|
||||
|
||||
(define
|
||||
ev-calendar-tests-run!
|
||||
(fn
|
||||
()
|
||||
(do
|
||||
(set! ev-cal-pass 0)
|
||||
(set! ev-cal-fail 0)
|
||||
(set! ev-cal-failures (list))
|
||||
(ev-cal-run-all!)
|
||||
{:failures ev-cal-failures :total (+ ev-cal-pass ev-cal-fail) :passed ev-cal-pass :failed ev-cal-fail})))
|
||||
141
lib/flow/README.md
Normal file
141
lib/flow/README.md
Normal file
@@ -0,0 +1,141 @@
|
||||
# flow — durable DAG workflows on Scheme
|
||||
|
||||
`flow` is a workflow engine for rose-ash: content pipelines (write → review →
|
||||
publish → federate), scheduled jobs, and multi-step user flows (signup, confirm,
|
||||
onboard) that **survive process restarts**. It is a thin Scheme prelude over the
|
||||
Scheme-on-SX guest (`lib/scheme/`); a flow runs *inside* the interpreter.
|
||||
|
||||
Run the suite: `bash lib/flow/conformance.sh` → **151/151 across 10 suites**.
|
||||
|
||||
## Model
|
||||
|
||||
A **flow** is just a Scheme procedure of one argument — the upstream value:
|
||||
|
||||
```
|
||||
node : input -> output
|
||||
```
|
||||
|
||||
Combinators build composite nodes out of child nodes. A node that ignores its
|
||||
argument is effectively a thunk. There is no separate "graph" object: composition
|
||||
*is* function composition, so flows are values you can name, pass, and nest.
|
||||
|
||||
```scheme
|
||||
(defflow publish
|
||||
(sequence
|
||||
(lambda (draft) (string-append draft "!"))
|
||||
(branch (lambda (post) (>= (string-length post) 3))
|
||||
(remote-node 'fed 'publish)
|
||||
(flow-const 'rejected))))
|
||||
|
||||
(flow/start publish "hello") ; => federated, or a (flow-suspended id tag) state
|
||||
```
|
||||
|
||||
## Building blocks (`spec.sx`)
|
||||
|
||||
| Combinator | Meaning |
|
||||
|---|---|
|
||||
| `(flow-node f)` / `(flow-id x)` / `(flow-const v)` | leaf nodes |
|
||||
| `(sequence n ...)` | thread input left-to-right |
|
||||
| `(parallel n ...)` | fan input to every child, join results into a list (sequential eval) |
|
||||
| `(map-flow node)` | run `node` over each item of a list input, join results |
|
||||
| `(flow-while pred body max)` / `(flow-until ...)` | bounded iteration (cap `max` steps) |
|
||||
| `(defflow name body)` | bind + register a named flow (so it survives restart) |
|
||||
|
||||
## Control flow + errors (`spec.sx`)
|
||||
|
||||
| Combinator | Meaning |
|
||||
|---|---|
|
||||
| `(branch pred then else)` | `pred` on input selects `then`/`else` (`cond` is a Scheme special form) |
|
||||
| `(retry n node)` | re-run on a *raised exception*, up to `n` attempts |
|
||||
| `(timeout budget node)` | cooperative **step budget**: nodes call `(tick)`; the `(budget+1)`-th tick raises `flow-timeout` |
|
||||
| `(try-catch node handler)` | catch a raised exception → `(handler error)` |
|
||||
| `(fail reason)` / `(failed? x)` / `(fail-reason x)` | explicit failure *values* (flow downstream as data) |
|
||||
| `(recover node handler)` | the fail-VALUE counterpart of try-catch |
|
||||
| `(attempt n ...)` | railway sequence: stop at the first node returning a `(fail ...)` |
|
||||
| `(tap effect)` | run a side effect, return input unchanged |
|
||||
|
||||
**Two error channels, on purpose.** Raised exceptions are for *bugs/transients*
|
||||
(caught by `retry`/`try-catch`). `(fail reason)` values are for *expected business
|
||||
outcomes* (validation rejected, declined) and compose via `attempt`/`recover`.
|
||||
|
||||
## Suspend / resume — the durable core (`spec.sx`, `store.sx`)
|
||||
|
||||
The guest Scheme's `call/cc` is **escape-only** — re-invoking a captured
|
||||
continuation after it returns *hangs* the runtime. So flow does **not** serialize
|
||||
continuations. Instead it uses **deterministic replay**:
|
||||
|
||||
- `(suspend tag)` — if `tag` is already in the replay log, return its logged value;
|
||||
otherwise escape to the driver as `(flow-suspended tag)`.
|
||||
- `resume` appends `(tag value)` to the log and **re-runs the flow from the start**.
|
||||
Already-resolved suspends replay their values; the first unresolved one escapes
|
||||
again (or the flow completes).
|
||||
|
||||
The entire persisted state is the replay log — plain data. No live continuation is
|
||||
ever stored, so flows survive process restarts and even moves between instances.
|
||||
|
||||
> **Author contract:** suspend `tag`s must be unique and deterministic across
|
||||
> replays, and **all** non-determinism / side effects must go through suspend
|
||||
> points (so their results are logged) — otherwise they re-run on every replay.
|
||||
|
||||
### Lifecycle (`store.sx`)
|
||||
|
||||
```scheme
|
||||
(flow/start flow input) ; raw result if it completes, else (flow-suspended id tag)
|
||||
(flow/resume id value) ; inject value at the waiting tag, continue
|
||||
(flow/cancel id) ; terminate; a later resume is rejected
|
||||
```
|
||||
|
||||
### Introspection & hygiene
|
||||
|
||||
```scheme
|
||||
(flow/status id) ; done | suspended | cancelled | unknown
|
||||
(flow/result id) ; result if done, else (flow-error reason)
|
||||
(flow/list) ; ((id status) ...)
|
||||
(flow/pending) ; ((id waiting-tag) ...) — what each suspended flow awaits
|
||||
(flow/gc) ; drop terminal records, keep live ones; returns count removed
|
||||
(flow/forget id) ; drop one terminal record (refuses live flows)
|
||||
```
|
||||
|
||||
### Crash recovery
|
||||
|
||||
```scheme
|
||||
(flow-store-export) ; the store as plain data (live procs nulled)
|
||||
(flow-store-import! d) ; restore the store from exported data
|
||||
(flow-resumable-ids) ; ids of suspended flows to wake on restart
|
||||
```
|
||||
|
||||
On restart the flow definitions are reloaded (`defflow` re-registers names) and the
|
||||
exported store reimported; `resume` re-resolves each flow's procedure **by name**.
|
||||
|
||||
## Distribution via fed-sx (`remote.sx`)
|
||||
|
||||
```scheme
|
||||
(flow-peer-register! addr table) ; mock a peer's exposed functions (fed-sx boundary)
|
||||
(remote-node addr fn) ; run a node on a peer
|
||||
(remote-failover addrs fn local) ; try peers in order, fall through to a local node
|
||||
(flow-replicate-to addr) ; copy this store to a peer's replica slot
|
||||
(flow-restore-from addr) ; import a peer's replica (handoff)
|
||||
```
|
||||
|
||||
**Handoff** is crash recovery across instances: replicate → local instance dies →
|
||||
peer restores the (plain-data) store and resumes. The replay log carries over, so
|
||||
all resolved suspends survive the move.
|
||||
|
||||
## Files
|
||||
|
||||
| File | Contents |
|
||||
|---|---|
|
||||
| `spec.sx` | combinators (flow-combinators-src / flow-control-src / flow-suspend-src) |
|
||||
| `store.sx` | durable store, lifecycle, crash recovery, introspection, hygiene |
|
||||
| `remote.sx` | fed-sx transport (mock peer registry), failover, replication |
|
||||
| `api.sx` | `flow-make-env` / `flow-run` SX helpers (one cached env, per-test reset) |
|
||||
| `tests/*.sx` | 10 suites, 151 cases |
|
||||
| `conformance.sh` | loads substrate + flow layer, runs every suite |
|
||||
|
||||
## Notes on the substrate
|
||||
|
||||
The guest Scheme (`lib/scheme/`, imported read-only) lacks dotted-rest params
|
||||
`(a . rest)` and named `let`; combinators use `(lambda args ...)` variadics + top-
|
||||
level recursion. `cons` is list-only (no dotted pairs), so log/assoc entries are
|
||||
2-element lists. Strings box as `{:scm-string "..."}`. Timeout is a step budget
|
||||
because there is no wall clock; `parallel` is sequential for the same reason.
|
||||
65
lib/flow/api.sx
Normal file
65
lib/flow/api.sx
Normal file
@@ -0,0 +1,65 @@
|
||||
;; lib/flow/api.sx — flow runtime entry points.
|
||||
;;
|
||||
;; Builds a Scheme env preloaded with the flow combinators (lib/flow/spec.sx),
|
||||
;; the durable store + lifecycle (lib/flow/store.sx), the fed-sx remote layer
|
||||
;; (lib/flow/remote.sx), and the host integration ABI (lib/flow/host.sx), and
|
||||
;; provides SX helpers to run flow programs.
|
||||
;;
|
||||
;; Scheme-level API (available inside flow programs):
|
||||
;; (flow/start flow input) — run a flow; raw result if it completes, else
|
||||
;; (flow-suspended id tag). Defined in store.sx.
|
||||
;; (flow/resume id value) — resume a suspended flow (store.sx)
|
||||
;; (flow/cancel id) — cancel a flow (store.sx)
|
||||
;; (suspend tag) — suspension point (spec.sx)
|
||||
;; (request kind payload) — host request envelope over suspend (host.sx)
|
||||
;; (remote-node addr fn) — node executed on a federation peer (remote.sx)
|
||||
;;
|
||||
;; SX-level helpers (for hosts and tests):
|
||||
;; (flow-make-env) — fresh standard env + combinators + store + remote + host
|
||||
;; (flow-run src) — eval a Scheme program string in a reset shared env
|
||||
;; (flow-run-in env src) — eval a Scheme program string in a given env
|
||||
;;
|
||||
;; flow-run reuses ONE env (building the full standard env is expensive) and
|
||||
;; resets the mutable flow globals before each program, so tests stay isolated
|
||||
;; without paying for a fresh standard env each time. flow-registry persists (it
|
||||
;; models reloaded flow definitions surviving a restart).
|
||||
|
||||
(define
|
||||
flow-make-env
|
||||
(fn
|
||||
()
|
||||
(let
|
||||
((env (scheme-standard-env)))
|
||||
(flow-load-combinators! env)
|
||||
(flow-load-store! env)
|
||||
(flow-load-remote! env)
|
||||
(flow-load-host! env)
|
||||
env)))
|
||||
|
||||
(define
|
||||
flow-run-in
|
||||
(fn (env src) (scheme-eval-program (scheme-parse-all src) env)))
|
||||
|
||||
(define
|
||||
flow-reset-src
|
||||
"(set! flow-store (list)) (set! flow-next-id 0) (set! flow-replay-log (list)) (set! flow-suspend-k #f) (set! flow-timeout-budget -1) (set! flow-peers (list)) (set! flow-replicas (list))")
|
||||
|
||||
(define flow-env-cache false)
|
||||
|
||||
(define
|
||||
flow-shared-env
|
||||
(fn
|
||||
()
|
||||
(begin
|
||||
(if flow-env-cache nil (set! flow-env-cache (flow-make-env)))
|
||||
flow-env-cache)))
|
||||
|
||||
(define
|
||||
flow-run
|
||||
(fn
|
||||
(src)
|
||||
(let
|
||||
((env (flow-shared-env)))
|
||||
(begin
|
||||
(scheme-eval-program (scheme-parse-all flow-reset-src) env)
|
||||
(scheme-eval-program (scheme-parse-all src) env)))))
|
||||
103
lib/flow/conformance.sh
Executable file
103
lib/flow/conformance.sh
Executable file
@@ -0,0 +1,103 @@
|
||||
#!/usr/bin/env bash
|
||||
# flow-on-sx conformance runner — runs all flow test suites in one sx_server process.
|
||||
#
|
||||
# Usage:
|
||||
# bash lib/flow/conformance.sh # run all suites
|
||||
# bash lib/flow/conformance.sh -v # verbose (list each suite)
|
||||
|
||||
set -uo pipefail
|
||||
cd "$(git rev-parse --show-toplevel)"
|
||||
|
||||
SX_SERVER="${SX_SERVER:-hosts/ocaml/_build/default/bin/sx_server.exe}"
|
||||
if [ ! -x "$SX_SERVER" ]; then
|
||||
SX_SERVER="/root/rose-ash/hosts/ocaml/_build/default/bin/sx_server.exe"
|
||||
fi
|
||||
if [ ! -x "$SX_SERVER" ]; then
|
||||
echo "ERROR: sx_server.exe not found." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
VERBOSE="${1:-}"
|
||||
|
||||
# Suites: NAME RUNNER-FN PATH
|
||||
SUITES=(
|
||||
"basic flow-basic-tests-run! lib/flow/tests/basic.sx"
|
||||
"control flow-ctl-tests-run! lib/flow/tests/control.sx"
|
||||
"suspend flow-sus-tests-run! lib/flow/tests/suspend.sx"
|
||||
"recovery flow-rec-tests-run! lib/flow/tests/recovery.sx"
|
||||
"distributed flow-dist-tests-run! lib/flow/tests/distributed.sx"
|
||||
"api flow-api-tests-run! lib/flow/tests/api.sx"
|
||||
"combinators flow-cmb-tests-run! lib/flow/tests/combinators.sx"
|
||||
"railway flow-rail-tests-run! lib/flow/tests/railway.sx"
|
||||
"integration flow-int-tests-run! lib/flow/tests/integration.sx"
|
||||
"hygiene flow-hyg-tests-run! lib/flow/tests/hygiene.sx"
|
||||
"host flow-hst-tests-run! lib/flow/tests/host.sx"
|
||||
)
|
||||
|
||||
TMPFILE=$(mktemp); trap "rm -f $TMPFILE" EXIT
|
||||
EPOCH=1
|
||||
|
||||
emit_load () { echo "(epoch $EPOCH)"; echo "(load \"$1\")"; EPOCH=$((EPOCH+1)); }
|
||||
emit_eval () { echo "(epoch $EPOCH)"; echo "(eval \"$1\")"; EPOCH=$((EPOCH+1)); }
|
||||
|
||||
{
|
||||
emit_load "lib/guest/lex.sx"
|
||||
emit_load "lib/guest/reflective/env.sx"
|
||||
emit_load "lib/guest/reflective/quoting.sx"
|
||||
emit_load "lib/scheme/parser.sx"
|
||||
emit_load "lib/scheme/eval.sx"
|
||||
emit_load "lib/scheme/runtime.sx"
|
||||
emit_load "lib/flow/spec.sx"
|
||||
emit_load "lib/flow/store.sx"
|
||||
emit_load "lib/flow/remote.sx"
|
||||
emit_load "lib/flow/host.sx"
|
||||
emit_load "lib/flow/api.sx"
|
||||
for SUITE in "${SUITES[@]}"; do
|
||||
read -r _NAME _RUNNER FILE <<< "$SUITE"
|
||||
emit_load "$FILE"
|
||||
emit_eval "($_RUNNER)"
|
||||
done
|
||||
} > "$TMPFILE"
|
||||
|
||||
OUTPUT=$(timeout 540 "$SX_SERVER" < "$TMPFILE" 2>&1 || true)
|
||||
|
||||
TOTAL_PASS=0
|
||||
TOTAL_FAIL=0
|
||||
FAILED_SUITES=()
|
||||
|
||||
LAST_DICT_LINES=$(echo "$OUTPUT" | grep -E '^\{:' || true)
|
||||
|
||||
I=0
|
||||
while read -r LINE; do
|
||||
[ -z "$LINE" ] && continue
|
||||
P=$(echo "$LINE" | grep -oE ':passed [0-9]+' | awk '{print $2}')
|
||||
F=$(echo "$LINE" | grep -oE ':failed [0-9]+' | awk '{print $2}')
|
||||
[ -z "$P" ] && P=0
|
||||
[ -z "$F" ] && F=0
|
||||
SUITE_INFO="${SUITES[$I]}"
|
||||
SUITE_NAME=$(echo "$SUITE_INFO" | awk '{print $1}')
|
||||
TOTAL_PASS=$((TOTAL_PASS + P))
|
||||
TOTAL_FAIL=$((TOTAL_FAIL + F))
|
||||
if [ "$F" -gt 0 ]; then
|
||||
FAILED_SUITES+=("$SUITE_NAME: $P/$((P+F))")
|
||||
printf 'X %-12s %d/%d\n' "$SUITE_NAME" "$P" "$((P+F))"
|
||||
echo "$LINE" | grep -oE ':name "[^"]*"' | sed 's/:name / fail: /'
|
||||
elif [ "$VERBOSE" = "-v" ]; then
|
||||
printf 'ok %-12s %d passed\n' "$SUITE_NAME" "$P"
|
||||
fi
|
||||
I=$((I+1))
|
||||
done <<< "$LAST_DICT_LINES"
|
||||
|
||||
TOTAL=$((TOTAL_PASS + TOTAL_FAIL))
|
||||
if [ "$TOTAL" -eq 0 ]; then
|
||||
echo "ERROR: no suite results parsed. Raw output:" >&2
|
||||
echo "$OUTPUT" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ $TOTAL_FAIL -eq 0 ]; then
|
||||
echo "ok $TOTAL_PASS/$TOTAL flow-on-sx tests passed (${#SUITES[@]} suites)"
|
||||
else
|
||||
echo "FAIL $TOTAL_PASS/$TOTAL passed, $TOTAL_FAIL failed:"
|
||||
for S in "${FAILED_SUITES[@]}"; do echo " $S"; done
|
||||
exit 1
|
||||
fi
|
||||
42
lib/flow/host.sx
Normal file
42
lib/flow/host.sx
Normal file
@@ -0,0 +1,42 @@
|
||||
;; lib/flow/host.sx — the host integration ABI (Phase 8).
|
||||
;;
|
||||
;; `suspend` is flow's seam to the outside world, but a bare (suspend tag) is just a
|
||||
;; signal — every author would invent their own tag shape. This layer defines a
|
||||
;; stable request/response contract so a host (e.g. an art-dag driver, or a human
|
||||
;; review UI) can hook in WITHOUT reverse-engineering ad-hoc tags.
|
||||
;;
|
||||
;; A flow asks the host to do something and waits for the answer:
|
||||
;; (request kind payload) — suspend with a typed envelope (flow-request kind
|
||||
;; payload); evaluates to the host's resume value.
|
||||
;; (await-human prompt) — request kind=human (a decision point)
|
||||
;; (await-render recipe) — request kind=render (e.g. an art-dag job)
|
||||
;; (await-effect kind p) — request of an arbitrary kind
|
||||
;;
|
||||
;; The host drives flows by polling its work queue and resuming:
|
||||
;; (flow-host-requests) — ((id kind payload) ...) for every SUSPENDED flow whose
|
||||
;; waiting tag is a host request. The host dispatches by kind (render -> submit a
|
||||
;; Celery job; human -> show UI), then calls (flow/resume id answer).
|
||||
;; (request? tag) / (request-kind tag) / (request-payload tag) — parse one tag.
|
||||
;;
|
||||
;; Reference driver — the host only supplies `dispatch`, a (kind payload) -> answer:
|
||||
;; (flow-drive-host dispatch) — one tick: service every CURRENTLY pending
|
||||
;; request (snapshot), resuming each with (dispatch kind payload); returns the
|
||||
;; count serviced. Resumes may create new requests — serviced on the next tick.
|
||||
;; (flow-run-host dispatch maxticks) — tick until quiescent (no pending requests)
|
||||
;; or maxticks reached; returns total requests serviced. Bounded for determinism.
|
||||
;;
|
||||
;; Contract: the host owns IO and persistence. flow stays deterministic — a flow
|
||||
;; never performs IO itself, it only `request`s; the host performs the effect and
|
||||
;; feeds the result back via resume (which the replay log records, so the effect is
|
||||
;; not re-run on recovery). Persist with flow-store-export after each transition and
|
||||
;; flow-store-import! on boot.
|
||||
|
||||
(define
|
||||
flow-host-src
|
||||
"(define (request kind payload) (suspend (list (quote flow-request) kind payload)))\n (define (request? tag) (and (pair? tag) (eq? (car tag) (quote flow-request))))\n (define (request-kind tag) (car (cdr tag)))\n (define (request-payload tag) (car (cdr (cdr tag))))\n (define (await-human prompt) (request (quote human) prompt))\n (define (await-render recipe) (request (quote render) recipe))\n (define (await-effect kind payload) (request kind payload))\n (define (flow-host-req-step pend)\n (if (null? pend)\n (list)\n (let ((id (car (car pend))) (tag (car (cdr (car pend)))))\n (if (request? tag)\n (cons (list id (request-kind tag) (request-payload tag))\n (flow-host-req-step (cdr pend)))\n (flow-host-req-step (cdr pend))))))\n (define (flow-host-requests) (flow-host-req-step (flow/pending)))\n (define (flow-drive-host-step reqs dispatch)\n (if (null? reqs)\n 0\n (begin\n (flow/resume (car (car reqs)) (dispatch (car (cdr (car reqs))) (car (cdr (cdr (car reqs))))))\n (+ 1 (flow-drive-host-step (cdr reqs) dispatch)))))\n (define (flow-drive-host dispatch) (flow-drive-host-step (flow-host-requests) dispatch))\n (define (flow-run-host dispatch maxticks)\n (if (<= maxticks 0)\n 0\n (let ((n (flow-drive-host dispatch)))\n (if (= n 0) 0 (+ n (flow-run-host dispatch (- maxticks 1)))))))")
|
||||
|
||||
(define
|
||||
flow-load-host!
|
||||
(fn
|
||||
(env)
|
||||
(begin (scheme-eval-program (scheme-parse-all flow-host-src) env) env)))
|
||||
34
lib/flow/remote.sx
Normal file
34
lib/flow/remote.sx
Normal file
@@ -0,0 +1,34 @@
|
||||
;; lib/flow/remote.sx — distributed nodes via fed-sx (Phase 4).
|
||||
;;
|
||||
;; A node can execute on a federation peer. The transport is the fed-sx boundary;
|
||||
;; it is MOCKED in tests by a peer registry mapping addr -> function table. In
|
||||
;; production flow-transport would issue a fed-sx call; here it dispatches locally.
|
||||
;;
|
||||
;; (flow-peer-register! addr table) — register a mock peer. table is a list of
|
||||
;; (fn-name proc) entries — the functions that peer exposes.
|
||||
;; (flow-transport addr fn input) — invoke fn on the peer with input. Raises
|
||||
;; (flow-remote-unreachable) if the addr is unknown, (flow-remote-no-fn) if the
|
||||
;; peer does not expose fn.
|
||||
;; (remote-node addr fn) — a node that runs fn on the peer at addr.
|
||||
;; (remote-failover addrs fn local) — try fn on each peer in addrs in order; on a
|
||||
;; raised error move to the next peer; if every peer fails, run the `local`
|
||||
;; node as a fallback.
|
||||
;;
|
||||
;; Persistence across instances + handoff. Each instance runs the same flow
|
||||
;; definitions, so the only thing that needs to cross the wire is the (plain-data)
|
||||
;; store — exactly flow-store-export from store.sx. Replication pushes that export
|
||||
;; to a peer's replica slot; handoff = restore the replica on the peer and resume.
|
||||
;;
|
||||
;; (flow-replicate-to addr) — copy this instance's store to peer addr's replica
|
||||
;; (flow-restore-from addr) — import the replica from peer addr (#t / #f)
|
||||
;; (flow-replica-get addr) — the raw replicated store at addr (or #f)
|
||||
|
||||
(define
|
||||
flow-remote-src
|
||||
"(define flow-peers (list))\n (define (flow-assoc key alist)\n (if (null? alist)\n #f\n (if (eq? (car (car alist)) key) (car (cdr (car alist))) (flow-assoc key (cdr alist)))))\n (define (flow-peer-register! addr table) (set! flow-peers (cons (list addr table) flow-peers)))\n (define (flow-transport addr fn input)\n (let ((table (flow-assoc addr flow-peers)))\n (if table\n (let ((proc (flow-assoc fn table)))\n (if proc (proc input) (raise (quote flow-remote-no-fn))))\n (raise (quote flow-remote-unreachable)))))\n (define (remote-node addr fn) (lambda (input) (flow-transport addr fn input)))\n (define (flow-failover-step addrs fn input local)\n (if (null? addrs)\n (local input)\n (guard (e (#t (flow-failover-step (cdr addrs) fn input local)))\n (flow-transport (car addrs) fn input))))\n (define (remote-failover addrs fn local)\n (lambda (input) (flow-failover-step addrs fn input local)))\n\n (define flow-replicas (list))\n (define (flow-replicas-remove addr reps)\n (if (null? reps)\n (list)\n (if (eq? (car (car reps)) addr)\n (flow-replicas-remove addr (cdr reps))\n (cons (car reps) (flow-replicas-remove addr (cdr reps))))))\n (define (flow-replicate-to addr)\n (set! flow-replicas (cons (list addr (flow-store-export)) (flow-replicas-remove addr flow-replicas))))\n (define (flow-replica-get addr) (flow-assoc addr flow-replicas))\n (define (flow-restore-from addr)\n (let ((data (flow-replica-get addr)))\n (if data (begin (flow-store-import! data) #t) #f)))")
|
||||
|
||||
(define
|
||||
flow-load-remote!
|
||||
(fn
|
||||
(env)
|
||||
(begin (scheme-eval-program (scheme-parse-all flow-remote-src) env) env)))
|
||||
19
lib/flow/scoreboard.json
Normal file
19
lib/flow/scoreboard.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"total": 166,
|
||||
"passed": 166,
|
||||
"failed": 0,
|
||||
"suites": {
|
||||
"basic": { "passed": 18, "total": 18 },
|
||||
"control": { "passed": 31, "total": 31 },
|
||||
"suspend": { "passed": 17, "total": 17 },
|
||||
"recovery": { "passed": 8, "total": 8 },
|
||||
"distributed": { "passed": 19, "total": 19 },
|
||||
"api": { "passed": 12, "total": 12 },
|
||||
"combinators": { "passed": 17, "total": 17 },
|
||||
"railway": { "passed": 10, "total": 10 },
|
||||
"integration": { "passed": 10, "total": 10 },
|
||||
"hygiene": { "passed": 9, "total": 9 },
|
||||
"host": { "passed": 15, "total": 15 }
|
||||
},
|
||||
"phases": { "phase1": "done", "phase2": "done", "phase3": "done", "phase4": "done", "phase5": "done", "phase6": "done", "phase7": "done", "phase8": "done" }
|
||||
}
|
||||
53
lib/flow/scoreboard.md
Normal file
53
lib/flow/scoreboard.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# flow-on-sx Scoreboard
|
||||
|
||||
**All tests pass: 166 / 166 across 11 suites. Phases 1-8 complete.**
|
||||
|
||||
`bash lib/flow/conformance.sh`
|
||||
|
||||
## Per-suite breakdown
|
||||
|
||||
| Suite | Passing | Covers |
|
||||
|-------|--------:|--------|
|
||||
| basic | 18 | Phase 1: single nodes, linear sequence, data-flow threading, defflow, parallel fan/join, nested composition, publish-shaped flow |
|
||||
| control | 31 | Phase 2: `branch` (6); error model `fail`/`failed?`/`fail-reason` (6); `try-catch` (6); `retry n` (6); `timeout` cooperative step budget (7) |
|
||||
| suspend | 17 | Phase 3: suspend/resume/cancel via deterministic replay; multi-step, replay determinism, lifecycle guards, suspend-in-branch |
|
||||
| recovery | 8 | Phase 3: crash recovery — store export/import, resumable scan, restart-at-every-step, replay-log survival |
|
||||
| distributed | 19 | Phase 4: `remote-node` (7); `remote-failover` (6); replication + handoff across instances (6) |
|
||||
| api | 12 | Phase 5: introspection — `flow/status`, `flow/result`, `flow/list`, `flow/pending` |
|
||||
| combinators | 17 | Phase 5: `tap`, `recover` (fail-value), `map-flow` fan-over-list, `flow-while`/`flow-until` bounded iteration |
|
||||
| railway | 10 | Phase 6: `attempt` — fail-value short-circuiting sequence + recover rejoin |
|
||||
| integration | 10 | Phase 7: end-to-end order + onboarding flows composing every phase (suspend, branch, federation, crash recovery, handoff, introspection) |
|
||||
| hygiene | 9 | Phase 5: `flow/gc` (prune terminal flows), `flow/forget` (drop one terminal record) |
|
||||
| host | 15 | Phase 8: host ABI — `request`/`await-human`/`await-render`, `flow-host-requests` queue, `flow-run-host` reference driver; art-dag-shaped render→review→publish loop |
|
||||
|
||||
## Architecture
|
||||
|
||||
Flow combinators are a **Scheme prelude** (`lib/flow/spec.sx`) loaded onto
|
||||
`scheme-standard-env`. A flow is a Scheme procedure `input -> output`. The whole
|
||||
flow executes inside the Scheme interpreter, so Phase 3's `suspend` (call/cc) will
|
||||
capture the flow continuation directly.
|
||||
|
||||
- `lib/flow/spec.sx` — combinators: `flow-node`, `flow-id`, `flow-const`,
|
||||
`sequence`, `parallel`, `defflow`; `flow-load-combinators!`.
|
||||
- `lib/flow/api.sx` — `flow/start` (Scheme); `flow-make-env`, `flow-run`,
|
||||
`flow-run-in` (SX helpers).
|
||||
- `lib/flow/tests/basic.sx` — 18 cases.
|
||||
- `lib/flow/conformance.sh` — loads substrate + flow layer, runs suites.
|
||||
|
||||
## Semantics notes
|
||||
|
||||
- **node** = 1-arg Scheme procedure; the upstream value is the argument. A node
|
||||
ignoring its argument is effectively a thunk.
|
||||
- **sequence** threads left-to-right; empty sequence = identity.
|
||||
- **parallel** fans the same input to every branch and joins results into a list.
|
||||
Evaluation is **sequential** for now; true concurrency arrives in Phase 3.
|
||||
|
||||
## Phases
|
||||
|
||||
- [x] Phase 1 — Declarative DAG + sequential execution (combinators + 18 tests, `flow/start`)
|
||||
- [x] Phase 2 — Control flow + error handling (branch, error model, try-catch, retry, timeout)
|
||||
- [x] Phase 3 — Suspend/resume (suspend/resume/cancel + crash recovery via deterministic replay)
|
||||
- [x] Phase 4 — Distributed nodes via fed-sx (remote-node, failover, replication + handoff)
|
||||
- [x] Phase 5 — Operational API + combinators (introspection, tap, recover, map-flow)
|
||||
- [ ] Phase 3 — Suspend / resume (the showcase)
|
||||
- [ ] Phase 4 — Distributed nodes via fed-sx
|
||||
61
lib/flow/spec.sx
Normal file
61
lib/flow/spec.sx
Normal file
@@ -0,0 +1,61 @@
|
||||
;; lib/flow/spec.sx — flow combinators as a Scheme prelude.
|
||||
;;
|
||||
;; A flow is a Scheme procedure of one argument: the upstream value.
|
||||
;; node : input -> output
|
||||
;; A leaf node ignoring its argument is effectively a thunk. Combinators
|
||||
;; build composite nodes out of child nodes. The whole flow runs INSIDE the
|
||||
;; Scheme interpreter.
|
||||
;;
|
||||
;; Phase 1 combinators (flow-combinators-src):
|
||||
;; flow-node / flow-id / flow-const / sequence / parallel / defflow
|
||||
;; defflow both binds the flow and registers it by name (flow-register!, in
|
||||
;; store.sx) so it can be re-resolved after a process restart.
|
||||
;; map-flow (Phase 5): run a node over each item of a list input, join results.
|
||||
;; flow-while / flow-until (Phase 5): bounded iteration — re-run body, threading
|
||||
;; the value, while/until pred holds, up to `max` steps (deterministic bound; no
|
||||
;; unbounded loops in pure SX).
|
||||
;;
|
||||
;; Phase 2 combinators (flow-control-src):
|
||||
;; branch / fail / failed? / fail-reason / try-catch / retry / timeout / tick
|
||||
;; tap (Phase 5): side-effecting pass-through (returns input unchanged).
|
||||
;; recover (Phase 5): the fail-VALUE counterpart of try-catch.
|
||||
;; attempt (Phase 6): railway sequence — thread nodes left-to-right but stop at
|
||||
;; the first node that returns a (fail ...) value, returning that failure.
|
||||
;;
|
||||
;; Phase 3 suspend core (flow-suspend-src):
|
||||
;; The guest Scheme's call/cc is ESCAPE-ONLY (re-invoking a captured k after it
|
||||
;; returns hangs the runtime), so suspend/resume CANNOT re-enter a continuation.
|
||||
;; Instead, durability uses DETERMINISTIC REPLAY: a flow re-runs from the start
|
||||
;; on each resume; suspend points that have already been resolved replay their
|
||||
;; logged value, and the first unresolved suspend escapes back to the driver.
|
||||
;; The entire persisted state is the replay log (plain (tag value) data), which
|
||||
;; survives process restart — no live continuation is ever serialized.
|
||||
;;
|
||||
;; (suspend tag) — if tag is in the replay log, return its value; else escape
|
||||
;; to the driver as (flow-suspended tag). tags must be unique & deterministic
|
||||
;; across replays. ALL effects/non-determinism must go through suspend so their
|
||||
;; results are logged (otherwise they re-run on every replay).
|
||||
;; (flow-drive flow input log) — run flow with the given replay log; returns
|
||||
;; (flow-done result) or (flow-suspended tag).
|
||||
|
||||
(define
|
||||
flow-combinators-src
|
||||
"(define (flow-node f) f)\n (define (flow-id input) input)\n (define (flow-const v) (lambda (input) v))\n (define (flow-seq-step ns v)\n (if (null? ns) v (flow-seq-step (cdr ns) ((car ns) v))))\n (define sequence (lambda ns (lambda (input) (flow-seq-step ns input))))\n (define parallel (lambda ns (lambda (input) (map (lambda (n) (n input)) ns))))\n (define (map-flow node) (lambda (items) (map node items)))\n (define (flow-while-step pred body input n)\n (if (<= n 0)\n input\n (if (pred input) (flow-while-step pred body (body input) (- n 1)) input)))\n (define (flow-while pred body max) (lambda (input) (flow-while-step pred body input max)))\n (define (flow-until-step pred body input n)\n (if (<= n 0)\n input\n (if (pred input) input (flow-until-step pred body (body input) (- n 1)))))\n (define (flow-until pred body max) (lambda (input) (flow-until-step pred body input max)))\n (define-syntax defflow\n (syntax-rules ()\n ((defflow nm body)\n (begin (define nm body) (flow-register! (quote nm) nm)))))")
|
||||
|
||||
(define
|
||||
flow-control-src
|
||||
"(define (branch pred then else)\n (lambda (input) (if (pred input) (then input) (else input))))\n (define (fail reason) (list (quote flow-fail) reason))\n (define (failed? x) (and (pair? x) (eq? (car x) (quote flow-fail))))\n (define (fail-reason x) (car (cdr x)))\n (define (recover node handler)\n (lambda (input)\n (let ((r (node input)))\n (if (failed? r) (handler (fail-reason r)) r))))\n (define (tap effect)\n (lambda (input) (begin (effect input) input)))\n (define (flow-attempt-step ns v)\n (if (failed? v)\n v\n (if (null? ns) v (flow-attempt-step (cdr ns) ((car ns) v)))))\n (define attempt (lambda ns (lambda (input) (flow-attempt-step ns input))))\n (define (try-catch node handler)\n (lambda (input) (guard (e (#t (handler e))) (node input))))\n (define (flow-retry-step n node input)\n (guard (e (#t (if (<= n 1) (raise e) (flow-retry-step (- n 1) node input))))\n (node input)))\n (define (retry n node) (lambda (input) (flow-retry-step n node input)))\n (define flow-timeout-budget -1)\n (define (tick)\n (if (< flow-timeout-budget 0)\n 0\n (begin\n (set! flow-timeout-budget (- flow-timeout-budget 1))\n (if (< flow-timeout-budget 0)\n (raise (quote flow-timeout))\n flow-timeout-budget))))\n (define (timeout budget node)\n (lambda (input)\n (let ((saved flow-timeout-budget))\n (set! flow-timeout-budget budget)\n (guard (e (#t (begin (set! flow-timeout-budget saved) (raise e))))\n (let ((result (node input)))\n (set! flow-timeout-budget saved)\n result)))))")
|
||||
|
||||
(define
|
||||
flow-suspend-src
|
||||
"(define flow-replay-log (list))\n (define flow-suspend-k #f)\n (define (flow-log-lookup tag log)\n (if (null? log)\n (list #f #f)\n (if (eq? (car (car log)) tag)\n (list #t (car (cdr (car log))))\n (flow-log-lookup tag (cdr log)))))\n (define (suspend tag)\n (let ((hit (flow-log-lookup tag flow-replay-log)))\n (if (car hit)\n (car (cdr hit))\n (flow-suspend-k (list (quote flow-suspended) tag)))))\n (define (flow-drive flow input log)\n (set! flow-replay-log log)\n (call/cc\n (lambda (k)\n (set! flow-suspend-k k)\n (list (quote flow-done) (flow input)))))")
|
||||
|
||||
(define
|
||||
flow-load-combinators!
|
||||
(fn
|
||||
(env)
|
||||
(begin
|
||||
(scheme-eval-program (scheme-parse-all flow-combinators-src) env)
|
||||
(scheme-eval-program (scheme-parse-all flow-control-src) env)
|
||||
(scheme-eval-program (scheme-parse-all flow-suspend-src) env)
|
||||
env)))
|
||||
45
lib/flow/store.sx
Normal file
45
lib/flow/store.sx
Normal file
File diff suppressed because one or more lines are too long
79
lib/flow/tests/api.sx
Normal file
79
lib/flow/tests/api.sx
Normal file
@@ -0,0 +1,79 @@
|
||||
;; lib/flow/tests/api.sx — Phase 5: operational introspection API.
|
||||
|
||||
(define flow-api-pass 0)
|
||||
(define flow-api-fail 0)
|
||||
(define flow-api-fails (list))
|
||||
|
||||
(define
|
||||
flow-api-test
|
||||
(fn
|
||||
(name actual expected)
|
||||
(if
|
||||
(= actual expected)
|
||||
(set! flow-api-pass (+ flow-api-pass 1))
|
||||
(begin
|
||||
(set! flow-api-fail (+ flow-api-fail 1))
|
||||
(append! flow-api-fails {:name name :expected expected :actual actual})))))
|
||||
|
||||
(define flow-a (fn (src) (flow-run src)))
|
||||
|
||||
;; ── flow/status ─────────────────────────────────────────────────
|
||||
(flow-api-test "status: unknown id" (flow-a "(flow/status 999)") "unknown")
|
||||
(flow-api-test
|
||||
"status: suspended flow"
|
||||
(flow-a
|
||||
"(defflow w (lambda (x) (suspend (quote q)))) (define id (car (cdr (flow/start w 0)))) (flow/status id)")
|
||||
"suspended")
|
||||
(flow-api-test
|
||||
"status: completed flow"
|
||||
(flow-a
|
||||
"(defflow w (sequence (lambda (x) (suspend (quote q))) (lambda (v) v))) (define id (car (cdr (flow/start w 0)))) (flow/resume id 5) (flow/status id)")
|
||||
"done")
|
||||
(flow-api-test
|
||||
"status: cancelled flow"
|
||||
(flow-a
|
||||
"(defflow w (lambda (x) (suspend (quote q)))) (define id (car (cdr (flow/start w 0)))) (flow/cancel id) (flow/status id)")
|
||||
"cancelled")
|
||||
|
||||
;; ── flow/result ─────────────────────────────────────────────────
|
||||
(flow-api-test
|
||||
"result: returns the value of a completed flow"
|
||||
(flow-a
|
||||
"(defflow w (sequence (lambda (x) (suspend (quote q))) (lambda (v) (list (quote got) v)))) (define id (car (cdr (flow/start w 0)))) (flow/resume id 9) (flow/result id)")
|
||||
(list "got" 9))
|
||||
(flow-api-test
|
||||
"result: a still-suspended flow has no result"
|
||||
(flow-a
|
||||
"(defflow w (lambda (x) (suspend (quote q)))) (define id (car (cdr (flow/start w 0)))) (flow/result id)")
|
||||
(list "flow-error" "not-done"))
|
||||
(flow-api-test
|
||||
"result: unknown id errors"
|
||||
(flow-a "(flow/result 999)")
|
||||
(list "flow-error" "no-such-flow"))
|
||||
|
||||
;; ── flow/list ───────────────────────────────────────────────────
|
||||
(flow-api-test "list: empty store" (flow-a "(flow/list)") (list))
|
||||
(flow-api-test
|
||||
"list: reports id + status for each flow (newest first)"
|
||||
(flow-a
|
||||
"(defflow w (lambda (x) (suspend (quote q)))) (flow/start w 0) (flow/start (lambda (x) (* x 2)) 5) (flow/list)")
|
||||
(list (list 2 "done") (list 1 "suspended")))
|
||||
|
||||
;; ── flow/pending ────────────────────────────────────────────────
|
||||
(flow-api-test
|
||||
"pending: lists suspended flows with their waiting tag"
|
||||
(flow-a
|
||||
"(defflow w (lambda (x) (suspend (quote review)))) (flow/start w 0) (flow/pending)")
|
||||
(list (list 1 "review")))
|
||||
(flow-api-test
|
||||
"pending: excludes completed and cancelled flows"
|
||||
(flow-a
|
||||
"(defflow w (lambda (x) (suspend (quote q)))) (defflow v (sequence (lambda (x) (suspend (quote r))) (lambda (y) y))) (define i1 (car (cdr (flow/start w 0)))) (define i2 (car (cdr (flow/start v 0)))) (define i3 (car (cdr (flow/start w 0)))) (flow/resume i2 1) (flow/cancel i3) (flow/pending)")
|
||||
(list (list 1 "q")))
|
||||
(flow-api-test
|
||||
"pending: operator can drain all pending flows"
|
||||
(flow-a
|
||||
"(defflow w (sequence (lambda (x) (suspend (quote q))) (lambda (v) (* v 10)))) (flow/start w 0) (flow/start w 0) (define ps (flow/pending)) (flow/resume (car (car ps)) 1) (flow/resume (car (car (cdr ps))) 2) (flow/list)")
|
||||
(list (list 1 "done") (list 2 "done")))
|
||||
|
||||
(define flow-api-tests-run! (fn () {:total (+ flow-api-pass flow-api-fail) :passed flow-api-pass :failed flow-api-fail :fails flow-api-fails}))
|
||||
121
lib/flow/tests/basic.sx
Normal file
121
lib/flow/tests/basic.sx
Normal file
@@ -0,0 +1,121 @@
|
||||
;; lib/flow/tests/basic.sx — Phase 1: declarative DAG + sequential execution.
|
||||
|
||||
(define flow-basic-pass 0)
|
||||
(define flow-basic-fail 0)
|
||||
(define flow-basic-fails (list))
|
||||
|
||||
(define
|
||||
flow-basic-test
|
||||
(fn
|
||||
(name actual expected)
|
||||
(if
|
||||
(= actual expected)
|
||||
(set! flow-basic-pass (+ flow-basic-pass 1))
|
||||
(begin
|
||||
(set! flow-basic-fail (+ flow-basic-fail 1))
|
||||
(append! flow-basic-fails {:name name :expected expected :actual actual})))))
|
||||
|
||||
;; Run a Scheme flow-program string and return its final value.
|
||||
(define flow-b (fn (src) (flow-run src)))
|
||||
|
||||
;; Scheme strings are boxed as {:scm-string "..."}; unwrap to a host string.
|
||||
(define flow-bs (fn (src) (get (flow-run src) :scm-string)))
|
||||
|
||||
;; ── single node ─────────────────────────────────────────────────
|
||||
(flow-basic-test
|
||||
"node: identity passes input through"
|
||||
(flow-b "(flow/start flow-id 7)")
|
||||
7)
|
||||
(flow-basic-test
|
||||
"node: const ignores input"
|
||||
(flow-b "(flow/start (flow-const 99) 1)")
|
||||
99)
|
||||
(flow-basic-test
|
||||
"node: bare lambda is a node"
|
||||
(flow-b "(flow/start (lambda (x) (* x x)) 6)")
|
||||
36)
|
||||
|
||||
;; ── linear sequence ─────────────────────────────────────────────
|
||||
(flow-basic-test
|
||||
"sequence: empty is identity"
|
||||
(flow-b "(flow/start (sequence) 42)")
|
||||
42)
|
||||
(flow-basic-test
|
||||
"sequence: single child"
|
||||
(flow-b "(flow/start (sequence (lambda (x) (+ x 1))) 41)")
|
||||
42)
|
||||
(flow-basic-test
|
||||
"sequence: two children thread"
|
||||
(flow-b
|
||||
"(flow/start (sequence (lambda (x) (+ x 1)) (lambda (x) (* x 10))) 4)")
|
||||
50)
|
||||
(flow-basic-test
|
||||
"sequence: three children thread"
|
||||
(flow-b
|
||||
"(flow/start (sequence (lambda (x) (+ x 1)) (lambda (x) (* x 2)) (lambda (x) (- x 3))) 5)")
|
||||
9)
|
||||
|
||||
;; ── data flow between nodes ─────────────────────────────────────
|
||||
(flow-basic-test
|
||||
"data flow: string accumulation"
|
||||
(flow-bs
|
||||
"(flow/start (sequence (lambda (s) (string-append s \"-a\")) (lambda (s) (string-append s \"-b\"))) \"x\")")
|
||||
"x-a-b")
|
||||
(flow-basic-test
|
||||
"data flow: list build"
|
||||
(flow-b
|
||||
"(flow/start (sequence (lambda (x) (cons x (list))) (lambda (xs) (cons 0 xs))) 7)")
|
||||
(list 0 7))
|
||||
|
||||
;; ── defflow ─────────────────────────────────────────────────────
|
||||
(flow-basic-test
|
||||
"defflow: names a flow"
|
||||
(flow-b
|
||||
"(defflow inc2 (sequence (lambda (x) (+ x 1)) (lambda (x) (+ x 1)))) (flow/start inc2 40)")
|
||||
42)
|
||||
(flow-basic-test
|
||||
"defflow: reusable"
|
||||
(flow-b
|
||||
"(defflow dbl (lambda (x) (* x 2))) (+ (flow/start dbl 3) (flow/start dbl 10))")
|
||||
26)
|
||||
|
||||
;; ── parallel (sequential semantics, join into list) ─────────────
|
||||
(flow-basic-test
|
||||
"parallel: fans input to all branches"
|
||||
(flow-b
|
||||
"(flow/start (parallel (lambda (x) (+ x 1)) (lambda (x) (* x 2)) (lambda (x) (- x 3))) 10)")
|
||||
(list 11 20 7))
|
||||
(flow-basic-test
|
||||
"parallel: empty joins to empty list"
|
||||
(flow-b "(flow/start (parallel) 5)")
|
||||
(list))
|
||||
(flow-basic-test
|
||||
"parallel: single branch"
|
||||
(flow-b "(flow/start (parallel (lambda (x) (* x x))) 9)")
|
||||
(list 81))
|
||||
|
||||
;; ── nested composition ──────────────────────────────────────────
|
||||
(flow-basic-test
|
||||
"nested: sequence of sequences"
|
||||
(flow-b
|
||||
"(flow/start (sequence (sequence (lambda (x) (+ x 1)) (lambda (x) (+ x 1))) (sequence (lambda (x) (* x 3)))) 0)")
|
||||
6)
|
||||
(flow-basic-test
|
||||
"nested: parallel inside sequence, join then reduce"
|
||||
(flow-b
|
||||
"(flow/start (sequence (parallel (lambda (x) (+ x 1)) (lambda (x) (* x 2))) (lambda (xs) (apply + xs))) 10)")
|
||||
31)
|
||||
(flow-basic-test
|
||||
"nested: sequence inside parallel branch"
|
||||
(flow-b
|
||||
"(flow/start (parallel (sequence (lambda (x) (+ x 1)) (lambda (x) (* x 2))) (lambda (x) x)) 5)")
|
||||
(list 12 5))
|
||||
|
||||
;; ── publish-shaped flow (the architecture sketch) ───────────────
|
||||
(flow-basic-test
|
||||
"publish: write -> (review | spell) -> join lengths"
|
||||
(flow-b
|
||||
"(defflow publish (sequence (lambda (draft) (string-append draft \"!\")) (parallel (lambda (c) (string-length c)) (lambda (c) (string-length (string-append c \"?\")))))) (flow/start publish \"hi\")")
|
||||
(list 3 4))
|
||||
|
||||
(define flow-basic-tests-run! (fn () {:total (+ flow-basic-pass flow-basic-fail) :passed flow-basic-pass :failed flow-basic-fail :fails flow-basic-fails}))
|
||||
108
lib/flow/tests/combinators.sx
Normal file
108
lib/flow/tests/combinators.sx
Normal file
@@ -0,0 +1,108 @@
|
||||
;; lib/flow/tests/combinators.sx — Phase 5: combinator library (tap, recover, map-flow, iteration).
|
||||
|
||||
(define flow-cmb-pass 0)
|
||||
(define flow-cmb-fail 0)
|
||||
(define flow-cmb-fails (list))
|
||||
|
||||
(define
|
||||
flow-cmb-test
|
||||
(fn
|
||||
(name actual expected)
|
||||
(if
|
||||
(= actual expected)
|
||||
(set! flow-cmb-pass (+ flow-cmb-pass 1))
|
||||
(begin
|
||||
(set! flow-cmb-fail (+ flow-cmb-fail 1))
|
||||
(append! flow-cmb-fails {:name name :expected expected :actual actual})))))
|
||||
|
||||
(define flow-m (fn (src) (flow-run src)))
|
||||
|
||||
;; ── tap (side-effecting pass-through) ───────────────────────────
|
||||
(flow-cmb-test
|
||||
"tap: returns input unchanged"
|
||||
(flow-m "(flow/start (tap (lambda (x) (* x 999))) 7)")
|
||||
7)
|
||||
(flow-cmb-test
|
||||
"tap: runs the side effect"
|
||||
(flow-m
|
||||
"(define seen 0) (flow/start (tap (lambda (x) (set! seen x))) 42) seen")
|
||||
42)
|
||||
(flow-cmb-test
|
||||
"tap: value flows on while the effect observes it"
|
||||
(flow-m
|
||||
"(define log 0) (flow/start (sequence (lambda (x) (+ x 1)) (tap (lambda (x) (set! log x))) (lambda (x) (* x 2))) 10) (list log (flow/result 1))")
|
||||
(list 11 22))
|
||||
|
||||
;; ── recover (fail-value counterpart of try-catch) ───────────────
|
||||
(flow-cmb-test
|
||||
"recover: passes a non-fail value through"
|
||||
(flow-m "(flow/start (recover (lambda (x) (* x 2)) (lambda (r) -1)) 5)")
|
||||
10)
|
||||
(flow-cmb-test
|
||||
"recover: handles a fail value via the reason"
|
||||
(flow-m
|
||||
"(flow/start (recover (lambda (x) (fail (quote too-small))) (lambda (r) (list (quote recovered) r))) 1)")
|
||||
(list "recovered" "too-small"))
|
||||
(flow-cmb-test
|
||||
"recover: handler can supply a default value"
|
||||
(flow-m
|
||||
"(flow/start (sequence (recover (lambda (x) (if (> x 0) x (fail (quote neg))) ) (flow-const 0)) (lambda (x) (* x 10))) -3)")
|
||||
0)
|
||||
(flow-cmb-test
|
||||
"recover: does not catch raised exceptions (those are try-catch's job)"
|
||||
(flow-m
|
||||
"(flow/start (try-catch (recover (lambda (x) (raise (quote boom))) (flow-const 0)) (lambda (e) e)) 1)")
|
||||
"boom")
|
||||
|
||||
;; ── map-flow (run a node over a list, join) ─────────────────────
|
||||
(flow-cmb-test
|
||||
"map-flow: applies the node to each item"
|
||||
(flow-m "(flow/start (map-flow (lambda (x) (* x x))) (list 1 2 3 4))")
|
||||
(list 1 4 9 16))
|
||||
(flow-cmb-test
|
||||
"map-flow: empty list joins to empty"
|
||||
(flow-m "(flow/start (map-flow (lambda (x) (+ x 1))) (list))")
|
||||
(list))
|
||||
(flow-cmb-test
|
||||
"map-flow: each item runs an independent sub-flow"
|
||||
(flow-m
|
||||
"(flow/start (map-flow (sequence (lambda (x) (+ x 1)) (lambda (x) (* x 2)))) (list 0 4 9))")
|
||||
(list 2 10 20))
|
||||
(flow-cmb-test
|
||||
"map-flow: composes — fan over a list then reduce the join"
|
||||
(flow-m
|
||||
"(flow/start (sequence (map-flow (lambda (x) (* x 10))) (lambda (xs) (apply + xs))) (list 1 2 3))")
|
||||
60)
|
||||
|
||||
;; ── flow-while / flow-until (bounded iteration) ─────────────────
|
||||
(flow-cmb-test
|
||||
"flow-while: iterates while the predicate holds"
|
||||
(flow-m
|
||||
"(flow/start (flow-while (lambda (x) (< x 10)) (lambda (x) (+ x 1)) 100) 0)")
|
||||
10)
|
||||
(flow-cmb-test
|
||||
"flow-while: a false predicate leaves input unchanged"
|
||||
(flow-m
|
||||
"(flow/start (flow-while (lambda (x) (< x 0)) (lambda (x) (+ x 1)) 100) 5)")
|
||||
5)
|
||||
(flow-cmb-test
|
||||
"flow-while: respects the max-iteration bound"
|
||||
(flow-m "(flow/start (flow-while (lambda (x) #t) (lambda (x) (+ x 1)) 3) 0)")
|
||||
3)
|
||||
(flow-cmb-test
|
||||
"flow-while: doubles until past a threshold"
|
||||
(flow-m
|
||||
"(flow/start (flow-while (lambda (x) (< x 50)) (lambda (x) (* x 2)) 100) 3)")
|
||||
96)
|
||||
(flow-cmb-test
|
||||
"flow-until: iterates until the predicate becomes true"
|
||||
(flow-m
|
||||
"(flow/start (flow-until (lambda (x) (>= x 10)) (lambda (x) (+ x 3)) 100) 0)")
|
||||
12)
|
||||
(flow-cmb-test
|
||||
"flow-until: composes inside a sequence"
|
||||
(flow-m
|
||||
"(flow/start (sequence (flow-until (lambda (x) (> x 100)) (lambda (x) (* x 3)) 100) (lambda (x) (- x 100))) 5)")
|
||||
35)
|
||||
|
||||
(define flow-cmb-tests-run! (fn () {:total (+ flow-cmb-pass flow-cmb-fail) :passed flow-cmb-pass :failed flow-cmb-fail :fails flow-cmb-fails}))
|
||||
179
lib/flow/tests/control.sx
Normal file
179
lib/flow/tests/control.sx
Normal file
@@ -0,0 +1,179 @@
|
||||
;; lib/flow/tests/control.sx — Phase 2: control flow + error handling.
|
||||
|
||||
(define flow-ctl-pass 0)
|
||||
(define flow-ctl-fail 0)
|
||||
(define flow-ctl-fails (list))
|
||||
|
||||
(define
|
||||
flow-ctl-test
|
||||
(fn
|
||||
(name actual expected)
|
||||
(if
|
||||
(= actual expected)
|
||||
(set! flow-ctl-pass (+ flow-ctl-pass 1))
|
||||
(begin
|
||||
(set! flow-ctl-fail (+ flow-ctl-fail 1))
|
||||
(append! flow-ctl-fails {:name name :expected expected :actual actual})))))
|
||||
|
||||
(define flow-c (fn (src) (flow-run src)))
|
||||
(define flow-cs (fn (src) (get (flow-run src) :scm-string)))
|
||||
|
||||
;; ── branch ──────────────────────────────────────────────────────
|
||||
(flow-ctl-test
|
||||
"branch: true selects then"
|
||||
(flow-c
|
||||
"(flow/start (branch (lambda (x) (> x 0)) (lambda (x) (* x 100)) (lambda (x) (- 0 x))) 5)")
|
||||
500)
|
||||
(flow-ctl-test
|
||||
"branch: false selects else"
|
||||
(flow-c
|
||||
"(flow/start (branch (lambda (x) (> x 0)) (lambda (x) (* x 100)) (lambda (x) (- 0 x))) -3)")
|
||||
3)
|
||||
(flow-ctl-test
|
||||
"branch: predicate sees the threaded input"
|
||||
(flow-c
|
||||
"(flow/start (sequence (lambda (x) (+ x 1)) (branch (lambda (x) (> x 3)) (flow-const 100) (flow-const 0))) 3)")
|
||||
100)
|
||||
(flow-ctl-test
|
||||
"branch: branches are full nodes (sequence inside)"
|
||||
(flow-c
|
||||
"(flow/start (branch (lambda (x) (< x 10)) (sequence (lambda (x) (+ x 1)) (lambda (x) (* x 2))) (flow-const 0)) 4)")
|
||||
10)
|
||||
(flow-ctl-test
|
||||
"branch: nested branch (3-way sign)"
|
||||
(flow-c
|
||||
"(defflow sign (branch (lambda (x) (> x 0)) (flow-const 1) (branch (lambda (x) (< x 0)) (flow-const -1) (flow-const 0)))) (list (flow/start sign 7) (flow/start sign -7) (flow/start sign 0))")
|
||||
(list 1 -1 0))
|
||||
(flow-ctl-test
|
||||
"branch: publish-shaped approval gate"
|
||||
(flow-cs
|
||||
"(defflow publish (branch (lambda (post) (>= (string-length post) 3)) (lambda (post) (string-append post \" [published]\")) (lambda (post) (string-append post \" [rejected]\")))) (flow/start publish \"ok\")")
|
||||
"ok [rejected]")
|
||||
|
||||
;; ── error model — explicit (fail reason) values ─────────────────
|
||||
(flow-ctl-test
|
||||
"fail: failed? is true for a failure value"
|
||||
(flow-c "(failed? (fail 404))")
|
||||
true)
|
||||
(flow-ctl-test
|
||||
"fail: fail-reason extracts the reason"
|
||||
(flow-c "(fail-reason (fail 404))")
|
||||
404)
|
||||
(flow-ctl-test
|
||||
"fail: failed? is false for a plain value"
|
||||
(flow-c "(failed? 7)")
|
||||
false)
|
||||
(flow-ctl-test
|
||||
"fail: failed? is false for an ordinary list"
|
||||
(flow-c "(failed? (list 1 2 3))")
|
||||
false)
|
||||
(flow-ctl-test
|
||||
"fail: a node may emit a failure as data"
|
||||
(flow-c
|
||||
"(defflow validate (lambda (s) (if (>= (string-length s) 3) s (fail (quote too-short))))) (failed? (flow/start validate \"hi\"))")
|
||||
true)
|
||||
(flow-ctl-test
|
||||
"fail: failure flows downstream, branch recovers"
|
||||
(flow-c
|
||||
"(defflow guarded (sequence (lambda (s) (if (>= (string-length s) 3) (string-length s) (fail (quote too-short)))) (branch failed? (lambda (f) (list (quote recovered) (fail-reason f))) (lambda (n) (list (quote ok) n))))) (flow/start guarded \"hi\")")
|
||||
(list "recovered" "too-short"))
|
||||
|
||||
;; ── try-catch — reify raised exceptions ─────────────────────────
|
||||
(flow-ctl-test
|
||||
"try-catch: no exception returns node result"
|
||||
(flow-c "(flow/start (try-catch (lambda (x) (* x 2)) (lambda (e) -1)) 5)")
|
||||
10)
|
||||
(flow-ctl-test
|
||||
"try-catch: handler runs on raise"
|
||||
(flow-c
|
||||
"(flow/start (try-catch (lambda (x) (raise (quote boom))) (flow-const 99)) 1)")
|
||||
99)
|
||||
(flow-ctl-test
|
||||
"try-catch: handler receives the reified error"
|
||||
(flow-c "(flow/start (try-catch (lambda (x) (raise 42)) (lambda (e) e)) 0)")
|
||||
42)
|
||||
(flow-ctl-test
|
||||
"try-catch: catches exception from deep inside a sequence"
|
||||
(flow-c
|
||||
"(flow/start (try-catch (sequence (lambda (x) (+ x 1)) (lambda (x) (raise (quote deep)))) (flow-const -99)) 5)")
|
||||
-99)
|
||||
(flow-ctl-test
|
||||
"try-catch: handler may convert to a failure value"
|
||||
(flow-c
|
||||
"(failed? (flow/start (try-catch (lambda (x) (raise (quote bad))) (lambda (e) (fail e))) 0))")
|
||||
true)
|
||||
(flow-ctl-test
|
||||
"try-catch: composes — recover then continue"
|
||||
(flow-c
|
||||
"(flow/start (sequence (try-catch (lambda (x) (raise (quote x))) (flow-const 10)) (lambda (n) (* n 5))) 0)")
|
||||
50)
|
||||
|
||||
;; ── retry — re-run on raised exceptions ─────────────────────────
|
||||
(flow-ctl-test
|
||||
"retry: succeeds after transient failures"
|
||||
(flow-c
|
||||
"(define ctr 0) (defflow flaky (lambda (x) (set! ctr (+ ctr 1)) (if (< ctr 3) (raise (quote nope)) (* x 10)))) (list (flow/start (retry 5 flaky) 7) ctr)")
|
||||
(list 70 3))
|
||||
(flow-ctl-test
|
||||
"retry: exhausted re-raises (caught by try-catch)"
|
||||
(flow-c
|
||||
"(flow/start (try-catch (retry 2 (lambda (x) (raise (quote always)))) (flow-const (quote gaveup))) 0)")
|
||||
"gaveup")
|
||||
(flow-ctl-test
|
||||
"retry: n=1 means a single attempt"
|
||||
(flow-c
|
||||
"(define ctr 0) (flow/start (try-catch (retry 1 (lambda (x) (set! ctr (+ ctr 1)) (raise (quote bad)))) (lambda (e) ctr)) 0)")
|
||||
1)
|
||||
(flow-ctl-test
|
||||
"retry: success on first attempt does not re-run"
|
||||
(flow-c
|
||||
"(define ctr 0) (flow/start (sequence (retry 5 (lambda (x) (set! ctr (+ ctr 1)) (* x 2))) (lambda (n) ctr)) 21)")
|
||||
1)
|
||||
(flow-ctl-test
|
||||
"retry: does not retry explicit failure values"
|
||||
(flow-c
|
||||
"(define ctr 0) (failed? (flow/start (retry 5 (lambda (x) (set! ctr (+ ctr 1)) (fail (quote bad)))) 0))")
|
||||
true)
|
||||
(flow-ctl-test
|
||||
"retry: failure-value path runs node exactly once"
|
||||
(flow-c
|
||||
"(define ctr 0) (flow/start (sequence (retry 5 (lambda (x) (set! ctr (+ ctr 1)) (fail (quote bad)))) (lambda (f) ctr)) 0)")
|
||||
1)
|
||||
|
||||
;; ── timeout — cooperative step budget ───────────────────────────
|
||||
(flow-ctl-test
|
||||
"timeout: work within budget completes"
|
||||
(flow-c
|
||||
"(define (cd n) (if (<= n 0) 99 (begin (tick) (cd (- n 1))))) (flow/start (try-catch (timeout 10 (lambda (x) (cd x))) (flow-const (quote timed-out))) 5)")
|
||||
99)
|
||||
(flow-ctl-test
|
||||
"timeout: work exceeding budget raises flow-timeout"
|
||||
(flow-c
|
||||
"(define (cd n) (if (<= n 0) 99 (begin (tick) (cd (- n 1))))) (flow/start (try-catch (timeout 10 (lambda (x) (cd x))) (flow-const (quote timed-out))) 20)")
|
||||
"timed-out")
|
||||
(flow-ctl-test
|
||||
"timeout: exact budget boundary completes"
|
||||
(flow-c
|
||||
"(define (cd n) (if (<= n 0) 99 (begin (tick) (cd (- n 1))))) (flow/start (try-catch (timeout 5 (lambda (x) (cd x))) (flow-const (quote timed-out))) 5)")
|
||||
99)
|
||||
(flow-ctl-test
|
||||
"timeout: one tick over the budget raises"
|
||||
(flow-c
|
||||
"(define (cd n) (if (<= n 0) 99 (begin (tick) (cd (- n 1))))) (flow/start (try-catch (timeout 5 (lambda (x) (cd x))) (flow-const (quote timed-out))) 6)")
|
||||
"timed-out")
|
||||
(flow-ctl-test
|
||||
"timeout: the raised error is identifiable"
|
||||
(flow-c
|
||||
"(define (cd n) (if (<= n 0) 99 (begin (tick) (cd (- n 1))))) (flow/start (try-catch (timeout 2 (lambda (x) (cd x))) (lambda (e) e)) 9)")
|
||||
"flow-timeout")
|
||||
(flow-ctl-test
|
||||
"timeout: a node that never ticks is unbounded"
|
||||
(flow-c "(flow/start (timeout 0 (lambda (x) (* x 2))) 5)")
|
||||
10)
|
||||
(flow-ctl-test
|
||||
"timeout: budget is restored across sequential timeouts"
|
||||
(flow-c
|
||||
"(define (cd n) (if (<= n 0) 1 (begin (tick) (cd (- n 1))))) (flow/start (sequence (timeout 4 (lambda (x) (cd x))) (timeout 4 (lambda (x) (cd 3))) (lambda (x) (begin (tick) (+ x 100)))) 3)")
|
||||
101)
|
||||
|
||||
(define flow-ctl-tests-run! (fn () {:total (+ flow-ctl-pass flow-ctl-fail) :passed flow-ctl-pass :failed flow-ctl-fail :fails flow-ctl-fails}))
|
||||
120
lib/flow/tests/distributed.sx
Normal file
120
lib/flow/tests/distributed.sx
Normal file
@@ -0,0 +1,120 @@
|
||||
;; lib/flow/tests/distributed.sx — Phase 4: distributed nodes via fed-sx (mocked).
|
||||
|
||||
(define flow-dist-pass 0)
|
||||
(define flow-dist-fail 0)
|
||||
(define flow-dist-fails (list))
|
||||
|
||||
(define
|
||||
flow-dist-test
|
||||
(fn
|
||||
(name actual expected)
|
||||
(if
|
||||
(= actual expected)
|
||||
(set! flow-dist-pass (+ flow-dist-pass 1))
|
||||
(begin
|
||||
(set! flow-dist-fail (+ flow-dist-fail 1))
|
||||
(append! flow-dist-fails {:name name :expected expected :actual actual})))))
|
||||
|
||||
(define flow-d (fn (src) (flow-run src)))
|
||||
|
||||
;; ── remote-node ─────────────────────────────────────────────────
|
||||
(flow-dist-test
|
||||
"remote: a node executes on a peer"
|
||||
(flow-d
|
||||
"(flow-peer-register! (quote edge) (list (list (quote double) (lambda (x) (* x 2))))) (flow/start (remote-node (quote edge) (quote double)) 21)")
|
||||
42)
|
||||
(flow-dist-test
|
||||
"remote: remote nodes compose in a sequence"
|
||||
(flow-d
|
||||
"(flow-peer-register! (quote edge) (list (list (quote inc) (lambda (x) (+ x 1))) (list (quote double) (lambda (x) (* x 2))))) (flow/start (sequence (remote-node (quote edge) (quote inc)) (remote-node (quote edge) (quote double))) 4)")
|
||||
10)
|
||||
(flow-dist-test
|
||||
"remote: a remote node mixes with local nodes"
|
||||
(flow-d
|
||||
"(flow-peer-register! (quote edge) (list (list (quote double) (lambda (x) (* x 2))))) (flow/start (sequence (lambda (x) (+ x 5)) (remote-node (quote edge) (quote double)) (lambda (x) (- x 1))) 10)")
|
||||
29)
|
||||
(flow-dist-test
|
||||
"remote: unreachable peer raises flow-remote-unreachable"
|
||||
(flow-d
|
||||
"(flow/start (try-catch (remote-node (quote ghost) (quote double)) (lambda (e) e)) 1)")
|
||||
"flow-remote-unreachable")
|
||||
(flow-dist-test
|
||||
"remote: unknown function on a peer raises flow-remote-no-fn"
|
||||
(flow-d
|
||||
"(flow-peer-register! (quote edge) (list (list (quote double) (lambda (x) (* x 2))))) (flow/start (try-catch (remote-node (quote edge) (quote missing)) (lambda (e) e)) 1)")
|
||||
"flow-remote-no-fn")
|
||||
(flow-dist-test
|
||||
"remote: a remote node can suspend the flow (peer returns control)"
|
||||
(flow-d
|
||||
"(flow-peer-register! (quote edge) (list (list (quote review) (lambda (x) x)))) (flow/start (sequence (remote-node (quote edge) (quote review)) (lambda (x) (suspend (quote human))) (lambda (v) (list (quote published) v))) 7)")
|
||||
(list "flow-suspended" 1 "human"))
|
||||
(flow-dist-test
|
||||
"remote: a transient remote failure is recoverable with retry"
|
||||
(flow-d
|
||||
"(define hits 0) (flow-peer-register! (quote edge) (list (list (quote flaky) (lambda (x) (begin (set! hits (+ hits 1)) (if (< hits 2) (raise (quote down)) (* x 3))))))) (list (flow/start (retry 3 (remote-node (quote edge) (quote flaky))) 7) hits)")
|
||||
(list 21 2))
|
||||
|
||||
;; ── failover (retry on a different peer, fall through to local) ──
|
||||
(flow-dist-test
|
||||
"failover: first reachable peer serves the request"
|
||||
(flow-d
|
||||
"(flow-peer-register! (quote p2) (list (list (quote f) (lambda (x) (+ x 100))))) (flow/start (remote-failover (list (quote p2) (quote down)) (quote f) (flow-const (quote local))) 5)")
|
||||
105)
|
||||
(flow-dist-test
|
||||
"failover: skips an unreachable peer to the next one"
|
||||
(flow-d
|
||||
"(flow-peer-register! (quote p2) (list (list (quote f) (lambda (x) (+ x 100))))) (flow/start (remote-failover (list (quote down) (quote p2)) (quote f) (flow-const (quote local))) 5)")
|
||||
105)
|
||||
(flow-dist-test
|
||||
"failover: skips a peer whose function raises"
|
||||
(flow-d
|
||||
"(flow-peer-register! (quote bad) (list (list (quote f) (lambda (x) (raise (quote boom)))))) (flow-peer-register! (quote good) (list (list (quote f) (lambda (x) (* x 10))))) (flow/start (remote-failover (list (quote bad) (quote good)) (quote f) (flow-const 0)) 4)")
|
||||
40)
|
||||
(flow-dist-test
|
||||
"failover: all peers fail, the local fallback runs"
|
||||
(flow-d
|
||||
"(flow/start (remote-failover (list (quote down1) (quote down2)) (quote f) (lambda (x) (* x -1))) 9)")
|
||||
-9)
|
||||
(flow-dist-test
|
||||
"failover: threads the input through to the chosen peer"
|
||||
(flow-d
|
||||
"(flow-peer-register! (quote p) (list (list (quote f) (lambda (x) (list (quote got) x))))) (flow/start (sequence (lambda (x) (+ x 1)) (remote-failover (list (quote p)) (quote f) (flow-const 0))) 41)")
|
||||
(list "got" 42))
|
||||
(flow-dist-test
|
||||
"failover: composes inside a larger sequence"
|
||||
(flow-d
|
||||
"(flow-peer-register! (quote p) (list (list (quote f) (lambda (x) (* x 2))))) (flow/start (sequence (remote-failover (list (quote down) (quote p)) (quote f) (flow-const 1)) (lambda (x) (+ x 3))) 5)")
|
||||
13)
|
||||
|
||||
;; ── replication + handoff ───────────────────────────────────────
|
||||
(flow-dist-test
|
||||
"replicate: a peer holds the exported store"
|
||||
(flow-d
|
||||
"(defflow w (lambda (x) (suspend (quote q)))) (flow/start w 10) (flow-replicate-to (quote peerB)) (if (flow-replica-get (quote peerB)) (quote replicated) (quote missing))")
|
||||
"replicated")
|
||||
(flow-dist-test
|
||||
"handoff: a peer resumes a flow after the local instance dies"
|
||||
(flow-d
|
||||
"(defflow w (sequence (lambda (x) (suspend (quote q))) (lambda (v) (list (quote done) v)))) (define id (car (cdr (flow/start w 10)))) (flow-replicate-to (quote peerB)) (set! flow-store (list)) (flow-restore-from (quote peerB)) (flow/resume id 55)")
|
||||
(list "done" 55))
|
||||
(flow-dist-test
|
||||
"handoff: restored peer reports the flow as resumable"
|
||||
(flow-d
|
||||
"(defflow w (lambda (x) (suspend (quote q)))) (define id (car (cdr (flow/start w 10)))) (flow-replicate-to (quote peerB)) (set! flow-store (list)) (flow-restore-from (quote peerB)) (flow-resumable-ids)")
|
||||
(list 1))
|
||||
(flow-dist-test
|
||||
"handoff: without restore the dead instance has lost the flow"
|
||||
(flow-d
|
||||
"(defflow w (lambda (x) (suspend (quote q)))) (define id (car (cdr (flow/start w 10)))) (flow-replicate-to (quote peerB)) (set! flow-store (list)) (flow/resume id 1)")
|
||||
(list "flow-error" "no-such-flow"))
|
||||
(flow-dist-test
|
||||
"restore: from an unknown peer yields false"
|
||||
(flow-d "(flow-restore-from (quote nowhere))")
|
||||
false)
|
||||
(flow-dist-test
|
||||
"handoff: replication preserves the replay log across the move"
|
||||
(flow-d
|
||||
"(defflow two (sequence (lambda (x) (suspend (quote a))) (lambda (x) (suspend (quote b))) (lambda (x) (list x)))) (define id (car (cdr (flow/start two 0)))) (flow/resume id 11) (flow-replicate-to (quote peerB)) (set! flow-store (list)) (flow-restore-from (quote peerB)) (flow/resume id 22)")
|
||||
(list 22))
|
||||
|
||||
(define flow-dist-tests-run! (fn () {:total (+ flow-dist-pass flow-dist-fail) :passed flow-dist-pass :failed flow-dist-fail :fails flow-dist-fails}))
|
||||
106
lib/flow/tests/host.sx
Normal file
106
lib/flow/tests/host.sx
Normal file
@@ -0,0 +1,106 @@
|
||||
;; lib/flow/tests/host.sx — Phase 8: host integration ABI (request/await/host-queue/driver).
|
||||
|
||||
(define flow-hst-pass 0)
|
||||
(define flow-hst-fail 0)
|
||||
(define flow-hst-fails (list))
|
||||
|
||||
(define
|
||||
flow-hst-test
|
||||
(fn
|
||||
(name actual expected)
|
||||
(if
|
||||
(= actual expected)
|
||||
(set! flow-hst-pass (+ flow-hst-pass 1))
|
||||
(begin
|
||||
(set! flow-hst-fail (+ flow-hst-fail 1))
|
||||
(append! flow-hst-fails {:name name :expected expected :actual actual})))))
|
||||
|
||||
(define flow-hst (fn (src) (flow-run src)))
|
||||
|
||||
;; ── request envelope ────────────────────────────────────────────
|
||||
(flow-hst-test
|
||||
"request: suspends with a typed envelope"
|
||||
(flow-hst
|
||||
"(car (cdr (cdr (flow/start (lambda (x) (request (quote render) x)) 5))))")
|
||||
(list "flow-request" "render" 5))
|
||||
(flow-hst-test
|
||||
"request?: recognizes an envelope"
|
||||
(flow-hst "(request? (list (quote flow-request) (quote human) 1))")
|
||||
true)
|
||||
(flow-hst-test
|
||||
"request?: a plain tag is not a request"
|
||||
(flow-hst "(request? (list (quote review) 1))")
|
||||
false)
|
||||
(flow-hst-test
|
||||
"request-kind / request-payload: parse the envelope"
|
||||
(flow-hst
|
||||
"(define t (list (quote flow-request) (quote render) (list (quote recipe) 7))) (list (request-kind t) (request-payload t))")
|
||||
(list "render" (list "recipe" 7)))
|
||||
|
||||
;; ── named decision points ───────────────────────────────────────
|
||||
(flow-hst-test
|
||||
"await-human: is a request of kind human"
|
||||
(flow-hst
|
||||
"(car (cdr (cdr (flow/start (lambda (x) (await-human x)) (quote approve?)))))")
|
||||
(list "flow-request" "human" "approve?"))
|
||||
(flow-hst-test
|
||||
"await-render: is a request of kind render"
|
||||
(flow-hst
|
||||
"(car (cdr (cdr (flow/start (lambda (x) (await-render x)) (quote recipe)))))")
|
||||
(list "flow-request" "render" "recipe"))
|
||||
(flow-hst-test
|
||||
"request: the host's resume value flows back into the flow"
|
||||
(flow-hst
|
||||
"(defflow f (sequence (lambda (x) (await-render x)) (lambda (art) (list (quote got) art)))) (define id (car (cdr (flow/start f 1)))) (flow/resume id (quote the-artifact))")
|
||||
(list "got" "the-artifact"))
|
||||
|
||||
;; ── host work queue ─────────────────────────────────────────────
|
||||
(flow-hst-test
|
||||
"flow-host-requests: lists (id kind payload) for pending requests"
|
||||
(flow-hst
|
||||
"(flow/start (lambda (x) (await-render x)) 99) (flow-host-requests)")
|
||||
(list (list 1 "render" 99)))
|
||||
(flow-hst-test
|
||||
"flow-host-requests: excludes bare (non-request) suspends"
|
||||
(flow-hst
|
||||
"(defflow a (lambda (x) (await-render x))) (defflow b (lambda (x) (suspend (quote plain)))) (flow/start a 1) (flow/start b 2) (flow-host-requests)")
|
||||
(list (list 1 "render" 1)))
|
||||
|
||||
;; ── the art-dag-shaped host driver loop (manual resumes) ────────
|
||||
(flow-hst-test
|
||||
"host driver: render then human-review then publish"
|
||||
(flow-hst
|
||||
"(defflow pipeline (sequence (lambda (recipe) (await-render recipe)) (lambda (art) (await-human (list (quote review) art))) (branch (lambda (d) (eq? d (quote approve))) (flow-const (quote published)) (flow-const (fail (quote rejected)))))) (define id (car (cdr (flow/start pipeline 99)))) (define r1 (flow-host-requests)) (flow/resume id (list (quote art) 99)) (define r2 (flow-host-requests)) (flow/resume id (quote approve)) (list r1 r2 (flow/status id) (flow/result id))")
|
||||
(list
|
||||
(list (list 1 "render" 99))
|
||||
(list (list 1 "human" (list "review" (list "art" 99))))
|
||||
"done"
|
||||
"published"))
|
||||
(flow-hst-test
|
||||
"host driver: rejection at the human gate yields a failure"
|
||||
(flow-hst
|
||||
"(defflow pipeline (sequence (lambda (recipe) (await-render recipe)) (lambda (art) (await-human (list (quote review) art))) (branch (lambda (d) (eq? d (quote approve))) (flow-const (quote published)) (flow-const (fail (quote rejected)))))) (define id (car (cdr (flow/start pipeline 1)))) (flow/resume id (quote artifact)) (failed? (flow/resume id (quote reject)))")
|
||||
true)
|
||||
|
||||
;; ── reference driver: host supplies only a dispatch fn ──────────
|
||||
(flow-hst-test
|
||||
"flow-drive-host: one tick services every pending request"
|
||||
(flow-hst
|
||||
"(flow/start (lambda (x) (await-render x)) 5) (define n (flow-drive-host (lambda (k p) (list (quote done) p)))) (list n (flow/status 1) (flow/result 1))")
|
||||
(list 1 "done" (list "done" 5)))
|
||||
(flow-hst-test
|
||||
"flow-run-host: drives a render -> human pipeline to completion"
|
||||
(flow-hst
|
||||
"(defflow pipeline (sequence (lambda (recipe) (await-render recipe)) (lambda (art) (await-human (list (quote review) art))) (branch (lambda (d) (eq? d (quote approve))) (flow-const (quote published)) (flow-const (fail (quote rejected)))))) (define id (car (cdr (flow/start pipeline 99)))) (define serviced (flow-run-host (lambda (kind payload) (if (eq? kind (quote render)) (list (quote art) payload) (quote approve))) 10)) (list serviced (flow/status id) (flow/result id))")
|
||||
(list 2 "done" "published"))
|
||||
(flow-hst-test
|
||||
"flow-run-host: returns 0 when nothing is pending"
|
||||
(flow-hst "(flow-run-host (lambda (k p) p) 5)")
|
||||
0)
|
||||
(flow-hst-test
|
||||
"flow-run-host: respects the maxticks bound"
|
||||
(flow-hst
|
||||
"(defflow pipe2 (sequence (lambda (r) (await-render r)) (lambda (a) (await-human a)) (lambda (d) d))) (define id (car (cdr (flow/start pipe2 1)))) (define serviced (flow-run-host (lambda (k p) p) 1)) (list serviced (flow/status id))")
|
||||
(list 1 "suspended"))
|
||||
|
||||
(define flow-hst-tests-run! (fn () {:total (+ flow-hst-pass flow-hst-fail) :passed flow-hst-pass :failed flow-hst-fail :fails flow-hst-fails}))
|
||||
67
lib/flow/tests/hygiene.sx
Normal file
67
lib/flow/tests/hygiene.sx
Normal file
@@ -0,0 +1,67 @@
|
||||
;; lib/flow/tests/hygiene.sx — Phase 5: store hygiene (flow/gc, flow/forget).
|
||||
|
||||
(define flow-hyg-pass 0)
|
||||
(define flow-hyg-fail 0)
|
||||
(define flow-hyg-fails (list))
|
||||
|
||||
(define
|
||||
flow-hyg-test
|
||||
(fn
|
||||
(name actual expected)
|
||||
(if
|
||||
(= actual expected)
|
||||
(set! flow-hyg-pass (+ flow-hyg-pass 1))
|
||||
(begin
|
||||
(set! flow-hyg-fail (+ flow-hyg-fail 1))
|
||||
(append! flow-hyg-fails {:name name :expected expected :actual actual})))))
|
||||
|
||||
(define flow-h (fn (src) (flow-run src)))
|
||||
|
||||
;; ── flow/gc ─────────────────────────────────────────────────────
|
||||
(flow-hyg-test
|
||||
"gc: empty store removes nothing"
|
||||
(flow-h "(flow/gc)")
|
||||
0)
|
||||
(flow-hyg-test
|
||||
"gc: removes a done flow, keeps a suspended one"
|
||||
(flow-h
|
||||
"(defflow w (lambda (x) (suspend (quote q)))) (flow/start w 0) (flow/start (lambda (x) x) 5) (define removed (flow/gc)) (list removed (flow/list))")
|
||||
(list 1 (list (list 1 "suspended"))))
|
||||
(flow-hyg-test
|
||||
"gc: removes a cancelled flow"
|
||||
(flow-h
|
||||
"(defflow w (lambda (x) (suspend (quote q)))) (define id (car (cdr (flow/start w 0)))) (flow/cancel id) (flow/gc)")
|
||||
1)
|
||||
(flow-hyg-test
|
||||
"gc: a kept suspended flow is still resumable"
|
||||
(flow-h
|
||||
"(defflow w (sequence (lambda (x) (suspend (quote q))) (lambda (v) (* v 2)))) (define id (car (cdr (flow/start w 0)))) (flow/start (lambda (x) x) 1) (flow/gc) (flow/resume id 21)")
|
||||
42)
|
||||
(flow-hyg-test
|
||||
"gc: counts every terminal flow it drops"
|
||||
(flow-h
|
||||
"(flow/start (lambda (x) x) 1) (flow/start (lambda (x) x) 2) (defflow w (lambda (x) (suspend (quote q)))) (flow/start w 0) (flow/gc)")
|
||||
2)
|
||||
|
||||
;; ── flow/forget ─────────────────────────────────────────────────
|
||||
(flow-hyg-test
|
||||
"forget: drops a completed flow"
|
||||
(flow-h
|
||||
"(defflow w (sequence (lambda (x) (suspend (quote q))) (lambda (v) v))) (define id (car (cdr (flow/start w 0)))) (flow/resume id 7) (list (flow/forget id) (flow/status id))")
|
||||
(list true "unknown"))
|
||||
(flow-hyg-test
|
||||
"forget: refuses to drop a live (suspended) flow"
|
||||
(flow-h
|
||||
"(defflow w (lambda (x) (suspend (quote q)))) (define id (car (cdr (flow/start w 0)))) (list (flow/forget id) (flow/status id))")
|
||||
(list false "suspended"))
|
||||
(flow-hyg-test
|
||||
"forget: drops a cancelled flow"
|
||||
(flow-h
|
||||
"(defflow w (lambda (x) (suspend (quote q)))) (define id (car (cdr (flow/start w 0)))) (flow/cancel id) (list (flow/forget id) (flow/status id))")
|
||||
(list true "unknown"))
|
||||
(flow-hyg-test
|
||||
"forget: unknown id yields false"
|
||||
(flow-h "(flow/forget 999)")
|
||||
false)
|
||||
|
||||
(define flow-hyg-tests-run! (fn () {:total (+ flow-hyg-pass flow-hyg-fail) :passed flow-hyg-pass :failed flow-hyg-fail :fails flow-hyg-fails}))
|
||||
115
lib/flow/tests/integration.sx
Normal file
115
lib/flow/tests/integration.sx
Normal file
@@ -0,0 +1,115 @@
|
||||
;; lib/flow/tests/integration.sx — Phase 7: end-to-end flows composing every phase.
|
||||
|
||||
(define flow-int-pass 0)
|
||||
(define flow-int-fail 0)
|
||||
(define flow-int-fails (list))
|
||||
|
||||
(define
|
||||
flow-int-test
|
||||
(fn
|
||||
(name actual expected)
|
||||
(if
|
||||
(= actual expected)
|
||||
(set! flow-int-pass (+ flow-int-pass 1))
|
||||
(begin
|
||||
(set! flow-int-fail (+ flow-int-fail 1))
|
||||
(append! flow-int-fails {:name name :expected expected :actual actual})))))
|
||||
|
||||
(define flow-i (fn (src) (flow-run src)))
|
||||
|
||||
;; The order-processing flow, defined once per program via this prelude string:
|
||||
;; validate amount (attempt: fail if <= 0)
|
||||
;; -> suspend for payment confirmation (resume value = confirmed amount)
|
||||
;; -> branch: confirmed>0 ? record on the ledger peer : declined failure
|
||||
(define
|
||||
order-prelude
|
||||
"(flow-peer-register! (quote ledger) (list (list (quote record) (lambda (amt) (list (quote recorded) amt)))))\n (defflow order\n (attempt\n (lambda (amt) (if (> amt 0) amt (fail (quote invalid-amount))))\n (lambda (amt) (suspend (quote await-payment)))\n (branch (lambda (amt) (> amt 0))\n (remote-node (quote ledger) (quote record))\n (flow-const (fail (quote declined))))))")
|
||||
|
||||
;; ── happy path through every phase ──────────────────────────────
|
||||
(flow-int-test
|
||||
"order: validate -> suspend -> resume -> branch -> federate"
|
||||
(flow-i
|
||||
(str
|
||||
order-prelude
|
||||
"(define id (car (cdr (flow/start order 100)))) (flow/resume id 250)"))
|
||||
(list "recorded" 250))
|
||||
(flow-int-test
|
||||
"order: starting suspends awaiting payment"
|
||||
(flow-i
|
||||
(str
|
||||
order-prelude
|
||||
"(define s (flow/start order 100)) (list (car s) (car (cdr (cdr s))))"))
|
||||
(list "flow-suspended" "await-payment"))
|
||||
(flow-int-test
|
||||
"order: invalid amount fails up front and never suspends"
|
||||
(flow-i
|
||||
(str
|
||||
order-prelude
|
||||
"(define r (flow/start order -5)) (list (failed? r) (fail-reason r))"))
|
||||
(list true "invalid-amount"))
|
||||
(flow-int-test
|
||||
"order: a declined payment yields a failure value"
|
||||
(flow-i
|
||||
(str
|
||||
order-prelude
|
||||
"(define id (car (cdr (flow/start order 100)))) (failed? (flow/resume id 0))"))
|
||||
true)
|
||||
|
||||
;; ── crash recovery mid-flow ─────────────────────────────────────
|
||||
(flow-int-test
|
||||
"order: survives a simulated crash between suspend and resume"
|
||||
(flow-i
|
||||
(str
|
||||
order-prelude
|
||||
"(define id (car (cdr (flow/start order 100)))) (define saved (flow-store-export)) (set! flow-store (list)) (flow-store-import! saved) (flow/resume id 250)"))
|
||||
(list "recorded" 250))
|
||||
|
||||
;; ── handoff to a peer mid-flow ──────────────────────────────────
|
||||
(flow-int-test
|
||||
"order: hands off to a peer that resumes and completes"
|
||||
(flow-i
|
||||
(str
|
||||
order-prelude
|
||||
"(define id (car (cdr (flow/start order 100)))) (flow-replicate-to (quote nodeB)) (set! flow-store (list)) (flow-restore-from (quote nodeB)) (flow/resume id 250)"))
|
||||
(list "recorded" 250))
|
||||
|
||||
;; ── introspection during the flow's life ────────────────────────
|
||||
(flow-int-test
|
||||
"order: pending shows what the flow awaits, then result after resume"
|
||||
(flow-i
|
||||
(str
|
||||
order-prelude
|
||||
"(define id (car (cdr (flow/start order 100)))) (define p (flow/pending)) (flow/resume id 250) (list p (flow/status id) (flow/result id))"))
|
||||
(list
|
||||
(list (list 1 "await-payment"))
|
||||
"done"
|
||||
(list "recorded" 250)))
|
||||
|
||||
;; ── onboarding: two human steps + cancellation ──────────────────
|
||||
(define
|
||||
onboard-prelude
|
||||
"(defflow onboard\n (sequence\n (lambda (user) (+ user 1))\n (lambda (x) (suspend (quote confirm-email)))\n (lambda (x) (suspend (quote complete-profile)))\n (lambda (x) (list (quote onboarded) x))))")
|
||||
|
||||
(flow-int-test
|
||||
"onboard: two suspends resume in order to completion"
|
||||
(flow-i
|
||||
(str
|
||||
onboard-prelude
|
||||
"(define id (car (cdr (flow/start onboard 0)))) (flow/resume id 7) (flow/resume id 9)"))
|
||||
(list "onboarded" 9))
|
||||
(flow-int-test
|
||||
"onboard: the second pending tag appears after the first resume"
|
||||
(flow-i
|
||||
(str
|
||||
onboard-prelude
|
||||
"(define id (car (cdr (flow/start onboard 0)))) (flow/resume id 7) (car (cdr (car (flow/pending))))"))
|
||||
"complete-profile")
|
||||
(flow-int-test
|
||||
"onboard: cancelling abandons the flow"
|
||||
(flow-i
|
||||
(str
|
||||
onboard-prelude
|
||||
"(define id (car (cdr (flow/start onboard 0)))) (flow/cancel id) (list (flow/status id) (car (flow/resume id 7)))"))
|
||||
(list "cancelled" "flow-error"))
|
||||
|
||||
(define flow-int-tests-run! (fn () {:total (+ flow-int-pass flow-int-fail) :passed flow-int-pass :failed flow-int-fail :fails flow-int-fails}))
|
||||
73
lib/flow/tests/railway.sx
Normal file
73
lib/flow/tests/railway.sx
Normal file
@@ -0,0 +1,73 @@
|
||||
;; lib/flow/tests/railway.sx — Phase 6: railway-oriented composition (attempt).
|
||||
|
||||
(define flow-rail-pass 0)
|
||||
(define flow-rail-fail 0)
|
||||
(define flow-rail-fails (list))
|
||||
|
||||
(define
|
||||
flow-rail-test
|
||||
(fn
|
||||
(name actual expected)
|
||||
(if
|
||||
(= actual expected)
|
||||
(set! flow-rail-pass (+ flow-rail-pass 1))
|
||||
(begin
|
||||
(set! flow-rail-fail (+ flow-rail-fail 1))
|
||||
(append! flow-rail-fails {:name name :expected expected :actual actual})))))
|
||||
|
||||
(define flow-r (fn (src) (flow-run src)))
|
||||
|
||||
;; ── attempt — short-circuit on the first (fail ...) ─────────────
|
||||
(flow-rail-test
|
||||
"attempt: threads like sequence when nothing fails"
|
||||
(flow-r
|
||||
"(flow/start (attempt (lambda (x) (+ x 1)) (lambda (x) (* x 10))) 4)")
|
||||
50)
|
||||
(flow-rail-test
|
||||
"attempt: empty is identity"
|
||||
(flow-r "(flow/start (attempt) 7)")
|
||||
7)
|
||||
(flow-rail-test
|
||||
"attempt: returns the first failure"
|
||||
(flow-r
|
||||
"(failed? (flow/start (attempt (lambda (x) (fail (quote bad))) (lambda (x) (* x 10))) 4))")
|
||||
true)
|
||||
(flow-rail-test
|
||||
"attempt: the failure carries its reason"
|
||||
(flow-r
|
||||
"(fail-reason (flow/start (attempt (lambda (x) x) (lambda (x) (fail (quote rejected)))) 4))")
|
||||
"rejected")
|
||||
(flow-rail-test
|
||||
"attempt: nodes after a failure do not run"
|
||||
(flow-r
|
||||
"(define ran 0) (flow/start (attempt (lambda (x) (fail (quote stop))) (lambda (x) (begin (set! ran (+ ran 1)) x))) 0) ran")
|
||||
0)
|
||||
(flow-rail-test
|
||||
"attempt: a failed input short-circuits immediately"
|
||||
(flow-r
|
||||
"(define ran 0) (fail-reason (flow/start (attempt (lambda (x) (begin (set! ran (+ ran 1)) x))) (fail (quote pre))))")
|
||||
"pre")
|
||||
(flow-rail-test
|
||||
"attempt: middle failure halts the chain"
|
||||
(flow-r
|
||||
"(define ran 0) (flow/start (attempt (lambda (x) (+ x 1)) (lambda (x) (fail (quote mid))) (lambda (x) (begin (set! ran (+ ran 1)) x))) 5) ran")
|
||||
0)
|
||||
|
||||
;; ── attempt + recover (rejoin the happy track) ──────────────────
|
||||
(flow-rail-test
|
||||
"attempt + recover: recover turns a failure into a value"
|
||||
(flow-r
|
||||
"(flow/start (recover (attempt (lambda (x) (if (> x 0) x (fail (quote non-positive)))) (lambda (x) (* x 2))) (flow-const 0)) -5)")
|
||||
0)
|
||||
(flow-rail-test
|
||||
"attempt + recover: happy path passes recover through"
|
||||
(flow-r
|
||||
"(flow/start (recover (attempt (lambda (x) (if (> x 0) x (fail (quote non-positive)))) (lambda (x) (* x 2))) (flow-const 0)) 5)")
|
||||
10)
|
||||
(flow-rail-test
|
||||
"attempt: validation pipeline reports the failing stage"
|
||||
(flow-r
|
||||
"(defflow validate (attempt (lambda (s) (if (>= (string-length s) 3) s (fail (quote too-short)))) (lambda (s) (if (<= (string-length s) 8) s (fail (quote too-long)))) (lambda (s) (list (quote ok) (string-length s))))) (list (fail-reason (flow/start validate \"hi\")) (flow/start validate \"hello\"))")
|
||||
(list "too-short" (list "ok" 5)))
|
||||
|
||||
(define flow-rail-tests-run! (fn () {:total (+ flow-rail-pass flow-rail-fail) :passed flow-rail-pass :failed flow-rail-fail :fails flow-rail-fails}))
|
||||
71
lib/flow/tests/recovery.sx
Normal file
71
lib/flow/tests/recovery.sx
Normal file
@@ -0,0 +1,71 @@
|
||||
;; lib/flow/tests/recovery.sx — Phase 3: crash recovery (store export/import + restart).
|
||||
;;
|
||||
;; "restart" is simulated within one program: (set! flow-store (list)) wipes the
|
||||
;; in-memory store (process death), while flow-registry persists as it would after
|
||||
;; reloading flow definitions. Recovery = import the exported (plain-data) store and
|
||||
;; resume; the flow proc is re-resolved by name.
|
||||
|
||||
(define flow-rec-pass 0)
|
||||
(define flow-rec-fail 0)
|
||||
(define flow-rec-fails (list))
|
||||
|
||||
(define
|
||||
flow-rec-test
|
||||
(fn
|
||||
(name actual expected)
|
||||
(if
|
||||
(= actual expected)
|
||||
(set! flow-rec-pass (+ flow-rec-pass 1))
|
||||
(begin
|
||||
(set! flow-rec-fail (+ flow-rec-fail 1))
|
||||
(append! flow-rec-fails {:name name :expected expected :actual actual})))))
|
||||
|
||||
(define flow-r (fn (src) (flow-run src)))
|
||||
|
||||
;; ── export / wipe / import ──────────────────────────────────────
|
||||
(flow-rec-test
|
||||
"export nulls the live procedure"
|
||||
(flow-r
|
||||
"(defflow w (lambda (x) (suspend (quote await)))) (flow/start w 10) (car (cdr (car (cdr (car (flow-store-export))))))")
|
||||
false)
|
||||
(flow-rec-test
|
||||
"a wiped store loses the flow (process death)"
|
||||
(flow-r
|
||||
"(defflow w (lambda (x) (suspend (quote await)))) (define id (car (cdr (flow/start w 10)))) (set! flow-store (list)) (flow/resume id 1)")
|
||||
(list "flow-error" "no-such-flow"))
|
||||
(flow-rec-test
|
||||
"import restores a wiped store and resume completes"
|
||||
(flow-r
|
||||
"(defflow w (sequence (lambda (x) (suspend (quote await))) (lambda (c) (list (quote done) c)))) (define id (car (cdr (flow/start w 10)))) (define saved (flow-store-export)) (set! flow-store (list)) (flow-store-import! saved) (flow/resume id 777)")
|
||||
(list "done" 777))
|
||||
|
||||
;; ── resumable scan ──────────────────────────────────────────────
|
||||
(flow-rec-test
|
||||
"resumable-ids lists the suspended flow after import"
|
||||
(flow-r
|
||||
"(defflow w (lambda (x) (suspend (quote await)))) (define id (car (cdr (flow/start w 10)))) (define saved (flow-store-export)) (set! flow-store (list)) (flow-store-import! saved) (flow-resumable-ids)")
|
||||
(list 1))
|
||||
(flow-rec-test
|
||||
"resumable-ids excludes completed flows"
|
||||
(flow-r
|
||||
"(defflow w (sequence (lambda (x) (suspend (quote await))) (lambda (c) c))) (define id (car (cdr (flow/start w 10)))) (flow/resume id 5) (flow-resumable-ids)")
|
||||
(list))
|
||||
(flow-rec-test
|
||||
"resumable-ids excludes cancelled flows after import"
|
||||
(flow-r
|
||||
"(defflow w (lambda (x) (suspend (quote await)))) (define id (car (cdr (flow/start w 10)))) (flow/cancel id) (define saved (flow-store-export)) (set! flow-store (list)) (flow-store-import! saved) (flow-resumable-ids)")
|
||||
(list))
|
||||
|
||||
;; ── restart at every step ───────────────────────────────────────
|
||||
(flow-rec-test
|
||||
"two suspends survive a restart between each step"
|
||||
(flow-r
|
||||
"(defflow two (sequence (lambda (x) (suspend (quote a))) (lambda (x) (suspend (quote b))) (lambda (x) (list (quote end) x)))) (define id (car (cdr (flow/start two 0)))) (define s1 (flow-store-export)) (set! flow-store (list)) (flow-store-import! s1) (flow/resume id 100) (define s2 (flow-store-export)) (set! flow-store (list)) (flow-store-import! s2) (flow/resume id 200)")
|
||||
(list "end" 200))
|
||||
(flow-rec-test
|
||||
"import preserves the replay log (earlier value survives restart)"
|
||||
(flow-r
|
||||
"(defflow two (sequence (lambda (x) (suspend (quote a))) (lambda (x) (suspend (quote b))) (lambda (x) (list x)))) (define id (car (cdr (flow/start two 0)))) (flow/resume id 11) (define saved (flow-store-export)) (set! flow-store (list)) (flow-store-import! saved) (flow/resume id 22)")
|
||||
(list 22))
|
||||
|
||||
(define flow-rec-tests-run! (fn () {:total (+ flow-rec-pass flow-rec-fail) :passed flow-rec-pass :failed flow-rec-fail :fails flow-rec-fails}))
|
||||
114
lib/flow/tests/suspend.sx
Normal file
114
lib/flow/tests/suspend.sx
Normal file
@@ -0,0 +1,114 @@
|
||||
;; lib/flow/tests/suspend.sx — Phase 3: suspend / resume / cancel (deterministic replay).
|
||||
|
||||
(define flow-sus-pass 0)
|
||||
(define flow-sus-fail 0)
|
||||
(define flow-sus-fails (list))
|
||||
|
||||
(define
|
||||
flow-sus-test
|
||||
(fn
|
||||
(name actual expected)
|
||||
(if
|
||||
(= actual expected)
|
||||
(set! flow-sus-pass (+ flow-sus-pass 1))
|
||||
(begin
|
||||
(set! flow-sus-fail (+ flow-sus-fail 1))
|
||||
(append! flow-sus-fails {:name name :expected expected :actual actual})))))
|
||||
|
||||
(define flow-s (fn (src) (flow-run src)))
|
||||
|
||||
;; ── flow/start ──────────────────────────────────────────────────
|
||||
(flow-sus-test
|
||||
"start: non-suspending flow returns the raw result"
|
||||
(flow-s "(flow/start (lambda (x) (* x 2)) 5)")
|
||||
10)
|
||||
(flow-sus-test
|
||||
"start: a suspending flow returns a flow-suspended state"
|
||||
(flow-s
|
||||
"(defflow w (sequence (lambda (x) (+ x 1)) (lambda (g) (suspend (quote await))) (lambda (c) c))) (car (flow/start w 10))")
|
||||
"flow-suspended")
|
||||
(flow-sus-test
|
||||
"start: suspended state carries a numeric id"
|
||||
(flow-s
|
||||
"(defflow w (lambda (x) (suspend (quote await)))) (car (cdr (flow/start w 10)))")
|
||||
1)
|
||||
(flow-sus-test
|
||||
"start: suspended state carries the suspend tag"
|
||||
(flow-s
|
||||
"(defflow w (lambda (x) (suspend (quote await)))) (car (cdr (cdr (flow/start w 10))))")
|
||||
"await")
|
||||
|
||||
;; ── flow/resume ─────────────────────────────────────────────────
|
||||
(flow-sus-test
|
||||
"resume: injects the value and completes"
|
||||
(flow-s
|
||||
"(defflow w (sequence (lambda (x) (+ x 1)) (lambda (g) (suspend (quote await))) (lambda (c) (list (quote done) c)))) (define s (flow/start w 10)) (flow/resume (car (cdr s)) 777)")
|
||||
(list "done" 777))
|
||||
(flow-sus-test
|
||||
"resume: injected value threads into the next node"
|
||||
(flow-s
|
||||
"(defflow w (sequence (lambda (x) (suspend (quote v))) (lambda (n) (* n 3)))) (define s (flow/start w 0)) (flow/resume (car (cdr s)) 14)")
|
||||
42)
|
||||
(flow-sus-test
|
||||
"resume: replays earlier suspends (recompute is deterministic)"
|
||||
(flow-s
|
||||
"(define runs 0) (defflow w (sequence (lambda (x) (begin (set! runs (+ runs 1)) (+ x 1))) (lambda (g) (suspend (quote await))) (lambda (c) c))) (define s (flow/start w 10)) (flow/resume (car (cdr s)) 99) runs")
|
||||
2)
|
||||
|
||||
;; ── multi-step suspension ───────────────────────────────────────
|
||||
(flow-sus-test
|
||||
"multi: first resume suspends at the next tag"
|
||||
(flow-s
|
||||
"(defflow two (sequence (lambda (x) (suspend (quote a))) (lambda (x) (suspend (quote b))) (lambda (x) (list (quote end) x)))) (define s (flow/start two 0)) (define s2 (flow/resume (car (cdr s)) 100)) (car (cdr (cdr s2)))")
|
||||
"b")
|
||||
(flow-sus-test
|
||||
"multi: second resume completes with the latest value"
|
||||
(flow-s
|
||||
"(defflow two (sequence (lambda (x) (suspend (quote a))) (lambda (x) (suspend (quote b))) (lambda (x) (list (quote end) x)))) (define id (car (cdr (flow/start two 0)))) (flow/resume id 100) (flow/resume id 200)")
|
||||
(list "end" 200))
|
||||
|
||||
;; ── error / lifecycle guards ────────────────────────────────────
|
||||
(flow-sus-test
|
||||
"resume: completed flow cannot be resumed again"
|
||||
(flow-s
|
||||
"(defflow w (lambda (x) (suspend (quote q)))) (define id (car (cdr (flow/start w 0)))) (flow/resume id 1) (flow/resume id 2)")
|
||||
(list "flow-error" "not-suspended"))
|
||||
(flow-sus-test
|
||||
"resume: unknown id errors"
|
||||
(flow-s "(flow/resume 999 1)")
|
||||
(list "flow-error" "no-such-flow"))
|
||||
|
||||
;; ── flow/cancel ─────────────────────────────────────────────────
|
||||
(flow-sus-test
|
||||
"cancel: returns a flow-cancelled state"
|
||||
(flow-s
|
||||
"(defflow w (lambda (x) (suspend (quote q)))) (define id (car (cdr (flow/start w 0)))) (flow/cancel id)")
|
||||
(list "flow-cancelled" 1))
|
||||
(flow-sus-test
|
||||
"cancel: a cancelled flow cannot be resumed (stale resume rejected)"
|
||||
(flow-s
|
||||
"(defflow w (lambda (x) (suspend (quote q)))) (define id (car (cdr (flow/start w 0)))) (flow/cancel id) (flow/resume id 5)")
|
||||
(list "flow-error" "not-suspended"))
|
||||
(flow-sus-test
|
||||
"cancel: unknown id errors"
|
||||
(flow-s "(flow/cancel 999)")
|
||||
(list "flow-error" "no-such-flow"))
|
||||
|
||||
;; ── composition ─────────────────────────────────────────────────
|
||||
(flow-sus-test
|
||||
"suspend inside a branch arm"
|
||||
(flow-s
|
||||
"(defflow gate (branch (lambda (x) (> x 0)) (lambda (x) (suspend (quote approve))) (flow-const (quote rejected)))) (define s (flow/start gate 5)) (flow/resume (car (cdr s)) (quote approved))")
|
||||
"approved")
|
||||
(flow-sus-test
|
||||
"two independent runs get independent ids"
|
||||
(flow-s
|
||||
"(defflow w (lambda (x) (suspend (quote q)))) (list (car (cdr (flow/start w 0))) (car (cdr (flow/start w 0))))")
|
||||
(list 1 2))
|
||||
(flow-sus-test
|
||||
"suspend reason may be a structured value"
|
||||
(flow-s
|
||||
"(defflow w (lambda (x) (suspend (list (quote needs) (quote approval))))) (car (cdr (cdr (flow/start w 0))))")
|
||||
(list "needs" "approval"))
|
||||
|
||||
(define flow-sus-tests-run! (fn () {:total (+ flow-sus-pass flow-sus-fail) :passed flow-sus-pass :failed flow-sus-fail :fails flow-sus-fails}))
|
||||
40
lib/mod/activity.sx
Normal file
40
lib/mod/activity.sx
Normal file
@@ -0,0 +1,40 @@
|
||||
;; lib/mod/activity.sx — export decisions as ActivityPub-shaped events.
|
||||
;;
|
||||
;; The rose-ash platform propagates cross-domain effects as ActivityPub-shaped
|
||||
;; activities. A moderation decision maps to a moderation verb so the rest of the
|
||||
;; platform (and federated peers) can act on it: remove→Delete, ban→Block,
|
||||
;; hide/escalate→Flag, keep→no activity. The precise mod action is preserved in
|
||||
;; :action so a consumer can disambiguate (e.g. hide vs escalate, both Flag).
|
||||
|
||||
(define
|
||||
mod/action->verb
|
||||
(fn
|
||||
(action)
|
||||
(cond
|
||||
((= action "remove") "Delete")
|
||||
((= action "ban") "Block")
|
||||
((= action "hide") "Flag")
|
||||
((= action "escalate") "Flag")
|
||||
(true nil))))
|
||||
|
||||
(define
|
||||
mod/decision->activity
|
||||
(fn
|
||||
(d actor)
|
||||
(let
|
||||
((verb (mod/action->verb (get d :action))))
|
||||
(if (nil? verb) nil {:type verb :action (get d :action) :actor actor :summary (str "moderation/" (get d :action) " via " (get d :rule)) :object (get d :report-id) :rule (get d :rule)}))))
|
||||
|
||||
;; map a batch of decisions to activities, dropping the no-op keeps
|
||||
(define
|
||||
mod/decisions->activities
|
||||
(fn
|
||||
(decisions actor)
|
||||
(reduce
|
||||
(fn
|
||||
(acc d)
|
||||
(let
|
||||
((a (mod/decision->activity d actor)))
|
||||
(if (nil? a) acc (append acc (list a)))))
|
||||
(list)
|
||||
decisions)))
|
||||
163
lib/mod/api.sx
Normal file
163
lib/mod/api.sx
Normal file
@@ -0,0 +1,163 @@
|
||||
;; lib/mod/api.sx — report registry + lifecycle façade + public entry points.
|
||||
;;
|
||||
;; mod/report files a report (assigning a sequential id) and opens a lifecycle
|
||||
;; case for it; mod/add-evidence accumulates evidence; mod/decide runs the engine
|
||||
;; and commits to the audit log. The lifecycle façade (mod/triage, mod/resolve,
|
||||
;; mod/review, mod/appeal, mod/finalize) drives the per-report case through its
|
||||
;; states, logging each committed decision to the audit trail.
|
||||
|
||||
(define mod/*reports* (list))
|
||||
(define mod/*cases* (list))
|
||||
(define mod/*counter* 0)
|
||||
(define mod/*rules* mod/default-rules)
|
||||
|
||||
(define
|
||||
mod/reset!
|
||||
(fn
|
||||
()
|
||||
(begin
|
||||
(set! mod/*reports* (list))
|
||||
(set! mod/*cases* (list))
|
||||
(set! mod/*counter* 0)
|
||||
(mod/audit-reset!))))
|
||||
|
||||
(define
|
||||
mod/report
|
||||
(fn
|
||||
(by about reason)
|
||||
(begin
|
||||
(set! mod/*counter* (+ mod/*counter* 1))
|
||||
(let
|
||||
((id (str "r" mod/*counter*)))
|
||||
(let
|
||||
((r (mod/mk-report id by about reason)))
|
||||
(begin
|
||||
(append! mod/*reports* r)
|
||||
(append! mod/*cases* {:id id :case (mod/mk-case r)})
|
||||
r))))))
|
||||
|
||||
(define
|
||||
mod/get-report
|
||||
(fn
|
||||
(id)
|
||||
(reduce
|
||||
(fn (acc r) (if (= (mod/report-id r) id) r acc))
|
||||
nil
|
||||
mod/*reports*)))
|
||||
|
||||
(define
|
||||
mod/add-evidence
|
||||
(fn
|
||||
(id kind val)
|
||||
(let
|
||||
((r (mod/get-report id)))
|
||||
(if
|
||||
(nil? r)
|
||||
nil
|
||||
(let
|
||||
((updated (mod/attach-evidence r (mod/mk-evidence kind val))))
|
||||
(begin
|
||||
(set!
|
||||
mod/*reports*
|
||||
(map
|
||||
(fn (x) (if (= (mod/report-id x) id) updated x))
|
||||
mod/*reports*))
|
||||
updated))))))
|
||||
|
||||
(define
|
||||
mod/decide
|
||||
(fn
|
||||
(id)
|
||||
(let
|
||||
((r (mod/get-report id)))
|
||||
(if
|
||||
(nil? r)
|
||||
nil
|
||||
(let
|
||||
((d (mod/decide-report r mod/*reports* mod/*rules*)))
|
||||
(begin (mod/log-decision! d (mod/report-evidence r)) d))))))
|
||||
|
||||
;; ── lifecycle façade over the case registry ──
|
||||
|
||||
(define
|
||||
mod/case-of
|
||||
(fn
|
||||
(id)
|
||||
(reduce
|
||||
(fn (acc rec) (if (= (get rec :id) id) (get rec :case) acc))
|
||||
nil
|
||||
mod/*cases*)))
|
||||
|
||||
(define
|
||||
mod/case-store!
|
||||
(fn
|
||||
(id c)
|
||||
(set!
|
||||
mod/*cases*
|
||||
(map
|
||||
(fn (rec) (if (= (get rec :id) id) {:id id :case c} rec))
|
||||
mod/*cases*))))
|
||||
|
||||
;; apply a lifecycle op to the stored case, persist it, and (when a decision was
|
||||
;; committed cleanly) append it to the audit log; returns the updated case
|
||||
(define
|
||||
mod/case-apply!
|
||||
(fn
|
||||
(id op log?)
|
||||
(let
|
||||
((c (mod/case-of id)))
|
||||
(if
|
||||
(nil? c)
|
||||
nil
|
||||
(let
|
||||
((c2 (op c)))
|
||||
(begin
|
||||
(mod/case-store! id c2)
|
||||
(when
|
||||
log?
|
||||
(when
|
||||
(nil? (mod/case-error c2))
|
||||
(let
|
||||
((d (mod/case-decision c2)))
|
||||
(if
|
||||
(nil? d)
|
||||
nil
|
||||
(mod/log-decision!
|
||||
d
|
||||
(mod/report-evidence (mod/case-report c2)))))))
|
||||
c2))))))
|
||||
|
||||
(define
|
||||
mod/triage
|
||||
(fn
|
||||
(id)
|
||||
(mod/case-apply!
|
||||
id
|
||||
(fn (c) (mod/case-triage c mod/*reports* mod/*rules*))
|
||||
false)))
|
||||
|
||||
(define
|
||||
mod/resolve
|
||||
(fn (id) (mod/case-apply! id (fn (c) (mod/case-resolve c)) true)))
|
||||
|
||||
(define
|
||||
mod/review
|
||||
(fn
|
||||
(id kind val)
|
||||
(mod/case-apply!
|
||||
id
|
||||
(fn (c) (mod/case-review c kind val mod/*reports* mod/*rules*))
|
||||
true)))
|
||||
|
||||
(define
|
||||
mod/appeal
|
||||
(fn
|
||||
(id kind val)
|
||||
(mod/case-apply!
|
||||
id
|
||||
(fn (c) (mod/case-appeal c kind val mod/*reports* mod/*rules*))
|
||||
true)))
|
||||
|
||||
(define
|
||||
mod/finalize
|
||||
(fn (id) (mod/case-apply! id (fn (c) (mod/case-finalize c)) false)))
|
||||
54
lib/mod/audit.sx
Normal file
54
lib/mod/audit.sx
Normal file
@@ -0,0 +1,54 @@
|
||||
;; lib/mod/audit.sx — append-only decision log.
|
||||
;;
|
||||
;; Every decision the api commits is recorded as an immutable audit entry holding
|
||||
;; the decision (action + matching rule), the proof tree (the derivation that
|
||||
;; justified it), and a snapshot of the evidence in force at decision time. The
|
||||
;; log is append-only: entries are never mutated or removed, only appended, each
|
||||
;; with a monotonic sequence number. Retrieval is by report id (full history) or
|
||||
;; by sequence.
|
||||
|
||||
(define mod/*audit-log* (list))
|
||||
(define mod/*audit-seq* 0)
|
||||
|
||||
(define
|
||||
mod/audit-reset!
|
||||
(fn
|
||||
()
|
||||
(begin (set! mod/*audit-log* (list)) (set! mod/*audit-seq* 0))))
|
||||
|
||||
(define mod/mk-audit-entry (fn (seq decision evidence-snapshot) {:action (get decision :action) :evidence evidence-snapshot :proof (get decision :proof) :rule (get decision :rule) :report-id (get decision :report-id) :seq seq}))
|
||||
|
||||
(define
|
||||
mod/log-decision!
|
||||
(fn
|
||||
(decision evidence-snapshot)
|
||||
(begin
|
||||
(set! mod/*audit-seq* (+ mod/*audit-seq* 1))
|
||||
(let
|
||||
((entry (mod/mk-audit-entry mod/*audit-seq* decision evidence-snapshot)))
|
||||
(begin (append! mod/*audit-log* entry) entry)))))
|
||||
|
||||
;; entries for one report, in chronological (sequence) order
|
||||
(define
|
||||
mod/audit
|
||||
(fn
|
||||
(id)
|
||||
(reduce
|
||||
(fn
|
||||
(acc e)
|
||||
(if (= (get e :report-id) id) (append acc (list e)) acc))
|
||||
(list)
|
||||
mod/*audit-log*)))
|
||||
|
||||
(define mod/audit-all (fn () mod/*audit-log*))
|
||||
(define mod/audit-count (fn () (len mod/*audit-log*)))
|
||||
|
||||
;; most recent decision logged for a report (nil if none)
|
||||
(define
|
||||
mod/audit-latest
|
||||
(fn
|
||||
(id)
|
||||
(reduce
|
||||
(fn (acc e) (if (= (get e :report-id) id) e acc))
|
||||
nil
|
||||
mod/*audit-log*)))
|
||||
55
lib/mod/batch.sx
Normal file
55
lib/mod/batch.sx
Normal file
@@ -0,0 +1,55 @@
|
||||
;; lib/mod/batch.sx — batch triage + corpus analytics.
|
||||
;;
|
||||
;; Operational layer: decide a whole queue of reports at once, summarize the
|
||||
;; outcomes by action, and measure which rules actually fire across a corpus.
|
||||
;; mod/never-fired is the empirical complement to lint's static unreachable check
|
||||
;; (Ext 5): lint finds rules that CAN'T fire by structure; never-fired finds rules
|
||||
;; that DIDN'T fire on real data.
|
||||
|
||||
(define
|
||||
mod/decide-batch
|
||||
(fn
|
||||
(reports rules)
|
||||
(map (fn (r) (mod/decide-report r reports rules)) reports)))
|
||||
|
||||
(define
|
||||
mod/count-action
|
||||
(fn
|
||||
(decisions action)
|
||||
(reduce
|
||||
(fn (acc d) (if (= (get d :action) action) (+ acc 1) acc))
|
||||
0
|
||||
decisions)))
|
||||
|
||||
(define mod/action-histogram (fn (decisions) {:keep (mod/count-action decisions "keep") :remove (mod/count-action decisions "remove") :escalate (mod/count-action decisions "escalate") :hide (mod/count-action decisions "hide") :ban (mod/count-action decisions "ban")}))
|
||||
|
||||
(define
|
||||
mod/rule-fire-count
|
||||
(fn
|
||||
(decisions rule-name)
|
||||
(reduce
|
||||
(fn (acc d) (if (= (get d :rule) rule-name) (+ acc 1) acc))
|
||||
0
|
||||
decisions)))
|
||||
|
||||
(define
|
||||
mod/rule-coverage
|
||||
(fn
|
||||
(reports rules)
|
||||
(let
|
||||
((decisions (mod/decide-batch reports rules)))
|
||||
(map (fn (rule) {:rule (mod/rule-name rule) :fired (mod/rule-fire-count decisions (mod/rule-name rule))}) rules))))
|
||||
|
||||
(define
|
||||
mod/never-fired
|
||||
(fn
|
||||
(reports rules)
|
||||
(reduce
|
||||
(fn
|
||||
(acc c)
|
||||
(if
|
||||
(= (get c :fired) 0)
|
||||
(append acc (list (get c :rule)))
|
||||
acc))
|
||||
(list)
|
||||
(mod/rule-coverage reports rules))))
|
||||
60
lib/mod/conformance.conf
Normal file
60
lib/mod/conformance.conf
Normal file
@@ -0,0 +1,60 @@
|
||||
# Mod conformance config — sourced by lib/guest/conformance.sh.
|
||||
|
||||
LANG_NAME=mod
|
||||
MODE=dict
|
||||
|
||||
PRELOADS=(
|
||||
lib/guest/pratt.sx
|
||||
lib/prolog/tokenizer.sx
|
||||
lib/prolog/parser.sx
|
||||
lib/prolog/runtime.sx
|
||||
lib/prolog/query.sx
|
||||
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
|
||||
lib/mod/offenders.sx
|
||||
lib/mod/quorum.sx
|
||||
lib/mod/trace.sx
|
||||
lib/mod/whatif.sx
|
||||
lib/mod/batch.sx
|
||||
lib/mod/temporal.sx
|
||||
lib/mod/sla.sx
|
||||
lib/mod/wire.sx
|
||||
lib/mod/activity.sx
|
||||
lib/mod/policies.sx
|
||||
lib/mod/pipeline.sx
|
||||
lib/mod/lifecycle.sx
|
||||
lib/mod/audit.sx
|
||||
lib/mod/api.sx
|
||||
lib/mod/fed.sx
|
||||
lib/mod/link.sx
|
||||
lib/mod/lint.sx
|
||||
)
|
||||
|
||||
SUITES=(
|
||||
"decide:lib/mod/tests/decide.sx:(mod-decide-tests-run!)"
|
||||
"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!)"
|
||||
"link:lib/mod/tests/link.sx:(mod-link-tests-run!)"
|
||||
"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!)"
|
||||
"trace:lib/mod/tests/trace.sx:(mod-trace-tests-run!)"
|
||||
"whatif:lib/mod/tests/whatif.sx:(mod-whatif-tests-run!)"
|
||||
"batch:lib/mod/tests/batch.sx:(mod-batch-tests-run!)"
|
||||
"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!)"
|
||||
"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!)"
|
||||
"pipeline:lib/mod/tests/pipeline.sx:(mod-pipeline-tests-run!)"
|
||||
)
|
||||
3
lib/mod/conformance.sh
Executable file
3
lib/mod/conformance.sh
Executable file
@@ -0,0 +1,3 @@
|
||||
#!/usr/bin/env bash
|
||||
# Thin wrapper — see lib/guest/conformance.sh and lib/mod/conformance.conf.
|
||||
exec bash "$(dirname "$0")/../guest/conformance.sh" "$(dirname "$0")/conformance.conf" "$@"
|
||||
16
lib/mod/defrule.sx
Normal file
16
lib/mod/defrule.sx
Normal file
@@ -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))
|
||||
64
lib/mod/engine.sx
Normal file
64
lib/mod/engine.sx
Normal file
@@ -0,0 +1,64 @@
|
||||
;; lib/mod/engine.sx — decide a report by querying the policy program.
|
||||
;;
|
||||
;; build-program assembles the report's facts plus the compiled policy clauses;
|
||||
;; decide-report runs the Prolog query and returns a decision. A decision is a
|
||||
;; proof, not a bare keyword: it carries the matching rule, the conditions it
|
||||
;; required, the evidence that satisfied them, and a derivation — the proof tree.
|
||||
;;
|
||||
;; The proof tree is built constructively: for the matching rule, each body goal
|
||||
;; is re-queried against the same DB with the report id bound, recording the goal
|
||||
;; text, whether it was solved, and the bindings that satisfied it. That is a
|
||||
;; genuine derivation drawn from the Prolog database, ready for the audit trail.
|
||||
|
||||
(define
|
||||
mod/find-rule
|
||||
(fn
|
||||
(rules name)
|
||||
(reduce
|
||||
(fn
|
||||
(acc r)
|
||||
(if (nil? acc) (if (= (mod/rule-name r) name) r acc) acc))
|
||||
nil
|
||||
rules)))
|
||||
|
||||
(define
|
||||
mod/build-program
|
||||
(fn
|
||||
(r count rules)
|
||||
(str (mod/report-facts r count) "\n" (mod/rules->program rules))))
|
||||
|
||||
(define
|
||||
mod/proof-goals
|
||||
(fn
|
||||
(db id conds)
|
||||
(if
|
||||
(empty? conds)
|
||||
(list {:solved true :goal "true" :bindings {}})
|
||||
(map
|
||||
(fn
|
||||
(c)
|
||||
(let
|
||||
((g (mod/cond->goal c id)))
|
||||
(let ((sols (pl-query-all db g))) {:solved (if (empty? sols) false true) :goal g :bindings (if (empty? sols) {} (first sols))})))
|
||||
conds))))
|
||||
|
||||
(define
|
||||
mod/decide-report
|
||||
(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-program r count 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"}
|
||||
(let
|
||||
((rname (dict-get sol "Rule")))
|
||||
(let ((rule (mod/find-rule rules rname))) {:action (mod/rule-action rule) :proof {:goals (mod/proof-goals db id (mod/rule-when rule)) :evidence kinds :conditions (mod/rule-when rule) :rule rname :count count} :report-id id :rule rname})))))))))
|
||||
55
lib/mod/explain.sx
Normal file
55
lib/mod/explain.sx
Normal file
@@ -0,0 +1,55 @@
|
||||
;; lib/mod/explain.sx — human-readable proof explanation.
|
||||
;;
|
||||
;; Turns a decision (from mod/decide-report, or any audit entry) into a readable
|
||||
;; multi-line "why": the action, the rule that fired, the evidence in play, and
|
||||
;; the derivation goal-by-goal with [proved]/[unproved] marks and the unification
|
||||
;; bindings that satisfied each goal. Pure SX over the Phase-2 proof tree.
|
||||
|
||||
(define
|
||||
mod/explain-binds
|
||||
(fn
|
||||
(binds)
|
||||
(mod/join-with
|
||||
", "
|
||||
(map (fn (k) (str k "=" (dict-get binds k))) (keys binds)))))
|
||||
|
||||
(define
|
||||
mod/explain-goal
|
||||
(fn
|
||||
(g)
|
||||
(let
|
||||
((mark (if (get g :solved) " [proved] " " [unproved] "))
|
||||
(binds (get g :bindings)))
|
||||
(if
|
||||
(empty? (keys binds))
|
||||
(str mark (get g :goal))
|
||||
(str mark (get g :goal) " {" (mod/explain-binds binds) "}")))))
|
||||
|
||||
(define
|
||||
mod/explain-evidence
|
||||
(fn
|
||||
(evidence)
|
||||
(if
|
||||
(empty? evidence)
|
||||
"Evidence: (none)"
|
||||
(str "Evidence: " (mod/join-with ", " evidence)))))
|
||||
|
||||
(define
|
||||
mod/explain
|
||||
(fn
|
||||
(decision)
|
||||
(let
|
||||
((id (get decision :report-id))
|
||||
(action (get decision :action))
|
||||
(rule (get decision :rule))
|
||||
(proof (get decision :proof)))
|
||||
(let
|
||||
((goals (get proof :goals)) (evidence (get proof :evidence)))
|
||||
(mod/join-with
|
||||
"\n"
|
||||
(append
|
||||
(list
|
||||
(str "Report " id ": " action " (rule: " rule ")")
|
||||
(mod/explain-evidence evidence)
|
||||
"Because:")
|
||||
(map mod/explain-goal goals)))))))
|
||||
145
lib/mod/fed.sx
Normal file
145
lib/mod/fed.sx
Normal file
@@ -0,0 +1,145 @@
|
||||
;; lib/mod/fed.sx — federation: cross-instance reports, decision sharing, trust,
|
||||
;; revocation. fed-sx itself is mocked here (an in-memory outbox); the real wire
|
||||
;; transport would replace mod/fed-send!.
|
||||
;;
|
||||
;; Trust is advisory by default (the hard rule): a peer's decision only binds
|
||||
;; locally when (mod/trusted? peer :mod) holds. An untrusted peer's decision is
|
||||
;; recorded as a suggestion in the advisory log and is NOT applied. Local
|
||||
;; decisions propagate outward via the outbox. Revocation undoes a locally
|
||||
;; applied action when its proof is invalidated, notifying the origin peer.
|
||||
|
||||
(define mod/*fed-trust* (list)) ;; {:peer :scope}
|
||||
(define mod/*fed-outbox* (list)) ;; {:to :type :payload}
|
||||
(define mod/*fed-advisory* (list)) ;; {:peer :decision} — received, not applied
|
||||
(define mod/*fed-applied* (list)) ;; {:report-id :action :origin :revoked}
|
||||
(define mod/*fed-origins* (list)) ;; {:id :origin}
|
||||
|
||||
(define
|
||||
mod/fed-reset!
|
||||
(fn
|
||||
()
|
||||
(begin
|
||||
(set! mod/*fed-trust* (list))
|
||||
(set! mod/*fed-outbox* (list))
|
||||
(set! mod/*fed-advisory* (list))
|
||||
(set! mod/*fed-applied* (list))
|
||||
(set! mod/*fed-origins* (list)))))
|
||||
|
||||
;; ── trust model ──
|
||||
|
||||
(define
|
||||
mod/trust-match?
|
||||
(fn
|
||||
(t peer scope)
|
||||
(if (= (get t :peer) peer) (= (get t :scope) scope) false)))
|
||||
|
||||
(define
|
||||
mod/grant-trust
|
||||
(fn (peer scope) (begin (append! mod/*fed-trust* {:scope scope :peer peer}) true)))
|
||||
|
||||
(define
|
||||
mod/revoke-trust
|
||||
(fn
|
||||
(peer scope)
|
||||
(set!
|
||||
mod/*fed-trust*
|
||||
(reduce
|
||||
(fn
|
||||
(acc t)
|
||||
(if (mod/trust-match? t peer scope) acc (append acc (list t))))
|
||||
(list)
|
||||
mod/*fed-trust*))))
|
||||
|
||||
(define
|
||||
mod/trusted?
|
||||
(fn
|
||||
(peer scope)
|
||||
(mod/any? (fn (t) (mod/trust-match? t peer scope)) mod/*fed-trust*)))
|
||||
|
||||
;; ── cross-instance reports ──
|
||||
|
||||
(define
|
||||
mod/fed-receive-report
|
||||
(fn
|
||||
(peer by about reason)
|
||||
(let
|
||||
((r (mod/report by about reason)))
|
||||
(begin (append! mod/*fed-origins* {:id (mod/report-id r) :origin peer}) r))))
|
||||
|
||||
(define
|
||||
mod/report-origin
|
||||
(fn
|
||||
(id)
|
||||
(reduce
|
||||
(fn (acc o) (if (= (get o :id) id) (get o :origin) acc))
|
||||
"local"
|
||||
mod/*fed-origins*)))
|
||||
|
||||
;; ── decision sharing (mock fed-sx send) ──
|
||||
|
||||
(define
|
||||
mod/fed-send!
|
||||
(fn (to type payload) (begin (append! mod/*fed-outbox* {:type type :to to :payload payload}) true)))
|
||||
|
||||
(define mod/fed-outbox (fn () mod/*fed-outbox*))
|
||||
|
||||
(define
|
||||
mod/fed-share-decision
|
||||
(fn
|
||||
(decision peers)
|
||||
(reduce
|
||||
(fn
|
||||
(acc p)
|
||||
(begin (mod/fed-send! p "decision" decision) (append acc (list p))))
|
||||
(list)
|
||||
peers)))
|
||||
|
||||
;; ── receiving a peer's decision (advisory unless trusted) ──
|
||||
|
||||
(define
|
||||
mod/fed-applied-action
|
||||
(fn
|
||||
(report-id)
|
||||
(reduce
|
||||
(fn (acc a) (if (= (get a :report-id) report-id) a acc))
|
||||
nil
|
||||
mod/*fed-applied*)))
|
||||
|
||||
(define
|
||||
mod/fed-receive-decision
|
||||
(fn
|
||||
(peer decision)
|
||||
(if
|
||||
(mod/trusted? peer :mod)
|
||||
(begin (append! mod/*fed-applied* {:revoked false :action (get decision :action) :report-id (get decision :report-id) :origin peer}) {:advisory false :peer peer :applied true :decision decision})
|
||||
(begin (append! mod/*fed-advisory* {:peer peer :decision decision}) {:advisory true :peer peer :applied false :decision decision}))))
|
||||
|
||||
;; ── revocation ──
|
||||
|
||||
(define
|
||||
mod/fed-revoke!
|
||||
(fn
|
||||
(report-id reason)
|
||||
(begin
|
||||
(set!
|
||||
mod/*fed-applied*
|
||||
(map
|
||||
(fn (a) (if (= (get a :report-id) report-id) {:revoked true :action (get a :action) :report-id (get a :report-id) :origin (get a :origin)} a))
|
||||
mod/*fed-applied*))
|
||||
(mod/fed-send! (mod/report-origin report-id) "revocation" {:report-id report-id :reason reason})
|
||||
report-id)))
|
||||
|
||||
;; re-run the engine; if the action no longer holds, the prior decision's proof
|
||||
;; is invalidated — revoke the applied moderation.
|
||||
(define
|
||||
mod/fed-revoke-if-invalidated
|
||||
(fn
|
||||
(report decision reports rules)
|
||||
(let
|
||||
((d2 (mod/decide-report report reports rules)))
|
||||
(if
|
||||
(= (get d2 :action) (get decision :action))
|
||||
{:revoked false :decision d2}
|
||||
(begin
|
||||
(mod/fed-revoke! (get decision :report-id) "proof invalidated")
|
||||
{:revoked true :decision d2})))))
|
||||
160
lib/mod/lifecycle.sx
Normal file
160
lib/mod/lifecycle.sx
Normal file
@@ -0,0 +1,160 @@
|
||||
;; lib/mod/lifecycle.sx — report lifecycle state machine (pure SX over the engine).
|
||||
;;
|
||||
;; Lifecycle state is deliberately separate from policy: the Prolog rules answer
|
||||
;; "what action?", this module answers "where in the process is this report?".
|
||||
;;
|
||||
;; :open ──triage──▶ :triaged ──resolve/review──▶ :decided ──appeal──▶ :appealed
|
||||
;; │ │
|
||||
;; └────finalize───▶ :final ◀┘
|
||||
;;
|
||||
;; A case is an immutable value {:report :state :decision :tier :error :history}.
|
||||
;; Every transition returns a NEW case; illegal transitions return the case
|
||||
;; unchanged with :error set. Tiers: triage runs the engine (auto-tier); a
|
||||
;; terminal action (hide/remove/keep) resolves immediately, an :escalate action
|
||||
;; flags the case for human review (human-tier) before it can be resolved.
|
||||
|
||||
(define mod/case* (fn (report state decision tier err history) {:history history :state state :report report :error err :tier tier :decision decision}))
|
||||
|
||||
(define
|
||||
mod/mk-case
|
||||
(fn (report) (mod/case* report "open" nil nil nil (list))))
|
||||
|
||||
(define mod/case-report (fn (c) (get c :report)))
|
||||
(define mod/case-state (fn (c) (get c :state)))
|
||||
(define mod/case-decision (fn (c) (get c :decision)))
|
||||
(define mod/case-tier (fn (c) (get c :tier)))
|
||||
(define mod/case-error (fn (c) (get c :error)))
|
||||
(define mod/case-history (fn (c) (get c :history)))
|
||||
|
||||
;; ── transition table ──
|
||||
|
||||
(define mod/lc-transitions {:final (list) :appealed (list "final") :decided (list "appealed" "final") :open (list "triaged") :triaged (list "decided")})
|
||||
|
||||
(define mod/member? (fn (x lst) (mod/any? (fn (y) (= y x)) lst)))
|
||||
|
||||
(define
|
||||
mod/lc-can-transition?
|
||||
(fn
|
||||
(from to)
|
||||
(let
|
||||
((outs (get mod/lc-transitions from)))
|
||||
(if (nil? outs) false (mod/member? to outs)))))
|
||||
|
||||
;; ── core transition: validate, record history, or flag :error ──
|
||||
|
||||
(define
|
||||
mod/case-goto
|
||||
(fn
|
||||
(c to note report decision tier)
|
||||
(let
|
||||
((from (mod/case-state c)))
|
||||
(if
|
||||
(mod/lc-can-transition? from to)
|
||||
(mod/case*
|
||||
report
|
||||
to
|
||||
decision
|
||||
tier
|
||||
nil
|
||||
(append (mod/case-history c) (list {:note note :to to :from from})))
|
||||
(mod/case*
|
||||
(mod/case-report c)
|
||||
from
|
||||
(mod/case-decision c)
|
||||
(mod/case-tier c)
|
||||
(str "illegal transition: " from " -> " to)
|
||||
(mod/case-history c))))))
|
||||
|
||||
(define
|
||||
mod/case-error-set
|
||||
(fn
|
||||
(c msg)
|
||||
(mod/case*
|
||||
(mod/case-report c)
|
||||
(mod/case-state c)
|
||||
(mod/case-decision c)
|
||||
(mod/case-tier c)
|
||||
msg
|
||||
(mod/case-history c))))
|
||||
|
||||
;; ── lifecycle operations ──
|
||||
|
||||
;; :open → :triaged — run the auto-tier first pass.
|
||||
(define
|
||||
mod/case-triage
|
||||
(fn
|
||||
(c reports rules)
|
||||
(let
|
||||
((d (mod/decide-report (mod/case-report c) reports rules)))
|
||||
(let
|
||||
((tier (if (= (get d :action) "escalate") "human" "auto")))
|
||||
(mod/case-goto
|
||||
c
|
||||
"triaged"
|
||||
"auto-tier first pass"
|
||||
(mod/case-report c)
|
||||
d
|
||||
tier)))))
|
||||
|
||||
;; :triaged → :decided — auto-tier resolves; human-tier is blocked until review.
|
||||
(define
|
||||
mod/case-resolve
|
||||
(fn
|
||||
(c)
|
||||
(if
|
||||
(= (mod/case-tier c) "human")
|
||||
(mod/case-error-set c "awaiting human review (escalated)")
|
||||
(mod/case-goto
|
||||
c
|
||||
"decided"
|
||||
"auto-tier resolved"
|
||||
(mod/case-report c)
|
||||
(mod/case-decision c)
|
||||
(mod/case-tier c)))))
|
||||
|
||||
;; :triaged → :decided — human review: attach evidence, re-decide, resolve.
|
||||
(define
|
||||
mod/case-review
|
||||
(fn
|
||||
(c kind val reports rules)
|
||||
(let
|
||||
((nr (mod/attach-evidence (mod/case-report c) (mod/mk-evidence kind val))))
|
||||
(let
|
||||
((d (mod/decide-report nr reports rules)))
|
||||
(mod/case-goto c "decided" (str "human review: " kind) nr d "human")))))
|
||||
|
||||
;; :decided → :appealed — appeal: attach evidence, re-decide (may override).
|
||||
(define
|
||||
mod/case-appeal
|
||||
(fn
|
||||
(c kind val reports rules)
|
||||
(let
|
||||
((nr (mod/attach-evidence (mod/case-report c) (mod/mk-evidence kind val))))
|
||||
(let
|
||||
((d (mod/decide-report nr reports rules)))
|
||||
(mod/case-goto
|
||||
c
|
||||
"appealed"
|
||||
(str "appeal: " kind)
|
||||
nr
|
||||
d
|
||||
(mod/case-tier c))))))
|
||||
|
||||
;; :decided | :appealed → :final
|
||||
(define
|
||||
mod/case-finalize
|
||||
(fn
|
||||
(c)
|
||||
(mod/case-goto
|
||||
c
|
||||
"final"
|
||||
"finalized"
|
||||
(mod/case-report c)
|
||||
(mod/case-decision c)
|
||||
(mod/case-tier c))))
|
||||
|
||||
(define
|
||||
mod/case-action
|
||||
(fn
|
||||
(c)
|
||||
(let ((d (mod/case-decision c))) (if (nil? d) nil (get d :action)))))
|
||||
92
lib/mod/link.sx
Normal file
92
lib/mod/link.sx
Normal file
@@ -0,0 +1,92 @@
|
||||
;; lib/mod/link.sx — report linking + deduplication.
|
||||
;;
|
||||
;; Reports about the same subject form a cluster; identical reports (same
|
||||
;; reporter + subject + reason) are duplicates. Linking is Prolog-backed: all
|
||||
;; report facts are loaded and related ids are found by unification — the same
|
||||
;; relational substrate the policy engine uses, here for retrieval rather than
|
||||
;; decision. Dedup is pure SX over a normalized link key.
|
||||
|
||||
(define
|
||||
mod/link-key
|
||||
(fn
|
||||
(r)
|
||||
(str
|
||||
(mod/report-by r)
|
||||
"|"
|
||||
(mod/report-about r)
|
||||
"|"
|
||||
(downcase (mod/report-reason r)))))
|
||||
|
||||
(define
|
||||
mod/dedup-reports
|
||||
(fn
|
||||
(reports)
|
||||
(reduce
|
||||
(fn
|
||||
(acc r)
|
||||
(if
|
||||
(mod/any? (fn (x) (= (mod/link-key x) (mod/link-key r))) acc)
|
||||
acc
|
||||
(append acc (list r))))
|
||||
(list)
|
||||
reports)))
|
||||
|
||||
(define
|
||||
mod/duplicate-count
|
||||
(fn (reports) (- (len reports) (len (mod/dedup-reports reports)))))
|
||||
|
||||
;; ── Prolog-backed relational retrieval ──
|
||||
|
||||
(define
|
||||
mod/report-rel-facts
|
||||
(fn
|
||||
(reports)
|
||||
(mod/join-with
|
||||
"\n"
|
||||
(map
|
||||
(fn
|
||||
(r)
|
||||
(str
|
||||
"report("
|
||||
(mod/report-id r)
|
||||
", "
|
||||
(mod/pl-quote (mod/report-by r))
|
||||
", "
|
||||
(mod/pl-quote (mod/report-about r))
|
||||
")."))
|
||||
reports))))
|
||||
|
||||
(define
|
||||
mod/related-ids
|
||||
(fn
|
||||
(subject reports)
|
||||
(let
|
||||
((db (pl-load (mod/report-rel-facts reports))))
|
||||
(map
|
||||
(fn (sol) (dict-get sol "Id"))
|
||||
(pl-query-all db (str "report(Id, _, " (mod/pl-quote subject) ")"))))))
|
||||
|
||||
(define
|
||||
mod/reporters-of
|
||||
(fn
|
||||
(subject reports)
|
||||
(let
|
||||
((db (pl-load (mod/report-rel-facts reports))))
|
||||
(map
|
||||
(fn (sol) (dict-get sol "By"))
|
||||
(pl-query-all db (str "report(_, By, " (mod/pl-quote subject) ")"))))))
|
||||
|
||||
(define
|
||||
mod/distinct
|
||||
(fn
|
||||
(items)
|
||||
(reduce
|
||||
(fn
|
||||
(acc x)
|
||||
(if (mod/any? (fn (y) (= y x)) acc) acc (append acc (list x))))
|
||||
(list)
|
||||
items)))
|
||||
|
||||
(define
|
||||
mod/distinct-reporters-of
|
||||
(fn (subject reports) (mod/distinct (mod/reporters-of subject reports))))
|
||||
69
lib/mod/lint.sx
Normal file
69
lib/mod/lint.sx
Normal file
@@ -0,0 +1,69 @@
|
||||
;; lib/mod/lint.sx — static analysis of a policy rule set.
|
||||
;;
|
||||
;; Because precedence is "first matching clause wins" (pl-query-one), the rule
|
||||
;; order has correctness consequences a moderator can get wrong: a rule placed
|
||||
;; after an unconditional (empty :when) rule can never fire, and a rule set with
|
||||
;; no unconditional rule may leave some reports undecided. lint-rules surfaces
|
||||
;; these without running the engine.
|
||||
|
||||
(define mod/rule-unconditional? (fn (r) (empty? (mod/rule-when r))))
|
||||
|
||||
;; names of rules that follow the first unconditional rule — structurally dead,
|
||||
;; since the unconditional rule always matches first
|
||||
(define
|
||||
mod/unreachable-rules
|
||||
(fn
|
||||
(rules)
|
||||
(get
|
||||
(reduce
|
||||
(fn
|
||||
(acc r)
|
||||
(if
|
||||
(get acc :hit)
|
||||
{:dead (append (get acc :dead) (list (mod/rule-name r))) :hit true}
|
||||
(if (mod/rule-unconditional? r) {:dead (get acc :dead) :hit true} acc)))
|
||||
{:dead (list) :hit false}
|
||||
rules)
|
||||
:dead)))
|
||||
|
||||
(define
|
||||
mod/has-catchall?
|
||||
(fn (rules) (mod/any? mod/rule-unconditional? rules)))
|
||||
|
||||
(define
|
||||
mod/count-eq
|
||||
(fn
|
||||
(x lst)
|
||||
(reduce (fn (a y) (if (= y x) (+ a 1) a)) 0 lst)))
|
||||
|
||||
(define
|
||||
mod/duplicate-rule-names
|
||||
(fn
|
||||
(rules)
|
||||
(let
|
||||
((names (map mod/rule-name rules)))
|
||||
(mod/distinct
|
||||
(reduce
|
||||
(fn
|
||||
(acc n)
|
||||
(if
|
||||
(< 1 (mod/count-eq n names))
|
||||
(append acc (list n))
|
||||
acc))
|
||||
(list)
|
||||
names)))))
|
||||
|
||||
(define mod/lint-rules (fn (rules) {:duplicate-names (mod/duplicate-rule-names rules) :has-catchall (mod/has-catchall? rules) :unreachable (mod/unreachable-rules rules)}))
|
||||
|
||||
;; a rule set is well-formed when nothing is dead, it has a catch-all, and rule
|
||||
;; names are unique
|
||||
(define
|
||||
mod/rules-ok?
|
||||
(fn
|
||||
(rules)
|
||||
(let
|
||||
((l (mod/lint-rules rules)))
|
||||
(if
|
||||
(empty? (get l :unreachable))
|
||||
(if (get l :has-catchall) (empty? (get l :duplicate-names)) false)
|
||||
false))))
|
||||
59
lib/mod/offenders.sx
Normal file
59
lib/mod/offenders.sx
Normal file
@@ -0,0 +1,59 @@
|
||||
;; lib/mod/offenders.sx — repeat-offender escalation (audit log as evidence).
|
||||
;;
|
||||
;; The append-only audit trail is itself a source of evidence: a subject already
|
||||
;; sanctioned several times is a repeat offender. mod/decide-escalating decides a
|
||||
;; report normally, then — if the action is a sanction and the subject has at
|
||||
;; least k PRIOR sanctions in the audit log — upgrades it to :ban. This is the one
|
||||
;; place a decision depends on history beyond the single report, and it reads that
|
||||
;; history from the audit log rather than re-deriving it.
|
||||
|
||||
(define
|
||||
mod/sanction?
|
||||
(fn
|
||||
(action)
|
||||
(mod/any? (fn (a) (= a action)) (list "hide" "remove" "ban"))))
|
||||
|
||||
;; count of prior sanctioning decisions in the audit log about a subject
|
||||
(define
|
||||
mod/subject-sanctions
|
||||
(fn
|
||||
(subject)
|
||||
(reduce
|
||||
(fn
|
||||
(acc e)
|
||||
(let
|
||||
((r (mod/get-report (get e :report-id))))
|
||||
(if
|
||||
(nil? r)
|
||||
acc
|
||||
(if
|
||||
(if
|
||||
(= (mod/report-about r) subject)
|
||||
(mod/sanction? (get e :action))
|
||||
false)
|
||||
(+ acc 1)
|
||||
acc))))
|
||||
0
|
||||
(mod/audit-all))))
|
||||
|
||||
(define
|
||||
mod/repeat-offender?
|
||||
(fn (subject k) (<= k (mod/subject-sanctions subject))))
|
||||
|
||||
(define
|
||||
mod/decide-escalating
|
||||
(fn
|
||||
(id k)
|
||||
(let
|
||||
((r (mod/get-report id)))
|
||||
(if
|
||||
(nil? r)
|
||||
nil
|
||||
(let
|
||||
((priors (mod/subject-sanctions (mod/report-about r))))
|
||||
(let
|
||||
((d (mod/decide id)))
|
||||
(if
|
||||
(if (mod/sanction? (get d :action)) (<= k priors) false)
|
||||
{:action "ban" :proof {:goals (get (get d :proof) :goals) :prior-sanctions priors :evidence (get (get d :proof) :evidence) :conditions (list) :rule "repeat-offender-ban" :count (get (get d :proof) :count)} :report-id id :rule "repeat-offender-ban" :strategy "escalating"}
|
||||
d)))))))
|
||||
18
lib/mod/pipeline.sx
Normal file
18
lib/mod/pipeline.sx
Normal file
@@ -0,0 +1,18 @@
|
||||
;; lib/mod/pipeline.sx — end-to-end triage orchestration.
|
||||
;;
|
||||
;; A single entry point that runs a report through the subsystem and returns the
|
||||
;; full artifact bundle: the decision (under the report's domain policy), a
|
||||
;; human-readable explanation, an ActivityPub-shaped event for the bus, and the
|
||||
;; wire line for federated peers. Composes policies (Ext 17), explain (Ext 3),
|
||||
;; activity (Ext 16) and wire (Ext 14) — the modules are independent, this is just
|
||||
;; the convenience that wires them together for the common "process a report" path.
|
||||
|
||||
(define
|
||||
mod/triage-pipeline
|
||||
(fn
|
||||
(domain r reports actor)
|
||||
(let ((d (mod/decide-in domain r reports))) {:activity (mod/decision->activity d actor) :action (get d :action) :wire (mod/decision->wire d) :rule (get d :rule) :decision d :explanation (mod/explain d)})))
|
||||
|
||||
(define mod/pipeline-action (fn (p) (get p :action)))
|
||||
(define mod/pipeline-activity (fn (p) (get p :activity)))
|
||||
(define mod/pipeline-wire (fn (p) (get p :wire)))
|
||||
40
lib/mod/policies.sx
Normal file
40
lib/mod/policies.sx
Normal file
@@ -0,0 +1,40 @@
|
||||
;; lib/mod/policies.sx — per-domain policy registry.
|
||||
;;
|
||||
;; rose-ash spans domains (blog, market, events, federation, …) that want
|
||||
;; different moderation — a marketplace listing and a blog comment are not held to
|
||||
;; the same bar. This registry maps a domain to a rule set; mod/decide-in resolves
|
||||
;; the right policy and decides. Unregistered domains fall back to the default
|
||||
;; rules, so adding a domain never leaves it unmoderated.
|
||||
|
||||
(define mod/*policies* (list))
|
||||
|
||||
(define mod/policies-reset! (fn () (set! mod/*policies* (list))))
|
||||
|
||||
(define
|
||||
mod/register-policy!
|
||||
(fn (domain rules) (begin (append! mod/*policies* {:domain domain :rules rules}) true)))
|
||||
|
||||
(define
|
||||
mod/policy-registered?
|
||||
(fn
|
||||
(domain)
|
||||
(mod/any? (fn (p) (= (get p :domain) domain)) mod/*policies*)))
|
||||
|
||||
(define
|
||||
mod/policy-for
|
||||
(fn
|
||||
(domain)
|
||||
(reduce
|
||||
(fn (acc p) (if (= (get p :domain) domain) (get p :rules) acc))
|
||||
mod/default-rules
|
||||
mod/*policies*)))
|
||||
|
||||
(define
|
||||
mod/decide-in
|
||||
(fn
|
||||
(domain r reports)
|
||||
(mod/decide-report r reports (mod/policy-for domain))))
|
||||
|
||||
(define
|
||||
mod/registered-domains
|
||||
(fn () (map (fn (p) (get p :domain)) mod/*policies*)))
|
||||
137
lib/mod/policy.sx
Normal file
137
lib/mod/policy.sx
Normal file
@@ -0,0 +1,137 @@
|
||||
;; 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. 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
|
||||
;; goal-by-goal re-querying in the engine).
|
||||
;;
|
||||
;; Precedence (top wins): exoneration evidence (appeal override) > confirmed-abuse
|
||||
;; evidence (human review) > spam/abuse classification > repeated-report count >
|
||||
;; default keep.
|
||||
|
||||
(define mod/mk-rule (fn (name action conds) {:when conds :name name :action action}))
|
||||
|
||||
(define mod/rule-name (fn (r) (get r :name)))
|
||||
(define mod/rule-action (fn (r) (get r :action)))
|
||||
(define mod/rule-when (fn (r) (get r :when)))
|
||||
|
||||
(define
|
||||
mod/default-rules
|
||||
(list
|
||||
(mod/mk-rule
|
||||
"exonerated-keep"
|
||||
:keep (list (list :evidence "exonerated")))
|
||||
(mod/mk-rule
|
||||
"reviewer-remove"
|
||||
:remove (list (list :evidence "confirmed-abuse")))
|
||||
(mod/mk-rule "spam-hide" :hide (list (list :classification "spam")))
|
||||
(mod/mk-rule
|
||||
"abuse-remove"
|
||||
:remove (list (list :classification "abuse")))
|
||||
(mod/mk-rule
|
||||
"repeated-escalate"
|
||||
:escalate (list (list :count-at-least 3)))
|
||||
(mod/mk-rule "default-keep" :keep (list))))
|
||||
|
||||
;; ── condition → Prolog goal ──
|
||||
;;
|
||||
;; (:classification "spam") → classification(Id, spam)
|
||||
;; (:evidence "kind") → evidence(Id, 'kind', _)
|
||||
;; (:attr "verified") → attr(Id, verified)
|
||||
;; (:not <cond>) → not(<cond>) (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 (quorum engine)
|
||||
;; (:burst-at-least 3) → report(Id, _, Sb), burst_count(Sb, Nb), Nb >= 3
|
||||
;; (temporal engine)
|
||||
|
||||
(define
|
||||
mod/cond->goal
|
||||
(fn
|
||||
(c idterm)
|
||||
(let
|
||||
((tag (first c)))
|
||||
(cond
|
||||
((= tag :classification)
|
||||
(str "classification(" idterm ", " (nth c 1) ")"))
|
||||
((= tag :evidence)
|
||||
(str
|
||||
"evidence("
|
||||
idterm
|
||||
", "
|
||||
(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 :any)
|
||||
(str
|
||||
"("
|
||||
(mod/join-with
|
||||
" ; "
|
||||
(map
|
||||
(fn (sub) (mod/cond->goal sub idterm))
|
||||
(nth c 1)))
|
||||
")"))
|
||||
((= tag :count-at-least)
|
||||
(str
|
||||
"report("
|
||||
idterm
|
||||
", B, S), report_count(S, N), N >= "
|
||||
(nth c 1)))
|
||||
((= tag :score-at-least)
|
||||
(str
|
||||
"aggregate_all(sum(W), signal("
|
||||
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)))
|
||||
((= tag :burst-at-least)
|
||||
(str
|
||||
"report("
|
||||
idterm
|
||||
", _, Sb), burst_count(Sb, Nb), Nb >= "
|
||||
(nth c 1)))
|
||||
(true "true")))))
|
||||
|
||||
(define
|
||||
mod/conds->body
|
||||
(fn
|
||||
(conds idterm)
|
||||
(if
|
||||
(empty? conds)
|
||||
"true"
|
||||
(mod/join-with ", " (map (fn (c) (mod/cond->goal c idterm)) conds)))))
|
||||
|
||||
(define
|
||||
mod/rule->clause
|
||||
(fn
|
||||
(r)
|
||||
(str
|
||||
"policy_action(Id, "
|
||||
(mod/rule-action r)
|
||||
", '"
|
||||
(mod/rule-name r)
|
||||
"') :- "
|
||||
(mod/conds->body (mod/rule-when r) "Id")
|
||||
".")))
|
||||
|
||||
(define
|
||||
mod/rules->program
|
||||
(fn (rules) (mod/join-with "\n" (map mod/rule->clause rules))))
|
||||
40
lib/mod/quorum.sx
Normal file
40
lib/mod/quorum.sx
Normal 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"}))))))))
|
||||
259
lib/mod/schema.sx
Normal file
259
lib/mod/schema.sx
Normal file
@@ -0,0 +1,259 @@
|
||||
;; lib/mod/schema.sx — report representation + Prolog fact generation.
|
||||
;;
|
||||
;; A report is a dict {:id :by :about :reason :evidence :attrs :signals :at}.
|
||||
;; :evidence — accumulated {:kind :val} entries (human review, scanners)
|
||||
;; :attrs — attribute names ("verified") for negation-as-failure conditions
|
||||
;; :signals — weighted {:kind :weight} entries for aggregate scoring rules
|
||||
;; :at — integer timestamp/tick (deterministic; supplied, not clock-read)
|
||||
;; The engine derives keyword classifications from the reason text and projects
|
||||
;; the report, its classifications, evidence, attributes, and signals into Prolog
|
||||
;; facts that policy clauses match against.
|
||||
|
||||
(define mod/mk-report (fn (id by about reason) {:attrs (list) :id id :signals (list) :by by :evidence (list) :about about :at 0 :reason reason}))
|
||||
|
||||
(define mod/report-id (fn (r) (get r :id)))
|
||||
(define mod/report-by (fn (r) (get r :by)))
|
||||
(define mod/report-about (fn (r) (get r :about)))
|
||||
(define mod/report-reason (fn (r) (get r :reason)))
|
||||
|
||||
(define
|
||||
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/report-signals
|
||||
(fn (r) (let ((s (get r :signals))) (if (nil? s) (list) s))))
|
||||
|
||||
(define
|
||||
mod/report-at
|
||||
(fn (r) (let ((t (get r :at))) (if (nil? t) 0 t))))
|
||||
|
||||
(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/mk-signal (fn (kind weight) {:kind kind :weight weight}))
|
||||
(define mod/signal-kind (fn (s) (get s :kind)))
|
||||
(define mod/signal-weight (fn (s) (get s :weight)))
|
||||
|
||||
(define mod/report* (fn (r evs attrs sigs at) {:attrs attrs :id (mod/report-id r) :signals sigs :by (mod/report-by r) :evidence evs :about (mod/report-about r) :at at :reason (mod/report-reason r)}))
|
||||
|
||||
(define
|
||||
mod/with-evidence
|
||||
(fn
|
||||
(r evs)
|
||||
(mod/report*
|
||||
r
|
||||
evs
|
||||
(mod/report-attrs r)
|
||||
(mod/report-signals r)
|
||||
(mod/report-at r))))
|
||||
|
||||
(define
|
||||
mod/with-attrs
|
||||
(fn
|
||||
(r attrs)
|
||||
(mod/report*
|
||||
r
|
||||
(mod/report-evidence r)
|
||||
attrs
|
||||
(mod/report-signals r)
|
||||
(mod/report-at r))))
|
||||
|
||||
(define
|
||||
mod/with-signals
|
||||
(fn
|
||||
(r sigs)
|
||||
(mod/report*
|
||||
r
|
||||
(mod/report-evidence r)
|
||||
(mod/report-attrs r)
|
||||
sigs
|
||||
(mod/report-at r))))
|
||||
|
||||
(define
|
||||
mod/with-at
|
||||
(fn
|
||||
(r at)
|
||||
(mod/report*
|
||||
r
|
||||
(mod/report-evidence r)
|
||||
(mod/report-attrs r)
|
||||
(mod/report-signals r)
|
||||
at)))
|
||||
|
||||
(define
|
||||
mod/attach-evidence
|
||||
(fn
|
||||
(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)))))
|
||||
|
||||
(define
|
||||
mod/attach-signal
|
||||
(fn (r s) (mod/with-signals r (append (mod/report-signals r) (list s)))))
|
||||
|
||||
;; ── substring search (the prolog-loaded env lacks includes?; slice/len do work) ──
|
||||
|
||||
(define
|
||||
mod/contains-at?
|
||||
(fn
|
||||
(hay needle hl nl pos)
|
||||
(if
|
||||
(< hl (+ pos nl))
|
||||
false
|
||||
(if
|
||||
(= (slice hay pos (+ pos nl)) needle)
|
||||
true
|
||||
(mod/contains-at? hay needle hl nl (+ pos 1))))))
|
||||
|
||||
(define
|
||||
mod/str-contains?
|
||||
(fn
|
||||
(hay needle)
|
||||
(let
|
||||
((hl (len hay)) (nl (len needle)))
|
||||
(if
|
||||
(= nl 0)
|
||||
true
|
||||
(mod/contains-at? hay needle hl nl 0)))))
|
||||
|
||||
;; ── evidence derivation (keyword classification) ──
|
||||
|
||||
(define
|
||||
mod/spam-keywords
|
||||
(list "spam" "buy now" "click here" "free money" "viagra" "limited offer"))
|
||||
|
||||
(define
|
||||
mod/abuse-keywords
|
||||
(list "abuse" "harassment" "threat" "slur" "hate speech"))
|
||||
|
||||
(define
|
||||
mod/any?
|
||||
(fn (pred coll) (reduce (fn (acc x) (if acc acc (pred x))) false coll)))
|
||||
|
||||
(define
|
||||
mod/reason-matches?
|
||||
(fn
|
||||
(reason kws)
|
||||
(let
|
||||
((low (downcase reason)))
|
||||
(mod/any? (fn (k) (mod/str-contains? low k)) kws))))
|
||||
|
||||
(define
|
||||
mod/classify-keywords
|
||||
(fn
|
||||
(r)
|
||||
(let
|
||||
((reason (mod/report-reason r)) (kinds (list)))
|
||||
(begin
|
||||
(when
|
||||
(mod/reason-matches? reason mod/spam-keywords)
|
||||
(append! kinds "spam"))
|
||||
(when
|
||||
(mod/reason-matches? reason mod/abuse-keywords)
|
||||
(append! kinds "abuse"))
|
||||
kinds))))
|
||||
|
||||
(define
|
||||
mod/report-count
|
||||
(fn
|
||||
(about reports)
|
||||
(reduce
|
||||
(fn
|
||||
(acc r)
|
||||
(if (= (mod/report-about r) about) (+ acc 1) acc))
|
||||
0
|
||||
reports)))
|
||||
|
||||
;; ── Prolog fact projection ──
|
||||
|
||||
(define
|
||||
mod/join-with
|
||||
(fn
|
||||
(sep items)
|
||||
(reduce (fn (acc x) (if (= acc "") x (str acc sep x))) "" items)))
|
||||
|
||||
(define mod/pl-quote (fn (s) (str "'" s "'")))
|
||||
|
||||
(define
|
||||
mod/classification-facts
|
||||
(fn
|
||||
(id kinds)
|
||||
(mod/join-with
|
||||
"\n"
|
||||
(map (fn (k) (str "classification(" id ", " k ").")) kinds))))
|
||||
|
||||
(define
|
||||
mod/evidence-facts
|
||||
(fn
|
||||
(id evs)
|
||||
(mod/join-with
|
||||
"\n"
|
||||
(map
|
||||
(fn
|
||||
(e)
|
||||
(str
|
||||
"evidence("
|
||||
id
|
||||
", "
|
||||
(mod/pl-quote (mod/evidence-kind e))
|
||||
", "
|
||||
(mod/pl-quote (str (mod/evidence-val e)))
|
||||
")."))
|
||||
evs))))
|
||||
|
||||
(define
|
||||
mod/attr-facts
|
||||
(fn
|
||||
(id attrs)
|
||||
(mod/join-with "\n" (map (fn (a) (str "attr(" id ", " a ").")) attrs))))
|
||||
|
||||
(define
|
||||
mod/signal-facts
|
||||
(fn
|
||||
(id sigs)
|
||||
(mod/join-with
|
||||
"\n"
|
||||
(map
|
||||
(fn
|
||||
(s)
|
||||
(str
|
||||
"signal("
|
||||
id
|
||||
", "
|
||||
(mod/pl-quote (mod/signal-kind s))
|
||||
", "
|
||||
(mod/signal-weight s)
|
||||
")."))
|
||||
sigs))))
|
||||
|
||||
(define
|
||||
mod/report-facts
|
||||
(fn
|
||||
(r count)
|
||||
(let
|
||||
((id (mod/report-id r))
|
||||
(by (mod/pl-quote (mod/report-by r)))
|
||||
(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)))
|
||||
(ats (mod/attr-facts id (mod/report-attrs r)))
|
||||
(sgs (mod/signal-facts id (mod/report-signals r))))
|
||||
(mod/join-with
|
||||
"\n"
|
||||
(list
|
||||
(str "report(" id ", " by ", " about ").")
|
||||
(str "report_count(" about ", " count ").")
|
||||
cls
|
||||
evs
|
||||
ats
|
||||
sgs))))))
|
||||
30
lib/mod/scoreboard.json
Normal file
30
lib/mod/scoreboard.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"lang": "mod",
|
||||
"total_passed": 390,
|
||||
"total_failed": 0,
|
||||
"total": 390,
|
||||
"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":"extensions","passed":32,"failed":0,"total":32},
|
||||
{"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":"quorum","passed":9,"failed":0,"total":9},
|
||||
{"name":"trace","passed":15,"failed":0,"total":15},
|
||||
{"name":"whatif","passed":13,"failed":0,"total":13},
|
||||
{"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":"disjunction","passed":10,"failed":0,"total":10},
|
||||
{"name":"activity","passed":17,"failed":0,"total":17},
|
||||
{"name":"policies","passed":14,"failed":0,"total":14},
|
||||
{"name":"defrule","passed":11,"failed":0,"total":11},
|
||||
{"name":"pipeline","passed":15,"failed":0,"total":15}
|
||||
],
|
||||
"generated": "2026-06-06T19:40:03+00:00"
|
||||
}
|
||||
27
lib/mod/scoreboard.md
Normal file
27
lib/mod/scoreboard.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# mod scoreboard
|
||||
|
||||
**390 / 390 passing** (0 failure(s)).
|
||||
|
||||
| Suite | Passed | Total | Status |
|
||||
|-------|--------|-------|--------|
|
||||
| decide | 31 | 31 | ok |
|
||||
| audit | 29 | 29 | ok |
|
||||
| escalation | 46 | 46 | ok |
|
||||
| fed | 26 | 26 | ok |
|
||||
| extensions | 32 | 32 | ok |
|
||||
| link | 12 | 12 | ok |
|
||||
| lint | 14 | 14 | ok |
|
||||
| severity | 14 | 14 | ok |
|
||||
| offenders | 19 | 19 | ok |
|
||||
| quorum | 9 | 9 | ok |
|
||||
| trace | 15 | 15 | ok |
|
||||
| whatif | 13 | 13 | ok |
|
||||
| batch | 17 | 17 | ok |
|
||||
| temporal | 15 | 15 | ok |
|
||||
| sla | 15 | 15 | ok |
|
||||
| wire | 16 | 16 | ok |
|
||||
| disjunction | 10 | 10 | ok |
|
||||
| activity | 17 | 17 | ok |
|
||||
| policies | 14 | 14 | ok |
|
||||
| defrule | 11 | 11 | ok |
|
||||
| pipeline | 15 | 15 | ok |
|
||||
60
lib/mod/severity.sx
Normal file
60
lib/mod/severity.sx
Normal file
@@ -0,0 +1,60 @@
|
||||
;; lib/mod/severity.sx — "strictest-wins" decision strategy.
|
||||
;;
|
||||
;; The default engine resolves precedence by rule ORDER (first proven clause wins,
|
||||
;; via pl-query-one). Some policies instead want the HARSHEST applicable sanction
|
||||
;; regardless of order. mod/decide-strictest collects every rule that proves
|
||||
;; (pl-query-all) and picks the highest-severity action. Same decision shape as
|
||||
;; the engine, plus :strategy. Built over the engine's helpers; engine untouched.
|
||||
|
||||
(define
|
||||
mod/action-severity
|
||||
(fn
|
||||
(action)
|
||||
(cond
|
||||
((= action "ban") 4)
|
||||
((= action "remove") 3)
|
||||
((= action "hide") 2)
|
||||
((= action "escalate") 1)
|
||||
(true 0))))
|
||||
|
||||
(define
|
||||
mod/strictest-sol
|
||||
(fn
|
||||
(sols)
|
||||
(reduce
|
||||
(fn
|
||||
(acc s)
|
||||
(if
|
||||
(nil? acc)
|
||||
s
|
||||
(if
|
||||
(<
|
||||
(mod/action-severity (dict-get acc "Action"))
|
||||
(mod/action-severity (dict-get s "Action")))
|
||||
s
|
||||
acc)))
|
||||
nil
|
||||
sols)))
|
||||
|
||||
(define
|
||||
mod/decide-strictest
|
||||
(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-program r count rules)))
|
||||
(let
|
||||
((db (pl-load program)))
|
||||
(let
|
||||
((sols (pl-query-all db (str "policy_action(" id ", Action, Rule)"))))
|
||||
(let
|
||||
((best (mod/strictest-sol sols)))
|
||||
(if
|
||||
(nil? best)
|
||||
{:action "keep" :proof {:goals (list) :evidence kinds :conditions (list) :rule "none" :count count} :report-id id :rule "none" :strategy "strictest"}
|
||||
(let
|
||||
((rule (mod/find-rule rules (dict-get best "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 "strictest"})))))))))
|
||||
47
lib/mod/sla.sx
Normal file
47
lib/mod/sla.sx
Normal file
@@ -0,0 +1,47 @@
|
||||
;; lib/mod/sla.sx — service-level sweep over pending lifecycle cases.
|
||||
;;
|
||||
;; Composes the Phase-3 lifecycle with the Ext-12 time dimension: a case left in a
|
||||
;; pending state (open / triaged / appealed) past a deadline has breached SLA and
|
||||
;; should resurface. A timed-case pairs a case with the tick it entered its
|
||||
;; current state (the caller stamps this — the lifecycle stays timeless and pure).
|
||||
;; Terminal states (decided / final) never breach.
|
||||
|
||||
(define mod/pending-states (list "open" "triaged" "appealed"))
|
||||
(define mod/pending-state? (fn (s) (mod/member? s mod/pending-states)))
|
||||
|
||||
(define mod/mk-timed-case (fn (c entered-at) {:entered-at entered-at :case c}))
|
||||
(define mod/tc-case (fn (tc) (get tc :case)))
|
||||
(define mod/tc-entered-at (fn (tc) (get tc :entered-at)))
|
||||
|
||||
(define
|
||||
mod/overdue?
|
||||
(fn
|
||||
(tc now deadline)
|
||||
(if
|
||||
(mod/pending-state? (mod/case-state (mod/tc-case tc)))
|
||||
(< deadline (- now (mod/tc-entered-at tc)))
|
||||
false)))
|
||||
|
||||
(define
|
||||
mod/sla-sweep
|
||||
(fn
|
||||
(timed-cases now deadline)
|
||||
(reduce
|
||||
(fn
|
||||
(acc tc)
|
||||
(if
|
||||
(mod/overdue? tc now deadline)
|
||||
(append
|
||||
acc
|
||||
(list (mod/report-id (mod/case-report (mod/tc-case tc)))))
|
||||
acc))
|
||||
(list)
|
||||
timed-cases)))
|
||||
|
||||
(define
|
||||
mod/overdue-count
|
||||
(fn
|
||||
(timed-cases now deadline)
|
||||
(len (mod/sla-sweep timed-cases now deadline))))
|
||||
|
||||
(define mod/age (fn (tc now) (- now (mod/tc-entered-at tc))))
|
||||
62
lib/mod/temporal.sx
Normal file
62
lib/mod/temporal.sx
Normal file
@@ -0,0 +1,62 @@
|
||||
;; lib/mod/temporal.sx — burst detection over a time window.
|
||||
;;
|
||||
;; A plain report count can't tell a burst (N reports in minutes) from slow
|
||||
;; accumulation (N reports over months). mod/decide-temporal takes a `now` tick
|
||||
;; and a `window`, counts reports about the subject with :at within [now-window,
|
||||
;; now], asserts it as burst_count/2, and lets a `(:burst-at-least K)` rule fire
|
||||
;; only on a genuine burst. Time is supplied (deterministic), never clock-read.
|
||||
|
||||
(define
|
||||
mod/window-count
|
||||
(fn
|
||||
(subject reports now window)
|
||||
(reduce
|
||||
(fn
|
||||
(acc r)
|
||||
(if
|
||||
(if
|
||||
(= (mod/report-about r) subject)
|
||||
(<= (- now window) (mod/report-at r))
|
||||
false)
|
||||
(+ acc 1)
|
||||
acc))
|
||||
0
|
||||
reports)))
|
||||
|
||||
(define
|
||||
mod/build-temporal-program
|
||||
(fn
|
||||
(r count bcount rules)
|
||||
(str
|
||||
(mod/report-facts r count)
|
||||
"\n"
|
||||
"burst_count("
|
||||
(mod/pl-quote (mod/report-about r))
|
||||
", "
|
||||
bcount
|
||||
").\n"
|
||||
(mod/rules->program rules))))
|
||||
|
||||
(define
|
||||
mod/decide-temporal
|
||||
(fn
|
||||
(r reports rules now window)
|
||||
(let
|
||||
((about (mod/report-about r))
|
||||
(id (mod/report-id r))
|
||||
(kinds (mod/classify-keywords r)))
|
||||
(let
|
||||
((count (mod/report-count about reports))
|
||||
(bcount (mod/window-count about reports now window)))
|
||||
(let
|
||||
((program (mod/build-temporal-program r count bcount rules)))
|
||||
(let
|
||||
((db (pl-load program)))
|
||||
(let
|
||||
((sol (pl-query-one db (str "policy_action(" id ", Action, Rule)"))))
|
||||
(if
|
||||
(nil? sol)
|
||||
{:action "keep" :proof {:burst bcount :goals (list) :evidence kinds :conditions (list) :rule "none" :count count} :report-id id :rule "none" :strategy "temporal"}
|
||||
(let
|
||||
((rule (mod/find-rule rules (dict-get sol "Rule"))))
|
||||
{:action (mod/rule-action rule) :proof {:burst bcount :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 "temporal"})))))))))
|
||||
95
lib/mod/tests/activity.sx
Normal file
95
lib/mod/tests/activity.sx
Normal file
@@ -0,0 +1,95 @@
|
||||
;; lib/mod/tests/activity.sx — Ext 16: ActivityPub-shaped decision export.
|
||||
|
||||
(define mod-ap-count 0)
|
||||
(define mod-ap-pass 0)
|
||||
(define mod-ap-fail 0)
|
||||
(define mod-ap-failures (list))
|
||||
|
||||
(define
|
||||
mod-ap-test!
|
||||
(fn
|
||||
(name got expected)
|
||||
(begin
|
||||
(set! mod-ap-count (+ mod-ap-count 1))
|
||||
(if
|
||||
(= got expected)
|
||||
(set! mod-ap-pass (+ mod-ap-pass 1))
|
||||
(begin
|
||||
(set! mod-ap-fail (+ mod-ap-fail 1))
|
||||
(append!
|
||||
mod-ap-failures
|
||||
(str name "\n expected: " expected "\n got: " got)))))))
|
||||
|
||||
;; ── action → AP verb ──
|
||||
|
||||
(mod-ap-test! "remove → Delete" (mod/action->verb "remove") "Delete")
|
||||
(mod-ap-test! "ban → Block" (mod/action->verb "ban") "Block")
|
||||
(mod-ap-test! "hide → Flag" (mod/action->verb "hide") "Flag")
|
||||
(mod-ap-test! "escalate → Flag" (mod/action->verb "escalate") "Flag")
|
||||
(mod-ap-test! "keep → nil (no activity)" (mod/action->verb "keep") nil)
|
||||
|
||||
;; ── single decision → activity ──
|
||||
|
||||
(define mod-ap-spam (mod/mk-report "r1" "a" "bob" "this is spam"))
|
||||
(define
|
||||
mod-ap-dec
|
||||
(mod/decide-report mod-ap-spam (list mod-ap-spam) mod/default-rules))
|
||||
(define mod-ap-act (mod/decision->activity mod-ap-dec "instance.example"))
|
||||
|
||||
(mod-ap-test! "activity type is Flag (hide)" (get mod-ap-act :type) "Flag")
|
||||
(mod-ap-test! "activity object is report id" (get mod-ap-act :object) "r1")
|
||||
(mod-ap-test!
|
||||
"activity actor preserved"
|
||||
(get mod-ap-act :actor)
|
||||
"instance.example")
|
||||
(mod-ap-test!
|
||||
"activity preserves precise action"
|
||||
(get mod-ap-act :action)
|
||||
"hide")
|
||||
(mod-ap-test! "activity carries rule" (get mod-ap-act :rule) "spam-hide")
|
||||
(mod-ap-test!
|
||||
"activity summary"
|
||||
(get mod-ap-act :summary)
|
||||
"moderation/hide via spam-hide")
|
||||
|
||||
;; ── keep produces no activity ──
|
||||
|
||||
(define mod-ap-clean (mod/mk-report "r2" "a" "b" "a fine post"))
|
||||
(define
|
||||
mod-ap-keep
|
||||
(mod/decide-report mod-ap-clean (list mod-ap-clean) mod/default-rules))
|
||||
(mod-ap-test!
|
||||
"keep decision → nil activity"
|
||||
(mod/decision->activity mod-ap-keep "x")
|
||||
nil)
|
||||
|
||||
;; ── abuse → Delete ──
|
||||
|
||||
(define mod-ap-abuse (mod/mk-report "r3" "a" "b" "harassment here"))
|
||||
(define
|
||||
mod-ap-abuse-dec
|
||||
(mod/decide-report mod-ap-abuse (list mod-ap-abuse) mod/default-rules))
|
||||
(mod-ap-test!
|
||||
"abuse decision → Delete activity"
|
||||
(get (mod/decision->activity mod-ap-abuse-dec "x") :type)
|
||||
"Delete")
|
||||
|
||||
;; ── batch export drops keeps ──
|
||||
|
||||
(define mod-ap-decisions (list mod-ap-dec mod-ap-keep mod-ap-abuse-dec))
|
||||
(define mod-ap-acts (mod/decisions->activities mod-ap-decisions "inst"))
|
||||
(mod-ap-test! "batch export drops the keep" (len mod-ap-acts) 2)
|
||||
(mod-ap-test!
|
||||
"batch export first is the Flag"
|
||||
(get (first mod-ap-acts) :type)
|
||||
"Flag")
|
||||
(mod-ap-test!
|
||||
"batch export second is the Delete"
|
||||
(get (nth mod-ap-acts 1) :type)
|
||||
"Delete")
|
||||
(mod-ap-test!
|
||||
"empty decisions → no activities"
|
||||
(mod/decisions->activities (list) "inst")
|
||||
(list))
|
||||
|
||||
(define mod-activity-tests-run! (fn () {:failures mod-ap-failures :total mod-ap-count :passed mod-ap-pass :failed mod-ap-fail}))
|
||||
187
lib/mod/tests/audit.sx
Normal file
187
lib/mod/tests/audit.sx
Normal file
@@ -0,0 +1,187 @@
|
||||
;; lib/mod/tests/audit.sx — Phase 2: evidence accumulation + proof tree + audit.
|
||||
|
||||
(define mod-aud-count 0)
|
||||
(define mod-aud-pass 0)
|
||||
(define mod-aud-fail 0)
|
||||
(define mod-aud-failures (list))
|
||||
|
||||
(define
|
||||
mod-aud-test!
|
||||
(fn
|
||||
(name got expected)
|
||||
(begin
|
||||
(set! mod-aud-count (+ mod-aud-count 1))
|
||||
(if
|
||||
(= got expected)
|
||||
(set! mod-aud-pass (+ mod-aud-pass 1))
|
||||
(begin
|
||||
(set! mod-aud-fail (+ mod-aud-fail 1))
|
||||
(append!
|
||||
mod-aud-failures
|
||||
(str name "\n expected: " expected "\n got: " got)))))))
|
||||
|
||||
(define
|
||||
mod-aud-decide1
|
||||
(fn (r) (mod/decide-report r (list r) mod/default-rules)))
|
||||
|
||||
;; ── proof tree: keyword classification ──
|
||||
|
||||
(define
|
||||
mod-aud-spam
|
||||
(mod-aud-decide1 (mod/mk-report "r1" "alice" "bob" "this is spam")))
|
||||
(define mod-aud-spam-goals (get (get mod-aud-spam :proof) :goals))
|
||||
|
||||
(mod-aud-test! "spam proof has one goal" (len mod-aud-spam-goals) 1)
|
||||
(mod-aud-test!
|
||||
"spam proof goal text"
|
||||
(get (first mod-aud-spam-goals) :goal)
|
||||
"classification(r1, spam)")
|
||||
(mod-aud-test!
|
||||
"spam proof goal solved"
|
||||
(get (first mod-aud-spam-goals) :solved)
|
||||
true)
|
||||
|
||||
;; ── proof tree: count rule with real bindings ──
|
||||
|
||||
(define mod-aud-rep-r (mod/mk-report "r3" "ann" "dave" "x"))
|
||||
(define
|
||||
mod-aud-rep
|
||||
(mod/decide-report
|
||||
mod-aud-rep-r
|
||||
(list mod-aud-rep-r mod-aud-rep-r mod-aud-rep-r)
|
||||
mod/default-rules))
|
||||
(define mod-aud-rep-goals (get (get mod-aud-rep :proof) :goals))
|
||||
(define mod-aud-rep-binds (get (first mod-aud-rep-goals) :bindings))
|
||||
|
||||
(mod-aud-test!
|
||||
"count proof goal solved"
|
||||
(get (first mod-aud-rep-goals) :solved)
|
||||
true)
|
||||
(mod-aud-test! "count proof binding N" (dict-get mod-aud-rep-binds "N") "3")
|
||||
(mod-aud-test!
|
||||
"count proof binding S (subject)"
|
||||
(dict-get mod-aud-rep-binds "S")
|
||||
"dave")
|
||||
|
||||
;; ── proof tree: default keep has a 'true' goal ──
|
||||
|
||||
(define
|
||||
mod-aud-keep
|
||||
(mod-aud-decide1 (mod/mk-report "rk" "a" "b" "a fine post")))
|
||||
(define mod-aud-keep-goals (get (get mod-aud-keep :proof) :goals))
|
||||
|
||||
(mod-aud-test!
|
||||
"keep proof goal text true"
|
||||
(get (first mod-aud-keep-goals) :goal)
|
||||
"true")
|
||||
(mod-aud-test!
|
||||
"keep proof goal solved"
|
||||
(get (first mod-aud-keep-goals) :solved)
|
||||
true)
|
||||
|
||||
;; ── evidence accumulation drives a rule ──
|
||||
|
||||
(define
|
||||
mod-aud-rev-r
|
||||
(mod/attach-evidence
|
||||
(mod/mk-report "re" "a" "carol" "neutral")
|
||||
(mod/mk-evidence "confirmed-abuse" "human")))
|
||||
(define mod-aud-rev (mod-aud-decide1 mod-aud-rev-r))
|
||||
|
||||
(mod-aud-test!
|
||||
"evidence has length 1"
|
||||
(len (mod/report-evidence mod-aud-rev-r))
|
||||
1)
|
||||
(mod-aud-test!
|
||||
"evidence reviewer-remove → remove"
|
||||
(get mod-aud-rev :action)
|
||||
"remove")
|
||||
(mod-aud-test!
|
||||
"evidence reviewer-remove rule"
|
||||
(get mod-aud-rev :rule)
|
||||
"reviewer-remove")
|
||||
(mod-aud-test!
|
||||
"evidence proof goal solved"
|
||||
(get (first (get (get mod-aud-rev :proof) :goals)) :solved)
|
||||
true)
|
||||
(mod-aud-test!
|
||||
"no evidence → not reviewer-remove"
|
||||
(get (mod-aud-decide1 (mod/mk-report "rn" "a" "b" "neutral")) :rule)
|
||||
"default-keep")
|
||||
|
||||
;; ── append-only audit log via the api ──
|
||||
|
||||
(mod/reset!)
|
||||
(mod/report "alice" "bob" "this is spam")
|
||||
(mod/report "carol" "eve" "fine post")
|
||||
(define mod-aud-d1 (mod/decide "r1"))
|
||||
(define mod-aud-d2 (mod/decide "r2"))
|
||||
|
||||
(mod-aud-test! "two decisions logged" (mod/audit-count) 2)
|
||||
(mod-aud-test!
|
||||
"first entry seq 1"
|
||||
(get (first (mod/audit-all)) :seq)
|
||||
1)
|
||||
(mod-aud-test!
|
||||
"audit r1 returns one entry"
|
||||
(len (mod/audit "r1"))
|
||||
1)
|
||||
(mod-aud-test!
|
||||
"audit r1 action matches decision"
|
||||
(get (first (mod/audit "r1")) :action)
|
||||
(get mod-aud-d1 :action))
|
||||
(mod-aud-test!
|
||||
"audit r1 rule matches decision"
|
||||
(get (first (mod/audit "r1")) :rule)
|
||||
"spam-hide")
|
||||
(mod-aud-test!
|
||||
"audit r1 entry carries proof goals"
|
||||
(len (get (get (first (mod/audit "r1")) :proof) :goals))
|
||||
1)
|
||||
(mod-aud-test!
|
||||
"audit r2 keep"
|
||||
(get (first (mod/audit "r2")) :action)
|
||||
"keep")
|
||||
(mod-aud-test! "audit unknown report → empty" (mod/audit "r99") (list))
|
||||
|
||||
;; ── append-only: re-deciding appends, never mutates ──
|
||||
|
||||
(define mod-aud-d1b (mod/decide "r1"))
|
||||
|
||||
(mod-aud-test! "re-decide appends (count 3)" (mod/audit-count) 3)
|
||||
(mod-aud-test!
|
||||
"audit r1 now has 2 entries"
|
||||
(len (mod/audit "r1"))
|
||||
2)
|
||||
(mod-aud-test!
|
||||
"audit r1 seqs monotonic"
|
||||
(get (nth (mod/audit "r1") 1) :seq)
|
||||
3)
|
||||
(mod-aud-test!
|
||||
"audit-latest r1 is seq 3"
|
||||
(get (mod/audit-latest "r1") :seq)
|
||||
3)
|
||||
(mod-aud-test!
|
||||
"first r1 entry unchanged (still seq 1)"
|
||||
(get (first (mod/audit "r1")) :seq)
|
||||
1)
|
||||
|
||||
;; ── evidence snapshot captured at decision time ──
|
||||
|
||||
(mod/add-evidence "r2" "confirmed-abuse" "human")
|
||||
(define mod-aud-d2b (mod/decide "r2"))
|
||||
|
||||
(mod-aud-test!
|
||||
"post-evidence decision flips to remove"
|
||||
(get mod-aud-d2b :action)
|
||||
"remove")
|
||||
(mod-aud-test!
|
||||
"audit snapshot records evidence kind"
|
||||
(mod/evidence-kind (first (get (mod/audit-latest "r2") :evidence)))
|
||||
"confirmed-abuse")
|
||||
(mod-aud-test!
|
||||
"earlier r2 entry had empty evidence snapshot"
|
||||
(len (get (first (mod/audit "r2")) :evidence))
|
||||
0)
|
||||
|
||||
(define mod-audit-tests-run! (fn () {:failures mod-aud-failures :total mod-aud-count :passed mod-aud-pass :failed mod-aud-fail}))
|
||||
101
lib/mod/tests/batch.sx
Normal file
101
lib/mod/tests/batch.sx
Normal file
@@ -0,0 +1,101 @@
|
||||
;; lib/mod/tests/batch.sx — Ext 11: batch triage + corpus analytics.
|
||||
|
||||
(define mod-b-count 0)
|
||||
(define mod-b-pass 0)
|
||||
(define mod-b-fail 0)
|
||||
(define mod-b-failures (list))
|
||||
|
||||
(define
|
||||
mod-b-test!
|
||||
(fn
|
||||
(name got expected)
|
||||
(begin
|
||||
(set! mod-b-count (+ mod-b-count 1))
|
||||
(if
|
||||
(= got expected)
|
||||
(set! mod-b-pass (+ mod-b-pass 1))
|
||||
(begin
|
||||
(set! mod-b-fail (+ mod-b-fail 1))
|
||||
(append!
|
||||
mod-b-failures
|
||||
(str name "\n expected: " expected "\n got: " got)))))))
|
||||
|
||||
;; corpus: 2 spam, 1 abuse, 2 clean — distinct subjects so the count rule stays quiet
|
||||
(define
|
||||
mod-b-corpus
|
||||
(list
|
||||
(mod/mk-report "r1" "u" "s1" "this is spam")
|
||||
(mod/mk-report "r2" "u" "s2" "buy now offer")
|
||||
(mod/mk-report "r3" "u" "s3" "harassment here")
|
||||
(mod/mk-report "r4" "u" "s4" "a fine post")
|
||||
(mod/mk-report "r5" "u" "s5" "thanks for sharing")))
|
||||
|
||||
(define mod-b-decisions (mod/decide-batch mod-b-corpus mod/default-rules))
|
||||
|
||||
;; ── decide-batch ──
|
||||
|
||||
(mod-b-test! "one decision per report" (len mod-b-decisions) 5)
|
||||
(mod-b-test!
|
||||
"first decision is hide"
|
||||
(get (first mod-b-decisions) :action)
|
||||
"hide")
|
||||
|
||||
;; ── action histogram ──
|
||||
|
||||
(define mod-b-hist (mod/action-histogram mod-b-decisions))
|
||||
(mod-b-test! "histogram hide count" (get mod-b-hist :hide) 2)
|
||||
(mod-b-test! "histogram remove count" (get mod-b-hist :remove) 1)
|
||||
(mod-b-test! "histogram keep count" (get mod-b-hist :keep) 2)
|
||||
(mod-b-test! "histogram escalate count" (get mod-b-hist :escalate) 0)
|
||||
(mod-b-test! "histogram ban count" (get mod-b-hist :ban) 0)
|
||||
(mod-b-test!
|
||||
"histogram totals match corpus"
|
||||
(+
|
||||
(+ (get mod-b-hist :hide) (get mod-b-hist :remove))
|
||||
(+
|
||||
(get mod-b-hist :keep)
|
||||
(+ (get mod-b-hist :escalate) (get mod-b-hist :ban))))
|
||||
5)
|
||||
|
||||
;; ── rule coverage (empirical) ──
|
||||
|
||||
(define mod-b-cov (mod/rule-coverage mod-b-corpus mod/default-rules))
|
||||
(mod-b-test! "coverage has one row per rule" (len mod-b-cov) 6)
|
||||
(mod-b-test!
|
||||
"spam-hide fired twice"
|
||||
(mod/rule-fire-count mod-b-decisions "spam-hide")
|
||||
2)
|
||||
(mod-b-test!
|
||||
"abuse-remove fired once"
|
||||
(mod/rule-fire-count mod-b-decisions "abuse-remove")
|
||||
1)
|
||||
(mod-b-test!
|
||||
"default-keep fired twice"
|
||||
(mod/rule-fire-count mod-b-decisions "default-keep")
|
||||
2)
|
||||
|
||||
;; ── never-fired: rules not exercised by this corpus ──
|
||||
|
||||
(define mod-b-never (mod/never-fired mod-b-corpus mod/default-rules))
|
||||
(mod-b-test!
|
||||
"exonerated-keep never fired"
|
||||
(mod/member? "exonerated-keep" mod-b-never)
|
||||
true)
|
||||
(mod-b-test!
|
||||
"reviewer-remove never fired"
|
||||
(mod/member? "reviewer-remove" mod-b-never)
|
||||
true)
|
||||
(mod-b-test!
|
||||
"repeated-escalate never fired"
|
||||
(mod/member? "repeated-escalate" mod-b-never)
|
||||
true)
|
||||
(mod-b-test!
|
||||
"spam-hide DID fire (not in never-fired)"
|
||||
(mod/member? "spam-hide" mod-b-never)
|
||||
false)
|
||||
(mod-b-test!
|
||||
"three rules never fired on this corpus"
|
||||
(len mod-b-never)
|
||||
3)
|
||||
|
||||
(define mod-batch-tests-run! (fn () {:failures mod-b-failures :total mod-b-count :passed mod-b-pass :failed mod-b-fail}))
|
||||
215
lib/mod/tests/decide.sx
Normal file
215
lib/mod/tests/decide.sx
Normal file
@@ -0,0 +1,215 @@
|
||||
;; lib/mod/tests/decide.sx — Phase 1: report representation + simple policy.
|
||||
|
||||
(define mod-dec-count 0)
|
||||
(define mod-dec-pass 0)
|
||||
(define mod-dec-fail 0)
|
||||
(define mod-dec-failures (list))
|
||||
|
||||
(define
|
||||
mod-dec-test!
|
||||
(fn
|
||||
(name got expected)
|
||||
(begin
|
||||
(set! mod-dec-count (+ mod-dec-count 1))
|
||||
(if
|
||||
(= got expected)
|
||||
(set! mod-dec-pass (+ mod-dec-pass 1))
|
||||
(begin
|
||||
(set! mod-dec-fail (+ mod-dec-fail 1))
|
||||
(append!
|
||||
mod-dec-failures
|
||||
(str name "\n expected: " expected "\n got: " got)))))))
|
||||
|
||||
;; decide a single report (count over a 1-element registry)
|
||||
(define
|
||||
mod-dec-one
|
||||
(fn
|
||||
(reason)
|
||||
(let
|
||||
((r (mod/mk-report "r1" "alice" "bob" reason)))
|
||||
(mod/decide-report r (list r) mod/default-rules))))
|
||||
|
||||
(define mod-dec-action (fn (reason) (get (mod-dec-one reason) :action)))
|
||||
|
||||
;; ── spam keyword → :hide ──
|
||||
|
||||
(mod-dec-test!
|
||||
"spam keyword 'spam' → hide"
|
||||
(mod-dec-action "this is spam")
|
||||
"hide")
|
||||
(mod-dec-test!
|
||||
"spam keyword 'buy now' → hide"
|
||||
(mod-dec-action "buy now while stocks last")
|
||||
"hide")
|
||||
(mod-dec-test!
|
||||
"spam keyword case-insensitive 'CLICK HERE' → hide"
|
||||
(mod-dec-action "CLICK HERE now")
|
||||
"hide")
|
||||
(mod-dec-test!
|
||||
"spam keyword 'free money' → hide"
|
||||
(mod-dec-action "win free money fast")
|
||||
"hide")
|
||||
|
||||
;; ── abuse keyword → :remove ──
|
||||
|
||||
(mod-dec-test!
|
||||
"abuse keyword 'harassment' → remove"
|
||||
(mod-dec-action "ongoing harassment of users")
|
||||
"remove")
|
||||
(mod-dec-test!
|
||||
"abuse keyword 'threat' → remove"
|
||||
(mod-dec-action "this is a threat")
|
||||
"remove")
|
||||
(mod-dec-test!
|
||||
"abuse keyword 'slur' → remove"
|
||||
(mod-dec-action "contains a slur")
|
||||
"remove")
|
||||
|
||||
;; ── no rule → :keep ──
|
||||
|
||||
(mod-dec-test!
|
||||
"neutral reason → keep"
|
||||
(mod-dec-action "I disagree with this post")
|
||||
"keep")
|
||||
(mod-dec-test! "empty reason → keep" (mod-dec-action "") "keep")
|
||||
|
||||
;; ── decision carries the matching rule (proof, not bare keyword) ──
|
||||
|
||||
(mod-dec-test!
|
||||
"spam decision rule name"
|
||||
(get (mod-dec-one "this is spam") :rule)
|
||||
"spam-hide")
|
||||
(mod-dec-test!
|
||||
"keep decision rule name"
|
||||
(get (mod-dec-one "fine post") :rule)
|
||||
"default-keep")
|
||||
(mod-dec-test!
|
||||
"abuse decision rule name"
|
||||
(get (mod-dec-one "harassment here") :rule)
|
||||
"abuse-remove")
|
||||
(mod-dec-test!
|
||||
"spam proof :rule"
|
||||
(get (get (mod-dec-one "spam!") :proof) :rule)
|
||||
"spam-hide")
|
||||
(mod-dec-test!
|
||||
"spam proof :evidence"
|
||||
(get (get (mod-dec-one "spam!") :proof) :evidence)
|
||||
(list "spam"))
|
||||
(mod-dec-test!
|
||||
"spam proof :count"
|
||||
(get (get (mod-dec-one "spam!") :proof) :count)
|
||||
1)
|
||||
|
||||
;; ── classification (evidence derivation) ──
|
||||
|
||||
(mod-dec-test!
|
||||
"classify spam"
|
||||
(mod/classify-keywords (mod/mk-report "r1" "a" "b" "spam!"))
|
||||
(list "spam"))
|
||||
(mod-dec-test!
|
||||
"classify abuse"
|
||||
(mod/classify-keywords (mod/mk-report "r1" "a" "b" "abuse"))
|
||||
(list "abuse"))
|
||||
(mod-dec-test!
|
||||
"classify neutral → empty"
|
||||
(mod/classify-keywords (mod/mk-report "r1" "a" "b" "hello"))
|
||||
(list))
|
||||
(mod-dec-test!
|
||||
"classify both spam+abuse"
|
||||
(mod/classify-keywords (mod/mk-report "r1" "a" "b" "spam and abuse"))
|
||||
(list "spam" "abuse"))
|
||||
|
||||
;; ── report-count + repeated → :escalate ──
|
||||
|
||||
(define
|
||||
mod-dec-three
|
||||
(list
|
||||
(mod/mk-report "r1" "a" "bob" "x")
|
||||
(mod/mk-report "r2" "c" "bob" "y")
|
||||
(mod/mk-report "r3" "d" "bob" "z")))
|
||||
|
||||
(mod-dec-test!
|
||||
"report-count counts subject"
|
||||
(mod/report-count "bob" mod-dec-three)
|
||||
3)
|
||||
(mod-dec-test!
|
||||
"3 reports about subject → escalate"
|
||||
(get
|
||||
(mod/decide-report (first mod-dec-three) mod-dec-three mod/default-rules)
|
||||
:action)
|
||||
"escalate")
|
||||
(mod-dec-test!
|
||||
"escalate rule name"
|
||||
(get
|
||||
(mod/decide-report (first mod-dec-three) mod-dec-three mod/default-rules)
|
||||
:rule)
|
||||
"repeated-escalate")
|
||||
|
||||
(define
|
||||
mod-dec-two
|
||||
(list
|
||||
(mod/mk-report "r1" "a" "carol" "x")
|
||||
(mod/mk-report "r2" "c" "carol" "y")))
|
||||
|
||||
(mod-dec-test!
|
||||
"2 reports about subject → keep (below threshold)"
|
||||
(get
|
||||
(mod/decide-report (first mod-dec-two) mod-dec-two mod/default-rules)
|
||||
:action)
|
||||
"keep")
|
||||
|
||||
;; ── precedence: spam beats repeated ──
|
||||
|
||||
(define
|
||||
mod-dec-spam-among-many
|
||||
(list
|
||||
(mod/mk-report "r1" "a" "dave" "buy now spam")
|
||||
(mod/mk-report "r2" "c" "dave" "y")
|
||||
(mod/mk-report "r3" "d" "dave" "z")))
|
||||
|
||||
(mod-dec-test!
|
||||
"spam wins over repeated (precedence)"
|
||||
(get
|
||||
(mod/decide-report
|
||||
(first mod-dec-spam-among-many)
|
||||
mod-dec-spam-among-many
|
||||
mod/default-rules)
|
||||
:action)
|
||||
"hide")
|
||||
|
||||
;; ── accessors ──
|
||||
|
||||
(mod-dec-test!
|
||||
"report-about accessor"
|
||||
(mod/report-about (mod/mk-report "r1" "a" "bob" "x"))
|
||||
"bob")
|
||||
(mod-dec-test!
|
||||
"report-by accessor"
|
||||
(mod/report-by (mod/mk-report "r1" "alice" "bob" "x"))
|
||||
"alice")
|
||||
|
||||
;; ── api registry ──
|
||||
|
||||
(mod/reset!)
|
||||
(define mod-dec-r1 (mod/report "alice" "bob" "this is spam"))
|
||||
(define mod-dec-r2 (mod/report "carol" "eve" "fine post"))
|
||||
|
||||
(mod-dec-test!
|
||||
"mod/report assigns sequential id r1"
|
||||
(mod/report-id mod-dec-r1)
|
||||
"r1")
|
||||
(mod-dec-test!
|
||||
"mod/report assigns sequential id r2"
|
||||
(mod/report-id mod-dec-r2)
|
||||
"r2")
|
||||
(mod-dec-test!
|
||||
"mod/decide via registry → hide"
|
||||
(get (mod/decide "r1") :action)
|
||||
"hide")
|
||||
(mod-dec-test!
|
||||
"mod/decide via registry → keep"
|
||||
(get (mod/decide "r2") :action)
|
||||
"keep")
|
||||
(mod-dec-test! "mod/decide unknown id → nil" (mod/decide "r99") nil)
|
||||
|
||||
(define mod-decide-tests-run! (fn () {:failures mod-dec-failures :total mod-dec-count :passed mod-dec-pass :failed mod-dec-fail}))
|
||||
95
lib/mod/tests/defrule.sx
Normal file
95
lib/mod/tests/defrule.sx
Normal file
@@ -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}))
|
||||
145
lib/mod/tests/disjunction.sx
Normal file
145
lib/mod/tests/disjunction.sx
Normal file
@@ -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}))
|
||||
279
lib/mod/tests/escalation.sx
Normal file
279
lib/mod/tests/escalation.sx
Normal file
@@ -0,0 +1,279 @@
|
||||
;; lib/mod/tests/escalation.sx — Phase 3: lifecycle state machine + escalation.
|
||||
|
||||
(define mod-esc-count 0)
|
||||
(define mod-esc-pass 0)
|
||||
(define mod-esc-fail 0)
|
||||
(define mod-esc-failures (list))
|
||||
|
||||
(define
|
||||
mod-esc-test!
|
||||
(fn
|
||||
(name got expected)
|
||||
(begin
|
||||
(set! mod-esc-count (+ mod-esc-count 1))
|
||||
(if
|
||||
(= got expected)
|
||||
(set! mod-esc-pass (+ mod-esc-pass 1))
|
||||
(begin
|
||||
(set! mod-esc-fail (+ mod-esc-fail 1))
|
||||
(append!
|
||||
mod-esc-failures
|
||||
(str name "\n expected: " expected "\n got: " got)))))))
|
||||
|
||||
;; ── transition table guard ──
|
||||
|
||||
(mod-esc-test!
|
||||
"open → triaged allowed"
|
||||
(mod/lc-can-transition? "open" "triaged")
|
||||
true)
|
||||
(mod-esc-test!
|
||||
"triaged → decided allowed"
|
||||
(mod/lc-can-transition? "triaged" "decided")
|
||||
true)
|
||||
(mod-esc-test!
|
||||
"decided → appealed allowed"
|
||||
(mod/lc-can-transition? "decided" "appealed")
|
||||
true)
|
||||
(mod-esc-test!
|
||||
"appealed → final allowed"
|
||||
(mod/lc-can-transition? "appealed" "final")
|
||||
true)
|
||||
(mod-esc-test!
|
||||
"open → decided rejected"
|
||||
(mod/lc-can-transition? "open" "decided")
|
||||
false)
|
||||
(mod-esc-test!
|
||||
"triaged → final rejected"
|
||||
(mod/lc-can-transition? "triaged" "final")
|
||||
false)
|
||||
(mod-esc-test!
|
||||
"final is terminal"
|
||||
(mod/lc-can-transition? "final" "open")
|
||||
false)
|
||||
|
||||
;; ── initial state ──
|
||||
|
||||
(define
|
||||
mod-esc-c0
|
||||
(mod/mk-case (mod/mk-report "r1" "alice" "bob" "this is spam")))
|
||||
(mod-esc-test! "new case is open" (mod/case-state mod-esc-c0) "open")
|
||||
(mod-esc-test! "new case has no decision" (mod/case-decision mod-esc-c0) nil)
|
||||
|
||||
;; ── auto-tier: spam triages + resolves to decided/hide ──
|
||||
|
||||
(define
|
||||
mod-esc-spam-rep
|
||||
(list (mod/mk-report "r1" "alice" "bob" "this is spam")))
|
||||
(define
|
||||
mod-esc-t1
|
||||
(mod/case-triage mod-esc-c0 mod-esc-spam-rep mod/default-rules))
|
||||
(mod-esc-test! "spam triaged" (mod/case-state mod-esc-t1) "triaged")
|
||||
(mod-esc-test! "spam triage tier auto" (mod/case-tier mod-esc-t1) "auto")
|
||||
(mod-esc-test! "spam triage action hide" (mod/case-action mod-esc-t1) "hide")
|
||||
|
||||
(define mod-esc-r1 (mod/case-resolve mod-esc-t1))
|
||||
(mod-esc-test!
|
||||
"auto resolve → decided"
|
||||
(mod/case-state mod-esc-r1)
|
||||
"decided")
|
||||
(mod-esc-test!
|
||||
"decision preserved through resolve"
|
||||
(mod/case-action mod-esc-r1)
|
||||
"hide")
|
||||
|
||||
;; ── illegal transition flags :error, leaves state ──
|
||||
|
||||
(define mod-esc-bad (mod/case-finalize mod-esc-c0))
|
||||
(mod-esc-test!
|
||||
"finalize from open is illegal"
|
||||
(mod/case-state mod-esc-bad)
|
||||
"open")
|
||||
(mod-esc-test!
|
||||
"illegal transition sets error"
|
||||
(nil? (mod/case-error mod-esc-bad))
|
||||
false)
|
||||
|
||||
;; ── human-tier: repeated report escalates, resolve blocked, review decides ──
|
||||
|
||||
(define mod-esc-rep-r (mod/mk-report "r3" "ann" "dave" "off-topic"))
|
||||
(define mod-esc-rep-reports (list mod-esc-rep-r mod-esc-rep-r mod-esc-rep-r))
|
||||
(define mod-esc-rep-c0 (mod/mk-case mod-esc-rep-r))
|
||||
(define
|
||||
mod-esc-rep-t
|
||||
(mod/case-triage mod-esc-rep-c0 mod-esc-rep-reports mod/default-rules))
|
||||
|
||||
(mod-esc-test!
|
||||
"repeated triage action escalate"
|
||||
(mod/case-action mod-esc-rep-t)
|
||||
"escalate")
|
||||
(mod-esc-test!
|
||||
"repeated triage tier human"
|
||||
(mod/case-tier mod-esc-rep-t)
|
||||
"human")
|
||||
(mod-esc-test!
|
||||
"repeated still triaged after triage"
|
||||
(mod/case-state mod-esc-rep-t)
|
||||
"triaged")
|
||||
|
||||
(define mod-esc-rep-block (mod/case-resolve mod-esc-rep-t))
|
||||
(mod-esc-test!
|
||||
"auto-resolve blocked on human tier (state unchanged)"
|
||||
(mod/case-state mod-esc-rep-block)
|
||||
"triaged")
|
||||
(mod-esc-test!
|
||||
"blocked resolve sets error"
|
||||
(nil? (mod/case-error mod-esc-rep-block))
|
||||
false)
|
||||
|
||||
(define
|
||||
mod-esc-rep-rev
|
||||
(mod/case-review
|
||||
mod-esc-rep-t
|
||||
"confirmed-abuse"
|
||||
"human"
|
||||
mod-esc-rep-reports
|
||||
mod/default-rules))
|
||||
(mod-esc-test!
|
||||
"human review → decided"
|
||||
(mod/case-state mod-esc-rep-rev)
|
||||
"decided")
|
||||
(mod-esc-test!
|
||||
"human review action remove"
|
||||
(mod/case-action mod-esc-rep-rev)
|
||||
"remove")
|
||||
(mod-esc-test!
|
||||
"review attached evidence to report"
|
||||
(len (mod/report-evidence (mod/case-report mod-esc-rep-rev)))
|
||||
1)
|
||||
|
||||
(define mod-esc-rep-final (mod/case-finalize mod-esc-rep-rev))
|
||||
(mod-esc-test!
|
||||
"review case finalizes"
|
||||
(mod/case-state mod-esc-rep-final)
|
||||
"final")
|
||||
|
||||
;; ── appeal overrides a prior decision ──
|
||||
|
||||
(define
|
||||
mod-esc-ap-c0
|
||||
(mod/mk-case (mod/mk-report "r5" "u" "v" "buy now spam")))
|
||||
(define mod-esc-ap-rep (list (mod/mk-report "r5" "u" "v" "buy now spam")))
|
||||
(define
|
||||
mod-esc-ap-t
|
||||
(mod/case-triage mod-esc-ap-c0 mod-esc-ap-rep mod/default-rules))
|
||||
(define mod-esc-ap-d (mod/case-resolve mod-esc-ap-t))
|
||||
|
||||
(mod-esc-test!
|
||||
"appeal precondition decided/hide"
|
||||
(mod/case-action mod-esc-ap-d)
|
||||
"hide")
|
||||
|
||||
(define
|
||||
mod-esc-ap-appealed
|
||||
(mod/case-appeal
|
||||
mod-esc-ap-d
|
||||
"exonerated"
|
||||
"moderator"
|
||||
mod-esc-ap-rep
|
||||
mod/default-rules))
|
||||
(mod-esc-test!
|
||||
"appeal → appealed state"
|
||||
(mod/case-state mod-esc-ap-appealed)
|
||||
"appealed")
|
||||
(mod-esc-test!
|
||||
"appeal overrides hide → keep"
|
||||
(mod/case-action mod-esc-ap-appealed)
|
||||
"keep")
|
||||
(mod-esc-test!
|
||||
"appeal recorded via exonerated-keep rule"
|
||||
(get (mod/case-decision mod-esc-ap-appealed) :rule)
|
||||
"exonerated-keep")
|
||||
|
||||
(define mod-esc-ap-final (mod/case-finalize mod-esc-ap-appealed))
|
||||
(mod-esc-test! "appealed → final" (mod/case-state mod-esc-ap-final) "final")
|
||||
|
||||
;; ── history records the full traversal ──
|
||||
|
||||
(mod-esc-test!
|
||||
"full lifecycle history length 4 (triage,resolve,appeal,finalize)"
|
||||
(len (mod/case-history mod-esc-ap-final))
|
||||
4)
|
||||
(mod-esc-test!
|
||||
"first history step open→triaged"
|
||||
(get (first (mod/case-history mod-esc-ap-final)) :to)
|
||||
"triaged")
|
||||
(mod-esc-test!
|
||||
"last history step → final"
|
||||
(get (nth (mod/case-history mod-esc-ap-final) 3) :to)
|
||||
"final")
|
||||
|
||||
;; ── api-level lifecycle façade ──
|
||||
|
||||
(mod/reset!)
|
||||
(mod/report "alice" "bob" "this is spam")
|
||||
(mod/report "carol" "dave" "off-topic")
|
||||
(mod/report "carol" "dave" "off-topic")
|
||||
(mod/report "carol" "dave" "off-topic")
|
||||
|
||||
(mod-esc-test!
|
||||
"api: case opens at open"
|
||||
(mod/case-state (mod/case-of "r1"))
|
||||
"open")
|
||||
|
||||
(define mod-esc-api-t1 (mod/triage "r1"))
|
||||
(mod-esc-test!
|
||||
"api: triage spam → triaged"
|
||||
(mod/case-state mod-esc-api-t1)
|
||||
"triaged")
|
||||
(mod-esc-test!
|
||||
"api: triage spam action hide"
|
||||
(mod/case-action mod-esc-api-t1)
|
||||
"hide")
|
||||
|
||||
(define mod-esc-api-r1 (mod/resolve "r1"))
|
||||
(mod-esc-test!
|
||||
"api: resolve → decided"
|
||||
(mod/case-state mod-esc-api-r1)
|
||||
"decided")
|
||||
(mod-esc-test!
|
||||
"api: resolve logged decision"
|
||||
(len (mod/audit "r1"))
|
||||
1)
|
||||
|
||||
(define mod-esc-api-app (mod/appeal "r1" "exonerated" "mod"))
|
||||
(mod-esc-test!
|
||||
"api: appeal → appealed"
|
||||
(mod/case-state mod-esc-api-app)
|
||||
"appealed")
|
||||
(mod-esc-test!
|
||||
"api: appeal overrides → keep"
|
||||
(mod/case-action mod-esc-api-app)
|
||||
"keep")
|
||||
(mod-esc-test!
|
||||
"api: appeal logged second decision"
|
||||
(len (mod/audit "r1"))
|
||||
2)
|
||||
(mod-esc-test!
|
||||
"api: finalize → final"
|
||||
(mod/case-state (mod/finalize "r1"))
|
||||
"final")
|
||||
|
||||
;; r4 is the 3rd report about dave → escalates via the human tier
|
||||
(define mod-esc-api-t4 (mod/triage "r4"))
|
||||
(mod-esc-test!
|
||||
"api: repeated triage escalates (human tier)"
|
||||
(mod/case-tier mod-esc-api-t4)
|
||||
"human")
|
||||
(define mod-esc-api-blk (mod/resolve "r4"))
|
||||
(mod-esc-test!
|
||||
"api: escalated resolve blocked"
|
||||
(mod/case-state mod-esc-api-blk)
|
||||
"triaged")
|
||||
(define mod-esc-api-rev (mod/review "r4" "confirmed-abuse" "human"))
|
||||
(mod-esc-test!
|
||||
"api: review → decided/remove"
|
||||
(mod/case-action mod-esc-api-rev)
|
||||
"remove")
|
||||
(mod-esc-test! "api: unknown id → nil" (mod/triage "r99") nil)
|
||||
|
||||
(define mod-escalation-tests-run! (fn () {:failures mod-esc-failures :total mod-esc-count :passed mod-esc-pass :failed mod-esc-fail}))
|
||||
313
lib/mod/tests/extensions.sx
Normal file
313
lib/mod/tests/extensions.sx
Normal file
@@ -0,0 +1,313 @@
|
||||
;; lib/mod/tests/extensions.sx — beyond-roadmap extensions.
|
||||
;;
|
||||
;; Ext 1: negation-as-failure conditions (:not / :attr) + report attributes.
|
||||
;; "hide spam UNLESS the author is verified" (closed-world reasoning).
|
||||
;; Ext 2: weighted/aggregate evidence scoring (:score-at-least) + report signals.
|
||||
;; Many low-confidence signals accumulate past a threshold via Prolog
|
||||
;; aggregate_all(sum(W), ...).
|
||||
;; Ext 3: human-readable proof explanation (mod/explain) over the proof tree.
|
||||
;; Demonstrated with custom rule sets so the default policy (and its 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)))))))
|
||||
|
||||
;; ── Ext 1: 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)
|
||||
|
||||
;; ── Ext 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")
|
||||
|
||||
;; ── Ext 1: 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)
|
||||
|
||||
;; ── Ext 1: 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))")
|
||||
|
||||
;; ── Ext 1: 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")
|
||||
|
||||
;; ── Ext 2: weighted signals + aggregate scoring ──
|
||||
|
||||
(define mod-ext-s0 (mod/mk-report "s1" "a" "b" "neutral"))
|
||||
(mod-ext-test!
|
||||
"fresh report has no signals"
|
||||
(len (mod/report-signals mod-ext-s0))
|
||||
0)
|
||||
(define
|
||||
mod-ext-s1
|
||||
(mod/attach-signal mod-ext-s0 (mod/mk-signal "link" 2)))
|
||||
(mod-ext-test!
|
||||
"attach-signal adds one"
|
||||
(len (mod/report-signals mod-ext-s1))
|
||||
1)
|
||||
(mod-ext-test!
|
||||
"attach-signal preserves attrs"
|
||||
(len
|
||||
(mod/report-attrs
|
||||
(mod/attach-signal mod-ext-rv (mod/mk-signal "x" 1))))
|
||||
1)
|
||||
|
||||
(define
|
||||
mod-ext-score-rules
|
||||
(list
|
||||
(mod/mk-rule
|
||||
"high-score-hide"
|
||||
:hide (list (list :score-at-least 5)))
|
||||
(mod/mk-rule "default-keep" :keep (list))))
|
||||
|
||||
;; one weak signal (2) — below threshold
|
||||
(define
|
||||
mod-ext-weak
|
||||
(mod/attach-signal
|
||||
(mod/mk-report "w1" "a" "b" "neutral")
|
||||
(mod/mk-signal "link" 2)))
|
||||
(mod-ext-test!
|
||||
"single weak signal → keep (below threshold)"
|
||||
(get
|
||||
(mod/decide-report mod-ext-weak (list mod-ext-weak) mod-ext-score-rules)
|
||||
:action)
|
||||
"keep")
|
||||
|
||||
;; three signals summing to 6 — over threshold
|
||||
(define
|
||||
mod-ext-strong0
|
||||
(mod/attach-signal
|
||||
(mod/mk-report "w2" "a" "b" "neutral")
|
||||
(mod/mk-signal "link" 2)))
|
||||
(define
|
||||
mod-ext-strong1
|
||||
(mod/attach-signal mod-ext-strong0 (mod/mk-signal "newaccount" 2)))
|
||||
(define
|
||||
mod-ext-strong
|
||||
(mod/attach-signal mod-ext-strong1 (mod/mk-signal "burst" 2)))
|
||||
(mod-ext-test!
|
||||
"accumulated signals (2+2+2=6) → hide"
|
||||
(get
|
||||
(mod/decide-report
|
||||
mod-ext-strong
|
||||
(list mod-ext-strong)
|
||||
mod-ext-score-rules)
|
||||
:action)
|
||||
"hide")
|
||||
(mod-ext-test!
|
||||
"scoring rule named in decision"
|
||||
(get
|
||||
(mod/decide-report
|
||||
mod-ext-strong
|
||||
(list mod-ext-strong)
|
||||
mod-ext-score-rules)
|
||||
:rule)
|
||||
"high-score-hide")
|
||||
|
||||
;; exactly at threshold (5) fires
|
||||
(define
|
||||
mod-ext-exact0
|
||||
(mod/attach-signal
|
||||
(mod/mk-report "w3" "a" "b" "neutral")
|
||||
(mod/mk-signal "link" 3)))
|
||||
(define
|
||||
mod-ext-exact
|
||||
(mod/attach-signal mod-ext-exact0 (mod/mk-signal "burst" 2)))
|
||||
(mod-ext-test!
|
||||
"exactly at threshold (5) → hide"
|
||||
(get
|
||||
(mod/decide-report mod-ext-exact (list mod-ext-exact) mod-ext-score-rules)
|
||||
:action)
|
||||
"hide")
|
||||
|
||||
(mod-ext-test!
|
||||
"cond->goal :score-at-least"
|
||||
(mod/cond->goal (list :score-at-least 5) "Id")
|
||||
"aggregate_all(sum(W), signal(Id, _, W), T), T >= 5")
|
||||
|
||||
;; ── Ext 3: human-readable proof explanation ──
|
||||
|
||||
(define mod-ext-spam-explain (mod/explain mod-ext-dec))
|
||||
|
||||
(mod-ext-test!
|
||||
"explain mentions the report id"
|
||||
(mod/str-contains? mod-ext-spam-explain "Report p1")
|
||||
true)
|
||||
(mod-ext-test!
|
||||
"explain mentions the action"
|
||||
(mod/str-contains? mod-ext-spam-explain "hide")
|
||||
true)
|
||||
(mod-ext-test!
|
||||
"explain mentions the rule"
|
||||
(mod/str-contains? mod-ext-spam-explain "spam-unverified-hide")
|
||||
true)
|
||||
(mod-ext-test!
|
||||
"explain marks proved goals"
|
||||
(mod/str-contains? mod-ext-spam-explain "[proved]")
|
||||
true)
|
||||
(mod-ext-test!
|
||||
"explain renders the evidence line"
|
||||
(mod/str-contains? mod-ext-spam-explain "Evidence: spam")
|
||||
true)
|
||||
|
||||
;; count-rule explanation shows the unification bindings
|
||||
(define mod-ext-rep-r (mod/mk-report "rc" "ann" "dave" "off-topic"))
|
||||
(define
|
||||
mod-ext-rep-d
|
||||
(mod/decide-report
|
||||
mod-ext-rep-r
|
||||
(list mod-ext-rep-r mod-ext-rep-r mod-ext-rep-r)
|
||||
mod/default-rules))
|
||||
(define mod-ext-rep-explain (mod/explain mod-ext-rep-d))
|
||||
(mod-ext-test!
|
||||
"explain shows binding N=3"
|
||||
(mod/str-contains? mod-ext-rep-explain "N=3")
|
||||
true)
|
||||
(mod-ext-test!
|
||||
"explain shows subject binding"
|
||||
(mod/str-contains? mod-ext-rep-explain "dave")
|
||||
true)
|
||||
|
||||
;; explain-goal direct: unproved goal gets [unproved]
|
||||
(mod-ext-test!
|
||||
"explain-goal marks unproved"
|
||||
(mod/str-contains? (mod/explain-goal {:solved false :goal "attr(x, foo)" :bindings {}}) "[unproved]")
|
||||
true)
|
||||
;; explain-binds renders key=value pairs
|
||||
(mod-ext-test!
|
||||
"explain-binds renders pair"
|
||||
(mod/explain-binds {:N "3"})
|
||||
"N=3")
|
||||
;; no-evidence decision says (none)
|
||||
(define
|
||||
mod-ext-keep-d
|
||||
(mod/decide-report mod-ext-clean (list mod-ext-clean) mod-ext-rules))
|
||||
(mod-ext-test!
|
||||
"explain (none) for empty evidence"
|
||||
(mod/str-contains? (mod/explain mod-ext-keep-d) "Evidence: (none)")
|
||||
true)
|
||||
|
||||
(define mod-extensions-tests-run! (fn () {:failures mod-ext-failures :total mod-ext-count :passed mod-ext-pass :failed mod-ext-fail}))
|
||||
154
lib/mod/tests/fed.sx
Normal file
154
lib/mod/tests/fed.sx
Normal file
@@ -0,0 +1,154 @@
|
||||
;; lib/mod/tests/fed.sx — Phase 4: federation (mock fed-sx).
|
||||
|
||||
(define mod-fed-count 0)
|
||||
(define mod-fed-pass 0)
|
||||
(define mod-fed-fail 0)
|
||||
(define mod-fed-failures (list))
|
||||
|
||||
(define
|
||||
mod-fed-test!
|
||||
(fn
|
||||
(name got expected)
|
||||
(begin
|
||||
(set! mod-fed-count (+ mod-fed-count 1))
|
||||
(if
|
||||
(= got expected)
|
||||
(set! mod-fed-pass (+ mod-fed-pass 1))
|
||||
(begin
|
||||
(set! mod-fed-fail (+ mod-fed-fail 1))
|
||||
(append!
|
||||
mod-fed-failures
|
||||
(str name "\n expected: " expected "\n got: " got)))))))
|
||||
|
||||
(mod/reset!)
|
||||
(mod/fed-reset!)
|
||||
|
||||
;; ── trust model (advisory by default) ──
|
||||
|
||||
(mod-fed-test! "trust initially false" (mod/trusted? "peerA" :mod) false)
|
||||
(mod/grant-trust "peerA" :mod)
|
||||
(mod-fed-test! "trust after grant" (mod/trusted? "peerA" :mod) true)
|
||||
(mod-fed-test! "trust wrong scope" (mod/trusted? "peerA" :other) false)
|
||||
(mod-fed-test! "trust other peer" (mod/trusted? "peerB" :mod) false)
|
||||
(mod/revoke-trust "peerA" :mod)
|
||||
(mod-fed-test! "trust after revoke" (mod/trusted? "peerA" :mod) false)
|
||||
|
||||
;; ── cross-instance reports ──
|
||||
|
||||
(define
|
||||
mod-fed-fr
|
||||
(mod/fed-receive-report "peerB" "alice" "bob" "this is spam"))
|
||||
(mod-fed-test! "fed report assigned id r1" (mod/report-id mod-fed-fr) "r1")
|
||||
(mod-fed-test! "fed report origin is peer" (mod/report-origin "r1") "peerB")
|
||||
(define mod-fed-local (mod/report "carol" "dave" "fine post"))
|
||||
(mod-fed-test!
|
||||
"local report origin is local"
|
||||
(mod/report-origin (mod/report-id mod-fed-local))
|
||||
"local")
|
||||
(mod-fed-test!
|
||||
"engine decides fed report (spam → hide)"
|
||||
(get
|
||||
(mod/decide-report mod-fed-fr (list mod-fed-fr) mod/default-rules)
|
||||
:action)
|
||||
"hide")
|
||||
|
||||
;; ── decision sharing (outbox) ──
|
||||
|
||||
(define mod-fed-dec {:action "hide" :rule "spam-hide" :report-id "r1"})
|
||||
(define
|
||||
mod-fed-shared
|
||||
(mod/fed-share-decision mod-fed-dec (list "peerB" "peerC")))
|
||||
(mod-fed-test! "share returns notified peers" (len mod-fed-shared) 2)
|
||||
(mod-fed-test! "outbox has two messages" (len (mod/fed-outbox)) 2)
|
||||
(mod-fed-test!
|
||||
"outbox message type decision"
|
||||
(get (first (mod/fed-outbox)) :type)
|
||||
"decision")
|
||||
(mod-fed-test!
|
||||
"outbox message addressed to peer"
|
||||
(get (first (mod/fed-outbox)) :to)
|
||||
"peerB")
|
||||
|
||||
;; ── receiving a peer decision: advisory unless trusted ──
|
||||
|
||||
(define mod-fed-untrusted (mod/fed-receive-decision "peerZ" {:action "remove" :rule "reviewer-remove" :report-id "rx"}))
|
||||
(mod-fed-test!
|
||||
"untrusted decision not applied"
|
||||
(get mod-fed-untrusted :applied)
|
||||
false)
|
||||
(mod-fed-test!
|
||||
"untrusted decision advisory"
|
||||
(get mod-fed-untrusted :advisory)
|
||||
true)
|
||||
(mod-fed-test!
|
||||
"untrusted decision absent from applied log"
|
||||
(mod/fed-applied-action "rx")
|
||||
nil)
|
||||
(mod-fed-test!
|
||||
"advisory log records suggestion"
|
||||
(len mod/*fed-advisory*)
|
||||
1)
|
||||
|
||||
(mod/grant-trust "peerT" :mod)
|
||||
(define mod-fed-trusted (mod/fed-receive-decision "peerT" {:action "hide" :rule "spam-hide" :report-id "ry"}))
|
||||
(mod-fed-test! "trusted decision applied" (get mod-fed-trusted :applied) true)
|
||||
(mod-fed-test!
|
||||
"trusted decision binds locally"
|
||||
(get (mod/fed-applied-action "ry") :action)
|
||||
"hide")
|
||||
|
||||
;; ── revocation ──
|
||||
|
||||
(mod-fed-test!
|
||||
"applied action not yet revoked"
|
||||
(get (mod/fed-applied-action "ry") :revoked)
|
||||
false)
|
||||
(mod/fed-revoke! "ry" "manual")
|
||||
(mod-fed-test!
|
||||
"revoke marks applied action revoked"
|
||||
(get (mod/fed-applied-action "ry") :revoked)
|
||||
true)
|
||||
(mod-fed-test!
|
||||
"revoke emits a revocation message"
|
||||
(mod/any? (fn (m) (= (get m :type) "revocation")) (mod/fed-outbox))
|
||||
true)
|
||||
|
||||
;; revoke-if-invalidated: proof still holds → no revocation
|
||||
(define mod-fed-spam-r (mod/mk-report "rs" "a" "b" "this is spam"))
|
||||
(define
|
||||
mod-fed-spam-d
|
||||
(mod/decide-report mod-fed-spam-r (list mod-fed-spam-r) mod/default-rules))
|
||||
(mod-fed-test! "spam decision is hide" (get mod-fed-spam-d :action) "hide")
|
||||
(define
|
||||
mod-fed-rev-same
|
||||
(mod/fed-revoke-if-invalidated
|
||||
mod-fed-spam-r
|
||||
mod-fed-spam-d
|
||||
(list mod-fed-spam-r)
|
||||
mod/default-rules))
|
||||
(mod-fed-test!
|
||||
"valid proof → not revoked"
|
||||
(get mod-fed-rev-same :revoked)
|
||||
false)
|
||||
|
||||
;; exoneration invalidates the proof → revocation
|
||||
(define
|
||||
mod-fed-exon-r
|
||||
(mod/attach-evidence mod-fed-spam-r (mod/mk-evidence "exonerated" "mod")))
|
||||
(define
|
||||
mod-fed-rev-inv
|
||||
(mod/fed-revoke-if-invalidated
|
||||
mod-fed-exon-r
|
||||
mod-fed-spam-d
|
||||
(list mod-fed-exon-r)
|
||||
mod/default-rules))
|
||||
(mod-fed-test!
|
||||
"invalidated proof → revoked"
|
||||
(get mod-fed-rev-inv :revoked)
|
||||
true)
|
||||
(mod-fed-test!
|
||||
"re-decision after exoneration is keep"
|
||||
(get (get mod-fed-rev-inv :decision) :action)
|
||||
"keep")
|
||||
|
||||
(define mod-fed-tests-run! (fn () {:failures mod-fed-failures :total mod-fed-count :passed mod-fed-pass :failed mod-fed-fail}))
|
||||
86
lib/mod/tests/link.sx
Normal file
86
lib/mod/tests/link.sx
Normal file
@@ -0,0 +1,86 @@
|
||||
;; lib/mod/tests/link.sx — Ext 4: report linking + dedup.
|
||||
|
||||
(define mod-lnk-count 0)
|
||||
(define mod-lnk-pass 0)
|
||||
(define mod-lnk-fail 0)
|
||||
(define mod-lnk-failures (list))
|
||||
|
||||
(define
|
||||
mod-lnk-test!
|
||||
(fn
|
||||
(name got expected)
|
||||
(begin
|
||||
(set! mod-lnk-count (+ mod-lnk-count 1))
|
||||
(if
|
||||
(= got expected)
|
||||
(set! mod-lnk-pass (+ mod-lnk-pass 1))
|
||||
(begin
|
||||
(set! mod-lnk-fail (+ mod-lnk-fail 1))
|
||||
(append!
|
||||
mod-lnk-failures
|
||||
(str name "\n expected: " expected "\n got: " got)))))))
|
||||
|
||||
;; ── link-key + dedup ──
|
||||
|
||||
(define mod-lnk-a (mod/mk-report "r1" "alice" "bob" "this is spam"))
|
||||
(define mod-lnk-a2 (mod/mk-report "r2" "alice" "bob" "THIS IS SPAM"))
|
||||
(define mod-lnk-b (mod/mk-report "r3" "carol" "bob" "abuse"))
|
||||
(define mod-lnk-c (mod/mk-report "r4" "alice" "eve" "this is spam"))
|
||||
|
||||
(mod-lnk-test!
|
||||
"identical reports share a link key (case-insensitive reason)"
|
||||
(= (mod/link-key mod-lnk-a) (mod/link-key mod-lnk-a2))
|
||||
true)
|
||||
(mod-lnk-test!
|
||||
"different reporter → different key"
|
||||
(= (mod/link-key mod-lnk-a) (mod/link-key mod-lnk-b))
|
||||
false)
|
||||
(mod-lnk-test!
|
||||
"different subject → different key"
|
||||
(= (mod/link-key mod-lnk-a) (mod/link-key mod-lnk-c))
|
||||
false)
|
||||
|
||||
(define mod-lnk-set (list mod-lnk-a mod-lnk-a2 mod-lnk-b mod-lnk-c))
|
||||
(mod-lnk-test!
|
||||
"dedup collapses identical reports"
|
||||
(len (mod/dedup-reports mod-lnk-set))
|
||||
3)
|
||||
(mod-lnk-test!
|
||||
"duplicate-count counts collapsed"
|
||||
(mod/duplicate-count mod-lnk-set)
|
||||
1)
|
||||
(mod-lnk-test!
|
||||
"dedup of all-distinct keeps all"
|
||||
(len (mod/dedup-reports (list mod-lnk-a mod-lnk-b mod-lnk-c)))
|
||||
3)
|
||||
|
||||
;; ── Prolog-backed relational linking ──
|
||||
|
||||
(mod-lnk-test!
|
||||
"related-ids finds all reports about subject"
|
||||
(len (mod/related-ids "bob" mod-lnk-set))
|
||||
3)
|
||||
(mod-lnk-test!
|
||||
"related-ids returns the ids"
|
||||
(mod/related-ids "eve" mod-lnk-set)
|
||||
(list "r4"))
|
||||
(mod-lnk-test!
|
||||
"related-ids empty for unknown subject"
|
||||
(mod/related-ids "nobody" mod-lnk-set)
|
||||
(list))
|
||||
|
||||
;; reporters: bob reported by alice (x2) + carol → 3 raw, 2 distinct
|
||||
(mod-lnk-test!
|
||||
"reporters-of counts all reports"
|
||||
(len (mod/reporters-of "bob" mod-lnk-set))
|
||||
3)
|
||||
(mod-lnk-test!
|
||||
"distinct reporters-of dedups reporters"
|
||||
(len (mod/distinct-reporters-of "bob" mod-lnk-set))
|
||||
2)
|
||||
(mod-lnk-test!
|
||||
"distinct utility removes dups"
|
||||
(mod/distinct (list "a" "b" "a" "c" "b"))
|
||||
(list "a" "b" "c"))
|
||||
|
||||
(define mod-link-tests-run! (fn () {:failures mod-lnk-failures :total mod-lnk-count :passed mod-lnk-pass :failed mod-lnk-fail}))
|
||||
122
lib/mod/tests/lint.sx
Normal file
122
lib/mod/tests/lint.sx
Normal file
@@ -0,0 +1,122 @@
|
||||
;; lib/mod/tests/lint.sx — Ext 5: policy rule-set static analysis.
|
||||
|
||||
(define mod-lint-count 0)
|
||||
(define mod-lint-pass 0)
|
||||
(define mod-lint-fail 0)
|
||||
(define mod-lint-failures (list))
|
||||
|
||||
(define
|
||||
mod-lint-test!
|
||||
(fn
|
||||
(name got expected)
|
||||
(begin
|
||||
(set! mod-lint-count (+ mod-lint-count 1))
|
||||
(if
|
||||
(= got expected)
|
||||
(set! mod-lint-pass (+ mod-lint-pass 1))
|
||||
(begin
|
||||
(set! mod-lint-fail (+ mod-lint-fail 1))
|
||||
(append!
|
||||
mod-lint-failures
|
||||
(str name "\n expected: " expected "\n got: " got)))))))
|
||||
|
||||
;; ── the default rule set is well-formed ──
|
||||
|
||||
(mod-lint-test!
|
||||
"default rules: no unreachable"
|
||||
(mod/unreachable-rules mod/default-rules)
|
||||
(list))
|
||||
(mod-lint-test!
|
||||
"default rules: has catch-all"
|
||||
(mod/has-catchall? mod/default-rules)
|
||||
true)
|
||||
(mod-lint-test!
|
||||
"default rules: no duplicate names"
|
||||
(mod/duplicate-rule-names mod/default-rules)
|
||||
(list))
|
||||
(mod-lint-test!
|
||||
"default rules: well-formed"
|
||||
(mod/rules-ok? mod/default-rules)
|
||||
true)
|
||||
|
||||
;; ── unreachable detection ──
|
||||
|
||||
(define
|
||||
mod-lint-shadowed
|
||||
(list
|
||||
(mod/mk-rule "spam-hide" :hide (list (list :classification "spam")))
|
||||
(mod/mk-rule "catch-all" :keep (list))
|
||||
(mod/mk-rule
|
||||
"abuse-remove"
|
||||
:remove (list (list :classification "abuse")))
|
||||
(mod/mk-rule
|
||||
"repeated"
|
||||
:escalate (list (list :count-at-least 3)))))
|
||||
|
||||
(mod-lint-test!
|
||||
"rules after catch-all are unreachable"
|
||||
(mod/unreachable-rules mod-lint-shadowed)
|
||||
(list "abuse-remove" "repeated"))
|
||||
(mod-lint-test!
|
||||
"shadowed rule set is not ok"
|
||||
(mod/rules-ok? mod-lint-shadowed)
|
||||
false)
|
||||
|
||||
;; ── missing catch-all ──
|
||||
|
||||
(define
|
||||
mod-lint-nocatch
|
||||
(list
|
||||
(mod/mk-rule "spam-hide" :hide (list (list :classification "spam")))
|
||||
(mod/mk-rule
|
||||
"abuse-remove"
|
||||
:remove (list (list :classification "abuse")))))
|
||||
|
||||
(mod-lint-test!
|
||||
"no catch-all detected"
|
||||
(mod/has-catchall? mod-lint-nocatch)
|
||||
false)
|
||||
(mod-lint-test!
|
||||
"no unreachable when no catch-all"
|
||||
(mod/unreachable-rules mod-lint-nocatch)
|
||||
(list))
|
||||
(mod-lint-test!
|
||||
"no-catch-all rule set is not ok"
|
||||
(mod/rules-ok? mod-lint-nocatch)
|
||||
false)
|
||||
|
||||
;; ── duplicate names ──
|
||||
|
||||
(define
|
||||
mod-lint-dups
|
||||
(list
|
||||
(mod/mk-rule "x" :hide (list (list :classification "spam")))
|
||||
(mod/mk-rule "x" :remove (list (list :classification "abuse")))
|
||||
(mod/mk-rule "default" :keep (list))))
|
||||
|
||||
(mod-lint-test!
|
||||
"duplicate names detected"
|
||||
(mod/duplicate-rule-names mod-lint-dups)
|
||||
(list "x"))
|
||||
(mod-lint-test!
|
||||
"duplicate-name rule set is not ok"
|
||||
(mod/rules-ok? mod-lint-dups)
|
||||
false)
|
||||
|
||||
;; ── helpers ──
|
||||
|
||||
(mod-lint-test!
|
||||
"rule-unconditional? true for empty when"
|
||||
(mod/rule-unconditional? (mod/mk-rule "d" :keep (list)))
|
||||
true)
|
||||
(mod-lint-test!
|
||||
"rule-unconditional? false with conditions"
|
||||
(mod/rule-unconditional?
|
||||
(mod/mk-rule "s" :hide (list (list :classification "spam"))))
|
||||
false)
|
||||
(mod-lint-test!
|
||||
"count-eq counts occurrences"
|
||||
(mod/count-eq "a" (list "a" "b" "a"))
|
||||
2)
|
||||
|
||||
(define mod-lint-tests-run! (fn () {:failures mod-lint-failures :total mod-lint-count :passed mod-lint-pass :failed mod-lint-fail}))
|
||||
115
lib/mod/tests/offenders.sx
Normal file
115
lib/mod/tests/offenders.sx
Normal file
@@ -0,0 +1,115 @@
|
||||
;; lib/mod/tests/offenders.sx — Ext 7: repeat-offender escalation.
|
||||
|
||||
(define mod-off-count 0)
|
||||
(define mod-off-pass 0)
|
||||
(define mod-off-fail 0)
|
||||
(define mod-off-failures (list))
|
||||
|
||||
(define
|
||||
mod-off-test!
|
||||
(fn
|
||||
(name got expected)
|
||||
(begin
|
||||
(set! mod-off-count (+ mod-off-count 1))
|
||||
(if
|
||||
(= got expected)
|
||||
(set! mod-off-pass (+ mod-off-pass 1))
|
||||
(begin
|
||||
(set! mod-off-fail (+ mod-off-fail 1))
|
||||
(append!
|
||||
mod-off-failures
|
||||
(str name "\n expected: " expected "\n got: " got)))))))
|
||||
|
||||
;; ── sanction? predicate ──
|
||||
|
||||
(mod-off-test! "hide is a sanction" (mod/sanction? "hide") true)
|
||||
(mod-off-test! "remove is a sanction" (mod/sanction? "remove") true)
|
||||
(mod-off-test! "ban is a sanction" (mod/sanction? "ban") true)
|
||||
(mod-off-test! "keep is not a sanction" (mod/sanction? "keep") false)
|
||||
(mod-off-test! "escalate is not a sanction" (mod/sanction? "escalate") false)
|
||||
|
||||
;; ── repeat-offender escalation over the audit log ──
|
||||
|
||||
(mod/reset!)
|
||||
(mod/report "u1" "spammer" "this is spam")
|
||||
(mod/report "u2" "spammer" "buy now offer")
|
||||
(mod/report "u3" "spammer" "click here free money")
|
||||
(mod/report "u4" "innocent" "fine post")
|
||||
|
||||
(mod-off-test!
|
||||
"no sanctions before any decision"
|
||||
(mod/subject-sanctions "spammer")
|
||||
0)
|
||||
|
||||
(define mod-off-d1 (mod/decide-escalating "r1" 2))
|
||||
(mod-off-test!
|
||||
"first spam → hide (0 priors)"
|
||||
(get mod-off-d1 :action)
|
||||
"hide")
|
||||
(mod-off-test!
|
||||
"one sanction recorded"
|
||||
(mod/subject-sanctions "spammer")
|
||||
1)
|
||||
|
||||
(define mod-off-d2 (mod/decide-escalating "r2" 2))
|
||||
(mod-off-test!
|
||||
"second spam → hide (1 prior, below k=2)"
|
||||
(get mod-off-d2 :action)
|
||||
"hide")
|
||||
(mod-off-test!
|
||||
"two sanctions recorded"
|
||||
(mod/subject-sanctions "spammer")
|
||||
2)
|
||||
|
||||
(define mod-off-d3 (mod/decide-escalating "r3" 2))
|
||||
(mod-off-test!
|
||||
"third spam → ban (2 priors ≥ k)"
|
||||
(get mod-off-d3 :action)
|
||||
"ban")
|
||||
(mod-off-test!
|
||||
"ban decision names repeat-offender rule"
|
||||
(get mod-off-d3 :rule)
|
||||
"repeat-offender-ban")
|
||||
(mod-off-test!
|
||||
"ban proof records prior sanction count"
|
||||
(get (get mod-off-d3 :proof) :prior-sanctions)
|
||||
2)
|
||||
|
||||
;; ── different subjects accumulate independently ──
|
||||
|
||||
(define mod-off-d4 (mod/decide-escalating "r4" 2))
|
||||
(mod-off-test!
|
||||
"innocent keep → not escalated"
|
||||
(get mod-off-d4 :action)
|
||||
"keep")
|
||||
(mod-off-test!
|
||||
"innocent has no sanctions"
|
||||
(mod/subject-sanctions "innocent")
|
||||
0)
|
||||
(mod-off-test!
|
||||
"repeat-offender? true for spammer at k=2"
|
||||
(mod/repeat-offender? "spammer" 2)
|
||||
true)
|
||||
(mod-off-test!
|
||||
"repeat-offender? false for innocent at k=1"
|
||||
(mod/repeat-offender? "innocent" 1)
|
||||
false)
|
||||
|
||||
;; ── non-sanction decisions are never upgraded to ban ──
|
||||
;; r5 is a clean post, but it is the 4th report about "spammer", so the
|
||||
;; repeated-report rule escalates it. escalate is not a sanction, so it passes
|
||||
;; through decide-escalating unchanged (never becomes :ban).
|
||||
|
||||
(mod/report "u5" "spammer" "a perfectly fine post")
|
||||
(define mod-off-d5 (mod/decide-escalating "r5" 1))
|
||||
(mod-off-test!
|
||||
"non-sanction (escalate) decision is not upgraded to ban"
|
||||
(get mod-off-d5 :action)
|
||||
"escalate")
|
||||
|
||||
(mod-off-test!
|
||||
"decide-escalating unknown id → nil"
|
||||
(mod/decide-escalating "r99" 2)
|
||||
nil)
|
||||
|
||||
(define mod-offenders-tests-run! (fn () {:failures mod-off-failures :total mod-off-count :passed mod-off-pass :failed mod-off-fail}))
|
||||
112
lib/mod/tests/pipeline.sx
Normal file
112
lib/mod/tests/pipeline.sx
Normal file
@@ -0,0 +1,112 @@
|
||||
;; lib/mod/tests/pipeline.sx — Ext 19: end-to-end triage orchestration.
|
||||
|
||||
(define mod-pp-count 0)
|
||||
(define mod-pp-pass 0)
|
||||
(define mod-pp-fail 0)
|
||||
(define mod-pp-failures (list))
|
||||
|
||||
(define
|
||||
mod-pp-test!
|
||||
(fn
|
||||
(name got expected)
|
||||
(begin
|
||||
(set! mod-pp-count (+ mod-pp-count 1))
|
||||
(if
|
||||
(= got expected)
|
||||
(set! mod-pp-pass (+ mod-pp-pass 1))
|
||||
(begin
|
||||
(set! mod-pp-fail (+ mod-pp-fail 1))
|
||||
(append!
|
||||
mod-pp-failures
|
||||
(str name "\n expected: " expected "\n got: " got)))))))
|
||||
|
||||
(mod/policies-reset!)
|
||||
(mod/register-policy!
|
||||
"market"
|
||||
(mod/ruleset
|
||||
(mod/defrule "market-spam-remove" :remove (list :classification "spam"))
|
||||
(mod/defrule "default-keep" :keep)))
|
||||
|
||||
;; ── spam in the market domain: full bundle ──
|
||||
|
||||
(define mod-pp-spam (mod/mk-report "r1" "u" "bob" "this is spam"))
|
||||
(define
|
||||
mod-pp
|
||||
(mod/triage-pipeline "market" mod-pp-spam (list mod-pp-spam) "inst.example"))
|
||||
|
||||
(mod-pp-test!
|
||||
"pipeline action (market policy → remove)"
|
||||
(mod/pipeline-action mod-pp)
|
||||
"remove")
|
||||
(mod-pp-test! "pipeline rule" (get mod-pp :rule) "market-spam-remove")
|
||||
(mod-pp-test!
|
||||
"pipeline explanation mentions the action"
|
||||
(mod/str-contains? (get mod-pp :explanation) "remove")
|
||||
true)
|
||||
(mod-pp-test!
|
||||
"pipeline activity is Delete (remove)"
|
||||
(get (mod/pipeline-activity mod-pp) :type)
|
||||
"Delete")
|
||||
(mod-pp-test!
|
||||
"pipeline activity object is the report"
|
||||
(get (mod/pipeline-activity mod-pp) :object)
|
||||
"r1")
|
||||
(mod-pp-test!
|
||||
"pipeline wire round-trips to the same action"
|
||||
(get (mod/wire->decision (mod/pipeline-wire mod-pp)) :action)
|
||||
"remove")
|
||||
|
||||
;; ── same report, blog domain (default) → hide, Flag ──
|
||||
|
||||
(define
|
||||
mod-pp-blog
|
||||
(mod/triage-pipeline "blog" mod-pp-spam (list mod-pp-spam) "inst.example"))
|
||||
(mod-pp-test!
|
||||
"blog default policy → hide"
|
||||
(mod/pipeline-action mod-pp-blog)
|
||||
"hide")
|
||||
(mod-pp-test!
|
||||
"blog activity is Flag"
|
||||
(get (mod/pipeline-activity mod-pp-blog) :type)
|
||||
"Flag")
|
||||
|
||||
;; ── clean report: keep, no activity, explanation says (none) ──
|
||||
|
||||
(define mod-pp-clean (mod/mk-report "r2" "u" "eve" "a fine post"))
|
||||
(define
|
||||
mod-pp-k
|
||||
(mod/triage-pipeline
|
||||
"market"
|
||||
mod-pp-clean
|
||||
(list mod-pp-clean)
|
||||
"inst.example"))
|
||||
(mod-pp-test! "clean → keep" (mod/pipeline-action mod-pp-k) "keep")
|
||||
(mod-pp-test! "keep → no activity" (mod/pipeline-activity mod-pp-k) nil)
|
||||
(mod-pp-test!
|
||||
"keep explanation says no evidence"
|
||||
(mod/str-contains? (get mod-pp-k :explanation) "Evidence: (none)")
|
||||
true)
|
||||
(mod-pp-test!
|
||||
"keep wire still round-trips"
|
||||
(get (mod/wire->decision (mod/pipeline-wire mod-pp-k)) :rule)
|
||||
"default-keep")
|
||||
|
||||
;; ── federated handoff: market decision crosses to a peer, trust-gated ──
|
||||
|
||||
(mod/fed-reset!)
|
||||
(define mod-pp-peer-dec (mod/wire->decision (mod/pipeline-wire mod-pp)))
|
||||
(mod-pp-test!
|
||||
"untrusted peer: market decision is advisory"
|
||||
(get (mod/fed-receive-decision "peerX" mod-pp-peer-dec) :applied)
|
||||
false)
|
||||
(mod/grant-trust "peerY" :mod)
|
||||
(mod-pp-test!
|
||||
"trusted peer: market decision applies"
|
||||
(get (mod/fed-receive-decision "peerY" mod-pp-peer-dec) :applied)
|
||||
true)
|
||||
(mod-pp-test!
|
||||
"applied action is remove"
|
||||
(get (mod/fed-applied-action "r1") :action)
|
||||
"remove")
|
||||
|
||||
(define mod-pipeline-tests-run! (fn () {:failures mod-pp-failures :total mod-pp-count :passed mod-pp-pass :failed mod-pp-fail}))
|
||||
112
lib/mod/tests/policies.sx
Normal file
112
lib/mod/tests/policies.sx
Normal file
@@ -0,0 +1,112 @@
|
||||
;; lib/mod/tests/policies.sx — Ext 17: per-domain policy registry.
|
||||
|
||||
(define mod-pol-count 0)
|
||||
(define mod-pol-pass 0)
|
||||
(define mod-pol-fail 0)
|
||||
(define mod-pol-failures (list))
|
||||
|
||||
(define
|
||||
mod-pol-test!
|
||||
(fn
|
||||
(name got expected)
|
||||
(begin
|
||||
(set! mod-pol-count (+ mod-pol-count 1))
|
||||
(if
|
||||
(= got expected)
|
||||
(set! mod-pol-pass (+ mod-pol-pass 1))
|
||||
(begin
|
||||
(set! mod-pol-fail (+ mod-pol-fail 1))
|
||||
(append!
|
||||
mod-pol-failures
|
||||
(str name "\n expected: " expected "\n got: " got)))))))
|
||||
|
||||
(mod/policies-reset!)
|
||||
|
||||
;; market is strict: spam is removed outright, not just hidden
|
||||
(define
|
||||
mod-pol-market-rules
|
||||
(list
|
||||
(mod/mk-rule
|
||||
"market-spam-remove"
|
||||
:remove (list (list :classification "spam")))
|
||||
(mod/mk-rule "default-keep" :keep (list))))
|
||||
|
||||
(mod-pol-test!
|
||||
"unregistered domain falls back to default"
|
||||
(mod/policy-registered? "market")
|
||||
false)
|
||||
(mod/register-policy! "market" mod-pol-market-rules)
|
||||
(mod-pol-test!
|
||||
"domain registered after register!"
|
||||
(mod/policy-registered? "market")
|
||||
true)
|
||||
|
||||
(define mod-pol-spam (mod/mk-report "r1" "a" "b" "this is spam"))
|
||||
|
||||
;; ── same report, different domain → different action ──
|
||||
|
||||
(mod-pol-test!
|
||||
"market policy removes spam"
|
||||
(get (mod/decide-in "market" mod-pol-spam (list mod-pol-spam)) :action)
|
||||
"remove")
|
||||
(mod-pol-test!
|
||||
"market decision uses market rule"
|
||||
(get (mod/decide-in "market" mod-pol-spam (list mod-pol-spam)) :rule)
|
||||
"market-spam-remove")
|
||||
(mod-pol-test!
|
||||
"blog (unregistered) uses default → hide"
|
||||
(get (mod/decide-in "blog" mod-pol-spam (list mod-pol-spam)) :action)
|
||||
"hide")
|
||||
(mod-pol-test!
|
||||
"blog decision uses default rule"
|
||||
(get (mod/decide-in "blog" mod-pol-spam (list mod-pol-spam)) :rule)
|
||||
"spam-hide")
|
||||
|
||||
;; ── policy-for resolution ──
|
||||
|
||||
(mod-pol-test!
|
||||
"policy-for market returns market rules"
|
||||
(mod/policy-for "market")
|
||||
mod-pol-market-rules)
|
||||
(mod-pol-test!
|
||||
"policy-for unknown returns default"
|
||||
(mod/policy-for "events")
|
||||
mod/default-rules)
|
||||
(mod-pol-test!
|
||||
"registered-domains lists market"
|
||||
(mod/registered-domains)
|
||||
(list "market"))
|
||||
|
||||
;; ── a second domain ──
|
||||
|
||||
(define
|
||||
mod-pol-events-rules
|
||||
(list (mod/mk-rule "events-keep-all" :keep (list))))
|
||||
|
||||
(mod/register-policy! "events" mod-pol-events-rules)
|
||||
(mod-pol-test!
|
||||
"events policy keeps everything (even spam)"
|
||||
(get (mod/decide-in "events" mod-pol-spam (list mod-pol-spam)) :action)
|
||||
"keep")
|
||||
(mod-pol-test!
|
||||
"two domains registered"
|
||||
(len (mod/registered-domains))
|
||||
2)
|
||||
(mod-pol-test!
|
||||
"market still removes after second registration"
|
||||
(get (mod/decide-in "market" mod-pol-spam (list mod-pol-spam)) :action)
|
||||
"remove")
|
||||
|
||||
;; ── clean report is keep everywhere ──
|
||||
|
||||
(define mod-pol-clean (mod/mk-report "r2" "a" "b" "a fine post"))
|
||||
(mod-pol-test!
|
||||
"clean report keep in market"
|
||||
(get (mod/decide-in "market" mod-pol-clean (list mod-pol-clean)) :action)
|
||||
"keep")
|
||||
(mod-pol-test!
|
||||
"clean report keep in blog"
|
||||
(get (mod/decide-in "blog" mod-pol-clean (list mod-pol-clean)) :action)
|
||||
"keep")
|
||||
|
||||
(define mod-policies-tests-run! (fn () {:failures mod-pol-failures :total mod-pol-count :passed mod-pol-pass :failed mod-pol-fail}))
|
||||
119
lib/mod/tests/quorum.sx
Normal file
119
lib/mod/tests/quorum.sx
Normal 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}))
|
||||
120
lib/mod/tests/severity.sx
Normal file
120
lib/mod/tests/severity.sx
Normal file
@@ -0,0 +1,120 @@
|
||||
;; lib/mod/tests/severity.sx — Ext 6: strictest-wins decision strategy.
|
||||
|
||||
(define mod-sev-count 0)
|
||||
(define mod-sev-pass 0)
|
||||
(define mod-sev-fail 0)
|
||||
(define mod-sev-failures (list))
|
||||
|
||||
(define
|
||||
mod-sev-test!
|
||||
(fn
|
||||
(name got expected)
|
||||
(begin
|
||||
(set! mod-sev-count (+ mod-sev-count 1))
|
||||
(if
|
||||
(= got expected)
|
||||
(set! mod-sev-pass (+ mod-sev-pass 1))
|
||||
(begin
|
||||
(set! mod-sev-fail (+ mod-sev-fail 1))
|
||||
(append!
|
||||
mod-sev-failures
|
||||
(str name "\n expected: " expected "\n got: " got)))))))
|
||||
|
||||
;; ── severity ranking ──
|
||||
|
||||
(mod-sev-test! "ban most severe" (mod/action-severity "ban") 4)
|
||||
(mod-sev-test!
|
||||
"remove > hide"
|
||||
(< (mod/action-severity "hide") (mod/action-severity "remove"))
|
||||
true)
|
||||
(mod-sev-test! "keep least severe" (mod/action-severity "keep") 0)
|
||||
(mod-sev-test!
|
||||
"escalate above keep"
|
||||
(< (mod/action-severity "keep") (mod/action-severity "escalate"))
|
||||
true)
|
||||
|
||||
;; ── strictest agrees with default-rules on simple cases ──
|
||||
|
||||
(define mod-sev-spam (mod/mk-report "r1" "a" "b" "this is spam"))
|
||||
(mod-sev-test!
|
||||
"strictest spam → hide"
|
||||
(get
|
||||
(mod/decide-strictest mod-sev-spam (list mod-sev-spam) mod/default-rules)
|
||||
:action)
|
||||
"hide")
|
||||
(define mod-sev-clean (mod/mk-report "r2" "a" "b" "a fine post"))
|
||||
(mod-sev-test!
|
||||
"strictest clean → keep"
|
||||
(get
|
||||
(mod/decide-strictest
|
||||
mod-sev-clean
|
||||
(list mod-sev-clean)
|
||||
mod/default-rules)
|
||||
:action)
|
||||
"keep")
|
||||
(mod-sev-test!
|
||||
"decision tagged strategy strictest"
|
||||
(get
|
||||
(mod/decide-strictest mod-sev-spam (list mod-sev-spam) mod/default-rules)
|
||||
:strategy)
|
||||
"strictest")
|
||||
|
||||
;; ── strictest diverges from first-match when order ≠ severity ──
|
||||
|
||||
(define
|
||||
mod-sev-rules
|
||||
(list
|
||||
(mod/mk-rule
|
||||
"early-escalate"
|
||||
:escalate (list (list :count-at-least 1)))
|
||||
(mod/mk-rule "spam-remove" :remove (list (list :classification "spam")))
|
||||
(mod/mk-rule "default-keep" :keep (list))))
|
||||
|
||||
(define mod-sev-r (mod/mk-report "r3" "a" "b" "this is spam"))
|
||||
|
||||
(mod-sev-test!
|
||||
"first-match picks earliest rule (escalate)"
|
||||
(get (mod/decide-report mod-sev-r (list mod-sev-r) mod-sev-rules) :action)
|
||||
"escalate")
|
||||
(mod-sev-test!
|
||||
"strictest picks harshest action (remove)"
|
||||
(get
|
||||
(mod/decide-strictest mod-sev-r (list mod-sev-r) mod-sev-rules)
|
||||
:action)
|
||||
"remove")
|
||||
(mod-sev-test!
|
||||
"strictest names the harshest rule"
|
||||
(get (mod/decide-strictest mod-sev-r (list mod-sev-r) mod-sev-rules) :rule)
|
||||
"spam-remove")
|
||||
(mod-sev-test!
|
||||
"strictest carries proof goals"
|
||||
(len
|
||||
(get
|
||||
(get
|
||||
(mod/decide-strictest mod-sev-r (list mod-sev-r) mod-sev-rules)
|
||||
:proof)
|
||||
:goals))
|
||||
1)
|
||||
|
||||
;; ── strictest among three matches (spam + repeated) ──
|
||||
|
||||
(define mod-sev-rep (mod/mk-report "r4" "a" "b" "buy now spam"))
|
||||
(define mod-sev-reps (list mod-sev-rep mod-sev-rep mod-sev-rep))
|
||||
(mod-sev-test!
|
||||
"strictest among hide+escalate+keep → hide (default rules)"
|
||||
(get
|
||||
(mod/decide-strictest mod-sev-rep mod-sev-reps mod/default-rules)
|
||||
:action)
|
||||
"hide")
|
||||
|
||||
;; ── strictest-sol helper ──
|
||||
|
||||
(mod-sev-test!
|
||||
"strictest-sol picks max severity"
|
||||
(dict-get
|
||||
(mod/strictest-sol (list {:Action "keep" :Rule "k"} {:Action "remove" :Rule "r"} {:Action "hide" :Rule "h"}))
|
||||
"Action")
|
||||
"remove")
|
||||
(mod-sev-test! "strictest-sol nil for empty" (mod/strictest-sol (list)) nil)
|
||||
|
||||
(define mod-severity-tests-run! (fn () {:failures mod-sev-failures :total mod-sev-count :passed mod-sev-pass :failed mod-sev-fail}))
|
||||
108
lib/mod/tests/sla.sx
Normal file
108
lib/mod/tests/sla.sx
Normal file
@@ -0,0 +1,108 @@
|
||||
;; lib/mod/tests/sla.sx — Ext 13: SLA sweep over pending lifecycle cases.
|
||||
|
||||
(define mod-sla-count 0)
|
||||
(define mod-sla-pass 0)
|
||||
(define mod-sla-fail 0)
|
||||
(define mod-sla-failures (list))
|
||||
|
||||
(define
|
||||
mod-sla-test!
|
||||
(fn
|
||||
(name got expected)
|
||||
(begin
|
||||
(set! mod-sla-count (+ mod-sla-count 1))
|
||||
(if
|
||||
(= got expected)
|
||||
(set! mod-sla-pass (+ mod-sla-pass 1))
|
||||
(begin
|
||||
(set! mod-sla-fail (+ mod-sla-fail 1))
|
||||
(append!
|
||||
mod-sla-failures
|
||||
(str name "\n expected: " expected "\n got: " got)))))))
|
||||
|
||||
;; ── pending-state? ──
|
||||
|
||||
(mod-sla-test! "open is pending" (mod/pending-state? "open") true)
|
||||
(mod-sla-test! "triaged is pending" (mod/pending-state? "triaged") true)
|
||||
(mod-sla-test! "appealed is pending" (mod/pending-state? "appealed") true)
|
||||
(mod-sla-test! "decided is not pending" (mod/pending-state? "decided") false)
|
||||
(mod-sla-test! "final is not pending" (mod/pending-state? "final") false)
|
||||
|
||||
;; build cases in known states
|
||||
(define mod-sla-spam (mod/mk-report "r1" "u" "bob" "this is spam"))
|
||||
(define mod-sla-spam-reports (list mod-sla-spam))
|
||||
(define
|
||||
mod-sla-triaged
|
||||
(mod/case-triage
|
||||
(mod/mk-case mod-sla-spam)
|
||||
mod-sla-spam-reports
|
||||
mod/default-rules))
|
||||
(define mod-sla-decided (mod/case-resolve mod-sla-triaged))
|
||||
(define mod-sla-open (mod/mk-case (mod/mk-report "r2" "u" "eve" "hello")))
|
||||
|
||||
;; ── overdue? ──
|
||||
|
||||
(define mod-sla-tc-old (mod/mk-timed-case mod-sla-triaged 0))
|
||||
(define mod-sla-tc-fresh (mod/mk-timed-case mod-sla-triaged 90))
|
||||
(define mod-sla-tc-done (mod/mk-timed-case mod-sla-decided 0))
|
||||
|
||||
(mod-sla-test!
|
||||
"old triaged case is overdue"
|
||||
(mod/overdue? mod-sla-tc-old 100 50)
|
||||
true)
|
||||
(mod-sla-test!
|
||||
"fresh triaged case not overdue"
|
||||
(mod/overdue? mod-sla-tc-fresh 100 50)
|
||||
false)
|
||||
(mod-sla-test!
|
||||
"decided case never overdue"
|
||||
(mod/overdue? mod-sla-tc-done 100 50)
|
||||
false)
|
||||
(mod-sla-test!
|
||||
"age computes elapsed ticks"
|
||||
(mod/age mod-sla-tc-old 100)
|
||||
100)
|
||||
(mod-sla-test!
|
||||
"boundary: exactly at deadline not overdue"
|
||||
(mod/overdue?
|
||||
(mod/mk-timed-case mod-sla-triaged 50)
|
||||
100
|
||||
50)
|
||||
false)
|
||||
(mod-sla-test!
|
||||
"boundary: one past deadline overdue"
|
||||
(mod/overdue?
|
||||
(mod/mk-timed-case mod-sla-triaged 49)
|
||||
100
|
||||
50)
|
||||
true)
|
||||
|
||||
;; ── sweep over a mixed queue ──
|
||||
|
||||
(define
|
||||
mod-sla-queue
|
||||
(list
|
||||
(mod/mk-timed-case mod-sla-triaged 0)
|
||||
(mod/mk-timed-case mod-sla-decided 0)
|
||||
(mod/mk-timed-case mod-sla-open 90))) ;; r2, pending, age 10 → not
|
||||
|
||||
(mod-sla-test!
|
||||
"sweep finds only the overdue pending case"
|
||||
(mod/sla-sweep mod-sla-queue 100 50)
|
||||
(list "r1"))
|
||||
(mod-sla-test!
|
||||
"overdue-count agrees"
|
||||
(mod/overdue-count mod-sla-queue 100 50)
|
||||
1)
|
||||
|
||||
;; tighten deadline so the young open case also breaches
|
||||
(mod-sla-test!
|
||||
"tighter deadline catches the open case too"
|
||||
(mod/overdue-count mod-sla-queue 100 5)
|
||||
2)
|
||||
(mod-sla-test!
|
||||
"empty queue → no breaches"
|
||||
(mod/sla-sweep (list) 100 50)
|
||||
(list))
|
||||
|
||||
(define mod-sla-tests-run! (fn () {:failures mod-sla-failures :total mod-sla-count :passed mod-sla-pass :failed mod-sla-fail}))
|
||||
156
lib/mod/tests/temporal.sx
Normal file
156
lib/mod/tests/temporal.sx
Normal file
@@ -0,0 +1,156 @@
|
||||
;; lib/mod/tests/temporal.sx — Ext 12: burst detection over a time window.
|
||||
|
||||
(define mod-tm-count 0)
|
||||
(define mod-tm-pass 0)
|
||||
(define mod-tm-fail 0)
|
||||
(define mod-tm-failures (list))
|
||||
|
||||
(define
|
||||
mod-tm-test!
|
||||
(fn
|
||||
(name got expected)
|
||||
(begin
|
||||
(set! mod-tm-count (+ mod-tm-count 1))
|
||||
(if
|
||||
(= got expected)
|
||||
(set! mod-tm-pass (+ mod-tm-pass 1))
|
||||
(begin
|
||||
(set! mod-tm-fail (+ mod-tm-fail 1))
|
||||
(append!
|
||||
mod-tm-failures
|
||||
(str name "\n expected: " expected "\n got: " got)))))))
|
||||
|
||||
(define
|
||||
mod-tm-at
|
||||
(fn (id about t) (mod/with-at (mod/mk-report id "u" about "off-topic") t)))
|
||||
|
||||
(define
|
||||
mod-tm-rules
|
||||
(list
|
||||
(mod/mk-rule "burst-hide" :hide (list (list :burst-at-least 3)))
|
||||
(mod/mk-rule "default-keep" :keep (list))))
|
||||
|
||||
;; ── window-count helper ──
|
||||
|
||||
(define
|
||||
mod-tm-burst
|
||||
(list
|
||||
(mod-tm-at "r1" "bob" 10)
|
||||
(mod-tm-at "r2" "bob" 11)
|
||||
(mod-tm-at "r3" "bob" 12)))
|
||||
(define
|
||||
mod-tm-slow
|
||||
(list
|
||||
(mod-tm-at "r1" "bob" 1)
|
||||
(mod-tm-at "r2" "bob" 2)
|
||||
(mod-tm-at "r3" "bob" 12)))
|
||||
|
||||
(mod-tm-test!
|
||||
"window-count: all 3 within window"
|
||||
(mod/window-count "bob" mod-tm-burst 12 5)
|
||||
3)
|
||||
(mod-tm-test!
|
||||
"window-count: only 1 within window"
|
||||
(mod/window-count "bob" mod-tm-slow 12 5)
|
||||
1)
|
||||
(mod-tm-test!
|
||||
"window-count: subject filter"
|
||||
(mod/window-count "eve" mod-tm-burst 12 5)
|
||||
0)
|
||||
|
||||
;; ── burst fires; slow accumulation does not ──
|
||||
|
||||
(mod-tm-test!
|
||||
"burst (3 in window) → hide"
|
||||
(get
|
||||
(mod/decide-temporal
|
||||
(first mod-tm-burst)
|
||||
mod-tm-burst
|
||||
mod-tm-rules
|
||||
12
|
||||
5)
|
||||
:action)
|
||||
"hide")
|
||||
(mod-tm-test!
|
||||
"slow accumulation (1 in window) → keep"
|
||||
(get
|
||||
(mod/decide-temporal
|
||||
(first mod-tm-slow)
|
||||
mod-tm-slow
|
||||
mod-tm-rules
|
||||
12
|
||||
5)
|
||||
:action)
|
||||
"keep")
|
||||
|
||||
;; ── contrast: the plain count rule fires on BOTH (3 total reports) ──
|
||||
(mod-tm-test!
|
||||
"count rule fires on slow case (distinct from burst)"
|
||||
(get
|
||||
(mod/decide-report (first mod-tm-slow) mod-tm-slow mod/default-rules)
|
||||
:action)
|
||||
"escalate")
|
||||
|
||||
;; ── decision shape ──
|
||||
|
||||
(define
|
||||
mod-tm-d
|
||||
(mod/decide-temporal
|
||||
(first mod-tm-burst)
|
||||
mod-tm-burst
|
||||
mod-tm-rules
|
||||
12
|
||||
5))
|
||||
(mod-tm-test! "burst decision rule" (get mod-tm-d :rule) "burst-hide")
|
||||
(mod-tm-test!
|
||||
"burst decision tagged strategy"
|
||||
(get mod-tm-d :strategy)
|
||||
"temporal")
|
||||
(mod-tm-test!
|
||||
"burst recorded in proof"
|
||||
(get (get mod-tm-d :proof) :burst)
|
||||
3)
|
||||
(mod-tm-test!
|
||||
"burst proof goal solved"
|
||||
(get (first (get (get mod-tm-d :proof) :goals)) :solved)
|
||||
true)
|
||||
|
||||
;; ── window boundary is inclusive ──
|
||||
|
||||
(define
|
||||
mod-tm-edge
|
||||
(list
|
||||
(mod-tm-at "r1" "bob" 7)
|
||||
(mod-tm-at "r2" "bob" 8)
|
||||
(mod-tm-at "r3" "bob" 9)))
|
||||
(mod-tm-test!
|
||||
"window boundary inclusive (now-window = at)"
|
||||
(mod/window-count "bob" mod-tm-edge 12 5)
|
||||
3)
|
||||
|
||||
;; ── schema :at round-trips and survives evidence attach ──
|
||||
|
||||
(mod-tm-test!
|
||||
"report-at reads timestamp"
|
||||
(mod/report-at (mod-tm-at "r1" "bob" 42))
|
||||
42)
|
||||
(mod-tm-test!
|
||||
"default report-at is 0"
|
||||
(mod/report-at (mod/mk-report "r1" "a" "b" "x"))
|
||||
0)
|
||||
(mod-tm-test!
|
||||
"attach-evidence preserves :at"
|
||||
(mod/report-at
|
||||
(mod/attach-evidence
|
||||
(mod-tm-at "r1" "bob" 42)
|
||||
(mod/mk-evidence "k" "v")))
|
||||
42)
|
||||
|
||||
;; ── cond->goal :burst-at-least ──
|
||||
|
||||
(mod-tm-test!
|
||||
"cond->goal :burst-at-least"
|
||||
(mod/cond->goal (list :burst-at-least 3) "Id")
|
||||
"report(Id, _, Sb), burst_count(Sb, Nb), Nb >= 3")
|
||||
|
||||
(define mod-temporal-tests-run! (fn () {:failures mod-tm-failures :total mod-tm-count :passed mod-tm-pass :failed mod-tm-fail}))
|
||||
116
lib/mod/tests/trace.sx
Normal file
116
lib/mod/tests/trace.sx
Normal file
@@ -0,0 +1,116 @@
|
||||
;; lib/mod/tests/trace.sx — Ext 9: policy dry-run diagnostics.
|
||||
|
||||
(define mod-tr-count 0)
|
||||
(define mod-tr-pass 0)
|
||||
(define mod-tr-fail 0)
|
||||
(define mod-tr-failures (list))
|
||||
|
||||
(define
|
||||
mod-tr-test!
|
||||
(fn
|
||||
(name got expected)
|
||||
(begin
|
||||
(set! mod-tr-count (+ mod-tr-count 1))
|
||||
(if
|
||||
(= got expected)
|
||||
(set! mod-tr-pass (+ mod-tr-pass 1))
|
||||
(begin
|
||||
(set! mod-tr-fail (+ mod-tr-fail 1))
|
||||
(append!
|
||||
mod-tr-failures
|
||||
(str name "\n expected: " expected "\n got: " got)))))))
|
||||
|
||||
(define
|
||||
mod-tr-find
|
||||
(fn
|
||||
(trace nm)
|
||||
(reduce (fn (acc t) (if (= (get t :rule) nm) t acc)) nil trace)))
|
||||
|
||||
;; ── trace a spam report against the default rules ──
|
||||
|
||||
(define mod-tr-spam (mod/mk-report "r1" "alice" "bob" "this is spam"))
|
||||
(define
|
||||
mod-tr-t
|
||||
(mod/trace-rules mod-tr-spam (list mod-tr-spam) mod/default-rules))
|
||||
|
||||
(mod-tr-test! "trace covers every rule" (len mod-tr-t) 6)
|
||||
(mod-tr-test!
|
||||
"spam-hide fires"
|
||||
(get (mod-tr-find mod-tr-t "spam-hide") :proved)
|
||||
true)
|
||||
(mod-tr-test!
|
||||
"default-keep always fires"
|
||||
(get (mod-tr-find mod-tr-t "default-keep") :proved)
|
||||
true)
|
||||
(mod-tr-test!
|
||||
"reviewer-remove does not fire (no evidence)"
|
||||
(get (mod-tr-find mod-tr-t "reviewer-remove") :proved)
|
||||
false)
|
||||
(mod-tr-test!
|
||||
"exonerated-keep does not fire"
|
||||
(get (mod-tr-find mod-tr-t "exonerated-keep") :proved)
|
||||
false)
|
||||
(mod-tr-test!
|
||||
"abuse-remove does not fire"
|
||||
(get (mod-tr-find mod-tr-t "abuse-remove") :proved)
|
||||
false)
|
||||
|
||||
;; ── winner matches the engine ──
|
||||
|
||||
(mod-tr-test!
|
||||
"first-proved is spam-hide"
|
||||
(get (mod/first-proved mod-tr-t) :rule)
|
||||
"spam-hide")
|
||||
(mod-tr-test!
|
||||
"winner action matches decide-report"
|
||||
(get (mod/first-proved mod-tr-t) :action)
|
||||
(get
|
||||
(mod/decide-report mod-tr-spam (list mod-tr-spam) mod/default-rules)
|
||||
:action))
|
||||
|
||||
;; ── an unproved rule shows which goal failed ──
|
||||
|
||||
(define
|
||||
mod-tr-rev-goals
|
||||
(get (mod-tr-find mod-tr-t "reviewer-remove") :goals))
|
||||
(mod-tr-test!
|
||||
"reviewer-remove goal is unsolved"
|
||||
(get (first mod-tr-rev-goals) :solved)
|
||||
false)
|
||||
(define mod-tr-spam-goals (get (mod-tr-find mod-tr-t "spam-hide") :goals))
|
||||
(mod-tr-test!
|
||||
"spam-hide goal is solved"
|
||||
(get (first mod-tr-spam-goals) :solved)
|
||||
true)
|
||||
|
||||
;; ── proved-rules list + rendering ──
|
||||
|
||||
(mod-tr-test!
|
||||
"proved-rules lists fired rules in order"
|
||||
(mod/proved-rules mod-tr-t)
|
||||
(list "spam-hide" "default-keep"))
|
||||
(mod-tr-test!
|
||||
"trace-report marks a firing rule"
|
||||
(mod/str-contains? (mod/trace-report mod-tr-t) "[fires] spam-hide")
|
||||
true)
|
||||
(mod-tr-test!
|
||||
"trace-report marks a non-firing rule"
|
||||
(mod/str-contains? (mod/trace-report mod-tr-t) "[ - ] reviewer-remove")
|
||||
true)
|
||||
|
||||
;; ── clean report: only default-keep fires ──
|
||||
|
||||
(define mod-tr-clean (mod/mk-report "r2" "a" "b" "a fine post"))
|
||||
(define
|
||||
mod-tr-tc
|
||||
(mod/trace-rules mod-tr-clean (list mod-tr-clean) mod/default-rules))
|
||||
(mod-tr-test!
|
||||
"clean report: only default-keep proves"
|
||||
(mod/proved-rules mod-tr-tc)
|
||||
(list "default-keep"))
|
||||
(mod-tr-test!
|
||||
"clean report winner is default-keep"
|
||||
(get (mod/first-proved mod-tr-tc) :rule)
|
||||
"default-keep")
|
||||
|
||||
(define mod-trace-tests-run! (fn () {:failures mod-tr-failures :total mod-tr-count :passed mod-tr-pass :failed mod-tr-fail}))
|
||||
117
lib/mod/tests/whatif.sx
Normal file
117
lib/mod/tests/whatif.sx
Normal file
@@ -0,0 +1,117 @@
|
||||
;; lib/mod/tests/whatif.sx — Ext 10: policy what-if / impact analysis.
|
||||
|
||||
(define mod-wi-count 0)
|
||||
(define mod-wi-pass 0)
|
||||
(define mod-wi-fail 0)
|
||||
(define mod-wi-failures (list))
|
||||
|
||||
(define
|
||||
mod-wi-test!
|
||||
(fn
|
||||
(name got expected)
|
||||
(begin
|
||||
(set! mod-wi-count (+ mod-wi-count 1))
|
||||
(if
|
||||
(= got expected)
|
||||
(set! mod-wi-pass (+ mod-wi-pass 1))
|
||||
(begin
|
||||
(set! mod-wi-fail (+ mod-wi-fail 1))
|
||||
(append!
|
||||
mod-wi-failures
|
||||
(str name "\n expected: " expected "\n got: " got)))))))
|
||||
|
||||
;; rules-b is the default policy with spam-hide removed: spam now falls through
|
||||
;; to default-keep. A spam report flips hide → keep; everything else is unchanged.
|
||||
(define mod-wi-rules-a mod/default-rules)
|
||||
(define
|
||||
mod-wi-rules-b
|
||||
(list
|
||||
(mod/mk-rule
|
||||
"reviewer-remove"
|
||||
:remove (list (list :evidence "confirmed-abuse")))
|
||||
(mod/mk-rule
|
||||
"abuse-remove"
|
||||
:remove (list (list :classification "abuse")))
|
||||
(mod/mk-rule
|
||||
"repeated-escalate"
|
||||
:escalate (list (list :count-at-least 3)))
|
||||
(mod/mk-rule "default-keep" :keep (list))))
|
||||
|
||||
(define mod-wi-spam (mod/mk-report "r1" "a" "bob" "this is spam"))
|
||||
(define mod-wi-abuse (mod/mk-report "r2" "a" "carol" "harassment here"))
|
||||
(define mod-wi-clean (mod/mk-report "r3" "a" "dave" "a fine post"))
|
||||
|
||||
;; ── single-report diff ──
|
||||
|
||||
(define
|
||||
mod-wi-d
|
||||
(mod/decision-diff
|
||||
mod-wi-spam
|
||||
(list mod-wi-spam)
|
||||
mod-wi-rules-a
|
||||
mod-wi-rules-b))
|
||||
(mod-wi-test! "spam before = hide" (get mod-wi-d :before) "hide")
|
||||
(mod-wi-test! "spam after = keep" (get mod-wi-d :after) "keep")
|
||||
(mod-wi-test! "spam decision flips" (get mod-wi-d :changed) true)
|
||||
(mod-wi-test! "diff carries report id" (get mod-wi-d :report-id) "r1")
|
||||
|
||||
(define
|
||||
mod-wi-da
|
||||
(mod/decision-diff
|
||||
mod-wi-abuse
|
||||
(list mod-wi-abuse)
|
||||
mod-wi-rules-a
|
||||
mod-wi-rules-b))
|
||||
(mod-wi-test! "abuse unchanged (remove both)" (get mod-wi-da :changed) false)
|
||||
(mod-wi-test! "abuse stays remove" (get mod-wi-da :after) "remove")
|
||||
|
||||
(define
|
||||
mod-wi-dc
|
||||
(mod/decision-diff
|
||||
mod-wi-clean
|
||||
(list mod-wi-clean)
|
||||
mod-wi-rules-a
|
||||
mod-wi-rules-b))
|
||||
(mod-wi-test! "clean unchanged (keep both)" (get mod-wi-dc :changed) false)
|
||||
|
||||
;; ── batch impact ──
|
||||
|
||||
(define mod-wi-batch (list mod-wi-spam mod-wi-abuse mod-wi-clean))
|
||||
(define
|
||||
mod-wi-impact
|
||||
(mod/policy-impact mod-wi-batch mod-wi-rules-a mod-wi-rules-b))
|
||||
|
||||
(mod-wi-test!
|
||||
"impact lists only changed reports"
|
||||
(len mod-wi-impact)
|
||||
1)
|
||||
(mod-wi-test!
|
||||
"impacted report is the spam one"
|
||||
(get (first mod-wi-impact) :report-id)
|
||||
"r1")
|
||||
(mod-wi-test!
|
||||
"impact-count agrees"
|
||||
(mod/impact-count mod-wi-batch mod-wi-rules-a mod-wi-rules-b)
|
||||
1)
|
||||
|
||||
;; ── identical rule sets → no impact ──
|
||||
|
||||
(mod-wi-test!
|
||||
"same rules → zero impact"
|
||||
(mod/impact-count mod-wi-batch mod-wi-rules-a mod-wi-rules-a)
|
||||
0)
|
||||
(mod-wi-test!
|
||||
"same rules → empty report"
|
||||
(mod/impact-report mod-wi-batch mod-wi-rules-a mod-wi-rules-a)
|
||||
"No decisions change.")
|
||||
|
||||
;; ── rendering ──
|
||||
|
||||
(mod-wi-test!
|
||||
"impact-report renders the flip"
|
||||
(mod/str-contains?
|
||||
(mod/impact-report mod-wi-batch mod-wi-rules-a mod-wi-rules-b)
|
||||
"r1: hide → keep")
|
||||
true)
|
||||
|
||||
(define mod-whatif-tests-run! (fn () {:failures mod-wi-failures :total mod-wi-count :passed mod-wi-pass :failed mod-wi-fail}))
|
||||
96
lib/mod/tests/wire.sx
Normal file
96
lib/mod/tests/wire.sx
Normal file
@@ -0,0 +1,96 @@
|
||||
;; lib/mod/tests/wire.sx — Ext 14: decision wire format + federated transport.
|
||||
|
||||
(define mod-w-count 0)
|
||||
(define mod-w-pass 0)
|
||||
(define mod-w-fail 0)
|
||||
(define mod-w-failures (list))
|
||||
|
||||
(define
|
||||
mod-w-test!
|
||||
(fn
|
||||
(name got expected)
|
||||
(begin
|
||||
(set! mod-w-count (+ mod-w-count 1))
|
||||
(if
|
||||
(= got expected)
|
||||
(set! mod-w-pass (+ mod-w-pass 1))
|
||||
(begin
|
||||
(set! mod-w-fail (+ mod-w-fail 1))
|
||||
(append!
|
||||
mod-w-failures
|
||||
(str name "\n expected: " expected "\n got: " got)))))))
|
||||
|
||||
;; ── split-char ──
|
||||
|
||||
(mod-w-test! "split on pipe" (mod/split-char "a|b|c" "|") (list "a" "b" "c"))
|
||||
(mod-w-test! "split single field" (mod/split-char "abc" "|") (list "abc"))
|
||||
(mod-w-test!
|
||||
"split four fields"
|
||||
(len (mod/split-char "MOD1|r1|hide|spam-hide" "|"))
|
||||
4)
|
||||
|
||||
;; ── serialize ──
|
||||
|
||||
(define
|
||||
mod-w-dec
|
||||
(mod/decide-report
|
||||
(mod/mk-report "r1" "a" "bob" "this is spam")
|
||||
(list (mod/mk-report "r1" "a" "bob" "this is spam"))
|
||||
mod/default-rules))
|
||||
(define mod-w-line (mod/decision->wire mod-w-dec))
|
||||
|
||||
(mod-w-test!
|
||||
"wire is versioned + delimited"
|
||||
mod-w-line
|
||||
"MOD1|r1|hide|spam-hide")
|
||||
(mod-w-test!
|
||||
"wire-valid? accepts well-formed"
|
||||
(mod/wire-valid? mod-w-line)
|
||||
true)
|
||||
(mod-w-test!
|
||||
"wire-valid? rejects junk"
|
||||
(mod/wire-valid? "not a wire line")
|
||||
false)
|
||||
(mod-w-test!
|
||||
"wire-valid? rejects wrong version"
|
||||
(mod/wire-valid? "MOD9|r1|hide|x")
|
||||
false)
|
||||
|
||||
;; ── round-trip ──
|
||||
|
||||
(define mod-w-back (mod/wire->decision mod-w-line))
|
||||
(mod-w-test! "round-trip report-id" (get mod-w-back :report-id) "r1")
|
||||
(mod-w-test! "round-trip action" (get mod-w-back :action) "hide")
|
||||
(mod-w-test! "round-trip rule" (get mod-w-back :rule) "spam-hide")
|
||||
(mod-w-test! "round-trip tags :wire" (get mod-w-back :wire) true)
|
||||
(mod-w-test! "malformed → nil" (mod/wire->decision "garbage") nil)
|
||||
|
||||
;; ── full federated transport: serialize → wire → deserialize → trust-gate ──
|
||||
|
||||
(mod/fed-reset!)
|
||||
(define mod-w-peer-dec (mod/wire->decision mod-w-line))
|
||||
|
||||
;; untrusted peer: decision is advisory, not applied
|
||||
(define mod-w-recv1 (mod/fed-receive-decision "peerX" mod-w-peer-dec))
|
||||
(mod-w-test!
|
||||
"wired decision from untrusted peer → advisory"
|
||||
(get mod-w-recv1 :applied)
|
||||
false)
|
||||
(mod-w-test!
|
||||
"untrusted wired decision not applied locally"
|
||||
(mod/fed-applied-action "r1")
|
||||
nil)
|
||||
|
||||
;; trusted peer: decision binds locally
|
||||
(mod/grant-trust "peerY" :mod)
|
||||
(define mod-w-recv2 (mod/fed-receive-decision "peerY" mod-w-peer-dec))
|
||||
(mod-w-test!
|
||||
"wired decision from trusted peer → applied"
|
||||
(get mod-w-recv2 :applied)
|
||||
true)
|
||||
(mod-w-test!
|
||||
"trusted wired decision binds locally"
|
||||
(get (mod/fed-applied-action "r1") :action)
|
||||
"hide")
|
||||
|
||||
(define mod-wire-tests-run! (fn () {:failures mod-w-failures :total mod-w-count :passed mod-w-pass :failed mod-w-fail}))
|
||||
56
lib/mod/trace.sx
Normal file
56
lib/mod/trace.sx
Normal file
@@ -0,0 +1,56 @@
|
||||
;; lib/mod/trace.sx — policy dry-run diagnostics.
|
||||
;;
|
||||
;; decide-report returns the winning rule; a policy author debugging "why didn't
|
||||
;; my rule fire?" needs the whole picture. mod/trace-rules evaluates a report
|
||||
;; against every rule and reports each rule's proved/unproved status plus its
|
||||
;; goal-by-goal derivation — so an unproved rule shows exactly which goal failed.
|
||||
;; The winner is the first proved rule (same precedence as the engine).
|
||||
|
||||
(define
|
||||
mod/trace-rules
|
||||
(fn
|
||||
(r reports rules)
|
||||
(let
|
||||
((count (mod/report-count (mod/report-about r) reports))
|
||||
(id (mod/report-id r)))
|
||||
(let
|
||||
((db (pl-load (mod/build-program r count rules))))
|
||||
(let
|
||||
((proved-names (map (fn (s) (dict-get s "Rule")) (pl-query-all db (str "policy_action(" id ", _, Rule)")))))
|
||||
(map
|
||||
(fn (rule) (let ((nm (mod/rule-name rule))) {:proved (mod/member? nm proved-names) :goals (mod/proof-goals db id (mod/rule-when rule)) :action (mod/rule-action rule) :rule nm}))
|
||||
rules))))))
|
||||
|
||||
(define
|
||||
mod/first-proved
|
||||
(fn
|
||||
(trace)
|
||||
(reduce
|
||||
(fn (acc t) (if (nil? acc) (if (get t :proved) t acc) acc))
|
||||
nil
|
||||
trace)))
|
||||
|
||||
(define
|
||||
mod/proved-rules
|
||||
(fn
|
||||
(trace)
|
||||
(reduce
|
||||
(fn
|
||||
(acc t)
|
||||
(if (get t :proved) (append acc (list (get t :rule))) acc))
|
||||
(list)
|
||||
trace)))
|
||||
|
||||
(define
|
||||
mod/trace-row
|
||||
(fn
|
||||
(t)
|
||||
(str
|
||||
(if (get t :proved) "[fires] " "[ - ] ")
|
||||
(get t :rule)
|
||||
" → "
|
||||
(get t :action))))
|
||||
|
||||
(define
|
||||
mod/trace-report
|
||||
(fn (trace) (mod/join-with "\n" (map mod/trace-row trace))))
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user