Compare commits
159 Commits
loops/fed-
...
loops/comm
| Author | SHA1 | Date | |
|---|---|---|---|
| a5ac0818c2 | |||
| 57066a9ed0 | |||
| f71af498cf | |||
| 79fa28e55d | |||
| a0f3a1177e | |||
| 29955831be | |||
| 35957d779f | |||
| 25f3734eab | |||
| d446562ed1 | |||
| 9f8e4d995d | |||
| 4c8e732803 | |||
| 9437f99e28 | |||
| 98f5e1bf14 | |||
| 538b8a53e0 | |||
| 7e732b1933 | |||
| 200b93c1f6 | |||
| 84d5732b38 | |||
| a37a158d01 | |||
| 739e743918 | |||
| c19f658cf2 | |||
| 2f75ab11fc | |||
| 9cfca1d008 | |||
| 82fbf01bb3 | |||
| 3e90c780e9 | |||
| 0f6dbdfc7d | |||
| 62a1485302 | |||
| 3cbf33d2d2 | |||
| 329b3c4903 | |||
| 4e521e3d7a | |||
| a00439da6e | |||
| 8e16ba6b04 | |||
| 919bd961d1 | |||
| b43901d297 | |||
| ecdaeea223 | |||
| 4be6988963 | |||
| 1c7b602978 | |||
| 90c2a57975 | |||
| 68c8e39508 | |||
| 92addf5146 | |||
| 8292607e38 | |||
| bf65de7b24 | |||
| 3764b62206 | |||
| 062a76e64f | |||
| aff7d1e84f | |||
| b0874b1282 | |||
| 156d6f12ec | |||
| c2d628e9c3 | |||
| 03da8d4328 | |||
| aabb950256 | |||
| a6864178c3 | |||
| 314cc37030 | |||
| 50eb7079e5 | |||
| c3668e4461 | |||
| b80cc32363 | |||
| 01be84b5d8 | |||
| 1902cce57f | |||
| 2b47b2925c | |||
| e53a292f1a | |||
| 3d2c1d94f2 | |||
| d9b9da3843 | |||
| 102c806451 | |||
| 0a1b89c975 | |||
| 779a592614 | |||
| 2ea87796a1 | |||
| 0e6ba55647 | |||
| ee9851c063 | |||
| c1d24eb9b3 | |||
| f4f34c1d33 | |||
| 16cb727406 | |||
| f8722b3b08 | |||
| e1f802cfff | |||
| ff537bfba2 | |||
| 6e825e1283 | |||
| 8dfc987095 | |||
| 97c7623743 | |||
| 1e4cf25015 | |||
| e896deffc8 | |||
| 72174941aa | |||
| 9c4a5d1913 | |||
| f91ac82434 | |||
| 5136249ae5 | |||
| 6fc61147a8 | |||
| 40be9cd074 | |||
| 0122c41ecb | |||
| 58656b03e4 | |||
| b0feb7b01b | |||
| a979297959 | |||
| 37226cf6eb | |||
| 15c97119e4 | |||
| 50a7f31a39 | |||
| e762cc2e32 | |||
| 915f51b2b6 | |||
| 4674620d7e | |||
| f3da3b975a | |||
| 9261d69cc5 | |||
| 1731476dc6 | |||
| 65cbdb8387 | |||
| fe47334e52 | |||
| e7501bdf8f | |||
| 91ffba9975 | |||
| c3a0727645 | |||
| 1b94082a71 | |||
| 57184daaee | |||
| d9e2627b89 | |||
| bcabed6bce | |||
| 5098a8f015 | |||
| 9fe5c9044d | |||
| c6f397c3d9 | |||
| f553d5b0aa | |||
| 14486dd78f | |||
| 9036ce3400 | |||
| 8c91b34264 | |||
| a7902df365 | |||
| 459427512d | |||
| c50f5d5155 | |||
| f52ad1fac6 | |||
| 219e2fcfe7 | |||
| 1d3021d206 | |||
| fa99652970 | |||
| 4807bc9c58 | |||
| b693854dc4 | |||
| 674d8115b8 | |||
| 99f8f37ff8 | |||
| 9ed58bd0fc | |||
| ab04ec1cf7 | |||
| a019aa1edc | |||
| 1340c2626b | |||
| ff9abe3ae6 | |||
| 21bb17e4a6 | |||
| 4bd9262060 | |||
| 5b4a8be689 | |||
| 9f4c6787e4 | |||
| 5e27a7f0c9 | |||
| 86ddaf255c | |||
| 6c3b7d1cf9 | |||
| 2404a593bd | |||
| 44fb231391 | |||
| 171a08a2f8 | |||
| ba41f8a580 | |||
| 5f6d62f45b | |||
| ad21776002 | |||
| 4922b6e987 | |||
| 632e06d3cf | |||
| 48379e04bc | |||
| a94ffa0feb | |||
| 9acdbcb8d8 | |||
| 8ba66e0dc9 | |||
| 503bdf12d6 | |||
| e64d72f554 | |||
| e1c5fdae53 | |||
| 728a91e49f | |||
| 750035d543 | |||
| 976c6dd0ef | |||
| c1baca2e4e | |||
| 65467c232b | |||
| e60c74f8c3 | |||
| fe614fc531 | |||
| 4fc73a97f4 | |||
| 0f7444e0d5 |
@@ -1 +1 @@
|
|||||||
{"sessionId":"31c80255-eb92-43e4-8997-84ad84e27326","pid":90960,"procStart":"564684","acquiredAt":1777049890282}
|
{"sessionId":"bf20a443-9df8-4cb9-932e-8c6f4c4625c2","pid":1303602,"procStart":"253831081","acquiredAt":1779865895644}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"sx-tree": {
|
"sx-tree": {
|
||||||
"type": "stdio",
|
"type": "stdio",
|
||||||
"command": "./hosts/ocaml/_build/default/bin/mcp_tree.exe"
|
"command": "/root/rose-ash/hosts/ocaml/_build/default/bin/mcp_tree.exe"
|
||||||
},
|
},
|
||||||
"rose-ash-services": {
|
"rose-ash-services": {
|
||||||
"type": "stdio",
|
"type": "stdio",
|
||||||
|
|||||||
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
|
#!/usr/bin/env bash
|
||||||
# lib/apl/conformance.sh — run APL test suites, emit scoreboard.json + scoreboard.md.
|
# lib/apl/conformance.sh — APL conformance via the shared guest driver.
|
||||||
|
# Config lives in lib/apl/conformance.conf (MODE=counters). Override the binary
|
||||||
set -uo pipefail
|
# with SX_SERVER=path/to/sx_server.exe bash lib/apl/conformance.sh
|
||||||
cd "$(git rev-parse --show-toplevel)"
|
exec bash "$(dirname "$0")/../guest/conformance.sh" "$(dirname "$0")/conformance.conf" "$@"
|
||||||
|
|
||||||
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 ]
|
|
||||||
|
|||||||
@@ -9,9 +9,9 @@
|
|||||||
"system": {"pass": 13, "fail": 0},
|
"system": {"pass": 13, "fail": 0},
|
||||||
"idioms": {"pass": 64, "fail": 0},
|
"idioms": {"pass": 64, "fail": 0},
|
||||||
"eval-ops": {"pass": 14, "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_fail": 0,
|
||||||
"total": 450
|
"total": 562
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ _Generated by `lib/apl/conformance.sh`_
|
|||||||
| system | 13 | 0 | 13 |
|
| system | 13 | 0 | 13 |
|
||||||
| idioms | 64 | 0 | 64 |
|
| idioms | 64 | 0 | 64 |
|
||||||
| eval-ops | 14 | 0 | 14 |
|
| eval-ops | 14 | 0 | 14 |
|
||||||
| pipeline | 40 | 0 | 40 |
|
| pipeline | 152 | 0 | 152 |
|
||||||
| **Total** | **450** | **0** | **450** |
|
| **Total** | **562** | **0** | **562** |
|
||||||
|
|
||||||
## Notes
|
## 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)))))
|
||||||
56
lib/commerce/api.sx
Normal file
56
lib/commerce/api.sx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
;; lib/commerce/api.sx — public commerce surface.
|
||||||
|
;;
|
||||||
|
;; A session bundles a pricing context with a cart: {:ctx CTX :cart CART}.
|
||||||
|
;; All operations are pure and return a new session. The total and the
|
||||||
|
;; per-line breakdown are deterministic functions of (ctx, cart).
|
||||||
|
;;
|
||||||
|
;; commerce-checkout is a Phase-3 stub — the order lifecycle is a durable
|
||||||
|
;; flow that suspends at the SumUp payment boundary.
|
||||||
|
|
||||||
|
(define commerce-session (fn (ctx) {:cart empty-cart :ctx ctx}))
|
||||||
|
|
||||||
|
(define commerce-ctx (fn (sess) (get sess :ctx)))
|
||||||
|
(define commerce-cart (fn (sess) (get sess :cart)))
|
||||||
|
(define commerce-lines (fn (sess) (cart-lines (get sess :cart))))
|
||||||
|
(define commerce-count (fn (sess) (cart-count (get sess :cart))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
commerce-add
|
||||||
|
(fn
|
||||||
|
(sess sku variant qty)
|
||||||
|
(assoc sess :cart (cart-add (get sess :cart) sku variant qty))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
commerce-remove
|
||||||
|
(fn
|
||||||
|
(sess sku variant)
|
||||||
|
(assoc sess :cart (cart-remove (get sess :cart) sku variant))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
commerce-set-qty
|
||||||
|
(fn
|
||||||
|
(sess sku variant qty)
|
||||||
|
(assoc sess :cart (cart-set-qty (get sess :cart) sku variant qty))))
|
||||||
|
|
||||||
|
;; True when the sku exists in the session's catalog snapshot.
|
||||||
|
(define
|
||||||
|
commerce-can-add?
|
||||||
|
(fn (sess sku) (catalog-has? (ctx-catalog (get sess :ctx)) sku)))
|
||||||
|
|
||||||
|
(define
|
||||||
|
commerce-total
|
||||||
|
(fn (sess) (cart-total (get sess :ctx) (get sess :cart))))
|
||||||
|
|
||||||
|
;; Per-line audit breakdown — the "which line contributed what" view.
|
||||||
|
(define
|
||||||
|
line-detail
|
||||||
|
(fn (ctx line) (let ((cat (ctx-catalog ctx))) {:sku (line-sku line) :unit (line-unit-price cat (line-sku line) (line-variant line)) :qty (line-qty line) :variant (line-variant line) :extended (line-extended cat line) :tax (line-tax ctx line)})))
|
||||||
|
|
||||||
|
(define
|
||||||
|
commerce-explain
|
||||||
|
(fn
|
||||||
|
(sess)
|
||||||
|
(map (fn (l) (line-detail (get sess :ctx) l)) (get sess :cart))))
|
||||||
|
|
||||||
|
;; Phase 3 — order lifecycle flow (reserve -> pay -> fulfil) lands here.
|
||||||
|
(define commerce-checkout (fn (sess) {:note "order lifecycle flow lands in Phase 3" :phase 3 :status :not-implemented}))
|
||||||
86
lib/commerce/cart.sx
Normal file
86
lib/commerce/cart.sx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
;; lib/commerce/cart.sx — cart as an ordered list of line items.
|
||||||
|
;;
|
||||||
|
;; A cart is a native list of lines; a line is (list sku variant qty).
|
||||||
|
;; All operations are pure: they return a new cart, never mutate. Line
|
||||||
|
;; order is insertion order (stable) so totals are reproducible.
|
||||||
|
;;
|
||||||
|
;; cart-lineo is the relational view — because a line *is* a (sku variant qty)
|
||||||
|
;; tuple, membero queries the cart directly, forward or backward.
|
||||||
|
|
||||||
|
(define empty-cart (list))
|
||||||
|
|
||||||
|
(define make-line (fn (sku variant qty) (list sku variant qty)))
|
||||||
|
(define line-sku (fn (l) (nth l 0)))
|
||||||
|
(define line-variant (fn (l) (nth l 1)))
|
||||||
|
(define line-qty (fn (l) (nth l 2)))
|
||||||
|
|
||||||
|
(define
|
||||||
|
same-line?
|
||||||
|
(fn
|
||||||
|
(l sku variant)
|
||||||
|
(and (= (line-sku l) sku) (= (line-variant l) variant))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
cart-qty
|
||||||
|
(fn
|
||||||
|
(cart sku variant)
|
||||||
|
(let
|
||||||
|
((m (filter (fn (l) (same-line? l sku variant)) cart)))
|
||||||
|
(if (empty? m) 0 (line-qty (first m))))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
cart-remove
|
||||||
|
(fn
|
||||||
|
(cart sku variant)
|
||||||
|
(filter (fn (l) (not (same-line? l sku variant))) cart)))
|
||||||
|
|
||||||
|
;; Add qty units; merges into an existing (sku,variant) line in place,
|
||||||
|
;; otherwise appends a new line at the end.
|
||||||
|
(define
|
||||||
|
cart-add
|
||||||
|
(fn
|
||||||
|
(cart sku variant qty)
|
||||||
|
(let
|
||||||
|
((existing (cart-qty cart sku variant)))
|
||||||
|
(if
|
||||||
|
(= existing 0)
|
||||||
|
(append cart (list (make-line sku variant qty)))
|
||||||
|
(map
|
||||||
|
(fn
|
||||||
|
(l)
|
||||||
|
(if
|
||||||
|
(same-line? l sku variant)
|
||||||
|
(make-line sku variant (+ existing qty))
|
||||||
|
l))
|
||||||
|
cart)))))
|
||||||
|
|
||||||
|
;; Set the absolute quantity; qty <= 0 removes the line.
|
||||||
|
(define
|
||||||
|
cart-set-qty
|
||||||
|
(fn
|
||||||
|
(cart sku variant qty)
|
||||||
|
(if
|
||||||
|
(<= qty 0)
|
||||||
|
(cart-remove cart sku variant)
|
||||||
|
(if
|
||||||
|
(= (cart-qty cart sku variant) 0)
|
||||||
|
(append cart (list (make-line sku variant qty)))
|
||||||
|
(map
|
||||||
|
(fn
|
||||||
|
(l)
|
||||||
|
(if (same-line? l sku variant) (make-line sku variant qty) l))
|
||||||
|
cart)))))
|
||||||
|
|
||||||
|
(define cart-empty? (fn (cart) (empty? cart)))
|
||||||
|
(define cart-lines (fn (cart) cart))
|
||||||
|
(define cart-skus (fn (cart) (map line-sku cart)))
|
||||||
|
|
||||||
|
;; Total number of units across all lines.
|
||||||
|
(define
|
||||||
|
cart-count
|
||||||
|
(fn (cart) (reduce (fn (acc l) (+ acc (line-qty l))) 0 cart)))
|
||||||
|
|
||||||
|
;; Relational view of cart lines.
|
||||||
|
(define
|
||||||
|
cart-lineo
|
||||||
|
(fn (cart sku variant qty) (membero (list sku variant qty) cart)))
|
||||||
83
lib/commerce/catalog.sx
Normal file
83
lib/commerce/catalog.sx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
;; lib/commerce/catalog.sx — catalog snapshot + relational accessors.
|
||||||
|
;;
|
||||||
|
;; A catalog snapshot is an immutable dict:
|
||||||
|
;; {:products (list (list sku price class) ...)
|
||||||
|
;; :variants (list (list sku variant delta) ...)
|
||||||
|
;; :stock (list (list sku variant qty) ...)}
|
||||||
|
;;
|
||||||
|
;; Money is integer minor units (pence/cents). class is a keyword product
|
||||||
|
;; class consumed later by tax and promotion relations. delta is a signed
|
||||||
|
;; price adjustment for a variant; qty is on-hand stock for (sku,variant).
|
||||||
|
;;
|
||||||
|
;; Accessor relations take the snapshot as the first argument and are fully
|
||||||
|
;; multidirectional: (producto cat "widget" p c) binds p,c forward;
|
||||||
|
;; (producto cat s 1000 c) enumerates every sku priced 1000 backward.
|
||||||
|
|
||||||
|
(define empty-catalog {:products (list) :stock (list) :variants (list)})
|
||||||
|
|
||||||
|
(define make-catalog (fn (products variants stock) {:products products :stock stock :variants variants}))
|
||||||
|
|
||||||
|
(define cat-products (fn (cat) (get cat :products)))
|
||||||
|
(define cat-variants (fn (cat) (get cat :variants)))
|
||||||
|
(define cat-stock (fn (cat) (get cat :stock)))
|
||||||
|
|
||||||
|
;; --- core fact relations ---
|
||||||
|
|
||||||
|
(define
|
||||||
|
producto
|
||||||
|
(fn
|
||||||
|
(cat sku price class)
|
||||||
|
(membero (list sku price class) (get cat :products))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
varianto
|
||||||
|
(fn
|
||||||
|
(cat sku variant delta)
|
||||||
|
(membero (list sku variant delta) (get cat :variants))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
stocko
|
||||||
|
(fn
|
||||||
|
(cat sku variant qty)
|
||||||
|
(membero (list sku variant qty) (get cat :stock))))
|
||||||
|
|
||||||
|
;; --- derived relations ---
|
||||||
|
|
||||||
|
(define
|
||||||
|
priceo
|
||||||
|
(fn (cat sku price) (fresh (c) (producto cat sku price c))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
classo
|
||||||
|
(fn (cat sku class) (fresh (p) (producto cat sku p class))))
|
||||||
|
|
||||||
|
;; Effective unit price of a (sku,variant): base + variant delta.
|
||||||
|
(define
|
||||||
|
unit-priceo
|
||||||
|
(fn
|
||||||
|
(cat sku variant price)
|
||||||
|
(fresh
|
||||||
|
(base delta)
|
||||||
|
(priceo cat sku base)
|
||||||
|
(varianto cat sku variant delta)
|
||||||
|
(pluso-i base delta price))))
|
||||||
|
|
||||||
|
;; --- deterministic lookups (first solution under fixed fact order) ---
|
||||||
|
|
||||||
|
(define
|
||||||
|
catalog-price
|
||||||
|
(fn
|
||||||
|
(cat sku)
|
||||||
|
(let
|
||||||
|
((rs (run 1 p (priceo cat sku p))))
|
||||||
|
(if (empty? rs) nil (first rs)))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
catalog-class
|
||||||
|
(fn
|
||||||
|
(cat sku)
|
||||||
|
(let
|
||||||
|
((rs (run 1 c (classo cat sku c))))
|
||||||
|
(if (empty? rs) nil (first rs)))))
|
||||||
|
|
||||||
|
(define catalog-has? (fn (cat sku) (not (nil? (catalog-price cat sku)))))
|
||||||
133
lib/commerce/conformance.sh
Executable file
133
lib/commerce/conformance.sh
Executable file
@@ -0,0 +1,133 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# lib/commerce/conformance.sh — run commerce test suites in one sx_server
|
||||||
|
# process per suite, emit scoreboard.json + scoreboard.md.
|
||||||
|
#
|
||||||
|
# commerce-on-sx builds pricing/promotion as miniKanren relations, so every
|
||||||
|
# suite loads the miniKanren stack first, then the commerce modules.
|
||||||
|
|
||||||
|
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=(catalog cart price api promo stack quote ledger)
|
||||||
|
|
||||||
|
OUT_JSON="lib/commerce/scoreboard.json"
|
||||||
|
OUT_MD="lib/commerce/scoreboard.md"
|
||||||
|
|
||||||
|
run_suite() {
|
||||||
|
local suite=$1
|
||||||
|
local file="lib/commerce/tests/${suite}.sx"
|
||||||
|
local TMP
|
||||||
|
TMP=$(mktemp)
|
||||||
|
cat > "$TMP" << EPOCHS
|
||||||
|
(epoch 1)
|
||||||
|
(load "spec/stdlib.sx")
|
||||||
|
(load "lib/r7rs.sx")
|
||||||
|
(load "lib/guest/match.sx")
|
||||||
|
(load "lib/minikanren/unify.sx")
|
||||||
|
(load "lib/minikanren/stream.sx")
|
||||||
|
(load "lib/minikanren/goals.sx")
|
||||||
|
(load "lib/minikanren/fresh.sx")
|
||||||
|
(load "lib/minikanren/conde.sx")
|
||||||
|
(load "lib/minikanren/run.sx")
|
||||||
|
(load "lib/minikanren/relations.sx")
|
||||||
|
(load "lib/minikanren/project.sx")
|
||||||
|
(load "lib/minikanren/intarith.sx")
|
||||||
|
(load "lib/minikanren/matche.sx")
|
||||||
|
(load "lib/minikanren/defrel.sx")
|
||||||
|
(load "lib/persist/event.sx")
|
||||||
|
(load "lib/persist/backend.sx")
|
||||||
|
(load "lib/persist/log.sx")
|
||||||
|
(load "lib/persist/kv.sx")
|
||||||
|
(load "lib/persist/idempotency.sx")
|
||||||
|
(load "lib/commerce/catalog.sx")
|
||||||
|
(load "lib/commerce/cart.sx")
|
||||||
|
(load "lib/commerce/price.sx")
|
||||||
|
(load "lib/commerce/api.sx")
|
||||||
|
(load "lib/commerce/promo.sx")
|
||||||
|
(load "lib/commerce/stack.sx")
|
||||||
|
(load "lib/commerce/quote.sx")
|
||||||
|
(load "lib/commerce/ledger.sx")
|
||||||
|
(epoch 2)
|
||||||
|
(eval "(define ct-pass 0)")
|
||||||
|
(eval "(define ct-fail 0)")
|
||||||
|
(eval "(define ct-fails (list))")
|
||||||
|
(eval "(define commerce-test (fn (name got expected) (if (= got expected) (set! ct-pass (+ ct-pass 1)) (begin (set! ct-fail (+ ct-fail 1)) (append! ct-fails name)))))")
|
||||||
|
(epoch 3)
|
||||||
|
(load "${file}")
|
||||||
|
(epoch 4)
|
||||||
|
(eval "(list ct-pass ct-fail)")
|
||||||
|
(eval "ct-fails")
|
||||||
|
EPOCHS
|
||||||
|
|
||||||
|
local OUTPUT
|
||||||
|
OUTPUT=$(timeout 300 "$SX_SERVER" < "$TMP" 2>/dev/null)
|
||||||
|
rm -f "$TMP"
|
||||||
|
|
||||||
|
# The (list ct-pass ct-fail) result follows its (ok-len 2 N) ack line.
|
||||||
|
local LINE
|
||||||
|
LINE=$(echo "$OUTPUT" | grep -oE '^\([0-9]+ [0-9]+\)$' | tail -1)
|
||||||
|
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 commerce 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
|
||||||
|
|
||||||
|
{
|
||||||
|
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"
|
||||||
|
|
||||||
|
{
|
||||||
|
printf '# commerce Conformance Scoreboard\n\n'
|
||||||
|
printf '_Generated by `lib/commerce/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))"
|
||||||
|
} > "$OUT_MD"
|
||||||
|
|
||||||
|
echo "Wrote $OUT_JSON and $OUT_MD" >&2
|
||||||
|
echo "Total: $TOTAL_PASS pass, $TOTAL_FAIL fail" >&2
|
||||||
|
|
||||||
|
[ "$TOTAL_FAIL" -eq 0 ]
|
||||||
176
lib/commerce/ledger.sx
Normal file
176
lib/commerce/ledger.sx
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
;; lib/commerce/ledger.sx — the order ledger as a persist event stream.
|
||||||
|
;;
|
||||||
|
;; Each order is an append-only stream "order/<id>" in a persist backend.
|
||||||
|
;; Order state is never stored directly — it is a projection (fold) over the
|
||||||
|
;; events, so the ledger is the single source of truth and replays identically.
|
||||||
|
;;
|
||||||
|
;; Lifecycle events:
|
||||||
|
;; :created quote snapshot {:subtotal :discount :tax :total :codes ...}
|
||||||
|
;; :reserved stock reserved
|
||||||
|
;; :paid {:amount :ref} — recorded idempotently on the payment ref
|
||||||
|
;; :fulfilled order shipped/delivered
|
||||||
|
;; :cancelled / :refunded
|
||||||
|
;;
|
||||||
|
;; Idempotency: the SumUp webhook can fire twice for one payment. order-pay
|
||||||
|
;; uses persist/append-once keyed by the payment ref, so a replayed webhook
|
||||||
|
;; yields the SAME :paid event without double-recording. Reconciliation then
|
||||||
|
;; detects genuine mismatches (paid != ordered) across the whole ledger.
|
||||||
|
|
||||||
|
(define order-stream (fn (order-id) (str "order/" order-id)))
|
||||||
|
|
||||||
|
;; --- writes ---
|
||||||
|
|
||||||
|
(define
|
||||||
|
order-create
|
||||||
|
(fn
|
||||||
|
(b order-id at quote)
|
||||||
|
(persist/append b (order-stream order-id) :created at quote)))
|
||||||
|
|
||||||
|
(define
|
||||||
|
order-reserve
|
||||||
|
(fn
|
||||||
|
(b order-id at data)
|
||||||
|
(persist/append b (order-stream order-id) :reserved at data)))
|
||||||
|
|
||||||
|
;; Idempotent on payment ref — a replayed webhook does not double-record.
|
||||||
|
(define
|
||||||
|
order-pay
|
||||||
|
(fn
|
||||||
|
(b order-id ref at amount)
|
||||||
|
(persist/append-once b (order-stream order-id) ref :paid at {:amount amount :ref ref})))
|
||||||
|
|
||||||
|
(define
|
||||||
|
order-fulfil
|
||||||
|
(fn
|
||||||
|
(b order-id at data)
|
||||||
|
(persist/append b (order-stream order-id) :fulfilled at data)))
|
||||||
|
|
||||||
|
(define
|
||||||
|
order-cancel
|
||||||
|
(fn
|
||||||
|
(b order-id at reason)
|
||||||
|
(persist/append b (order-stream order-id) :cancelled at {:reason reason})))
|
||||||
|
|
||||||
|
(define
|
||||||
|
order-refund
|
||||||
|
(fn
|
||||||
|
(b order-id ref at amount)
|
||||||
|
(persist/append-once
|
||||||
|
b
|
||||||
|
(order-stream order-id)
|
||||||
|
(str "refund/" ref)
|
||||||
|
:refunded at
|
||||||
|
{:amount amount :ref ref})))
|
||||||
|
|
||||||
|
;; --- reads ---
|
||||||
|
|
||||||
|
(define
|
||||||
|
order-events
|
||||||
|
(fn (b order-id) (persist/read b (order-stream order-id))))
|
||||||
|
|
||||||
|
;; --- projections over an event list ---
|
||||||
|
|
||||||
|
(define
|
||||||
|
order-status-of
|
||||||
|
(fn
|
||||||
|
(events)
|
||||||
|
(reduce
|
||||||
|
(fn
|
||||||
|
(st e)
|
||||||
|
(let
|
||||||
|
((t (persist/event-type e)))
|
||||||
|
(cond
|
||||||
|
((= t :created) :pending)
|
||||||
|
((= t :reserved) :reserved)
|
||||||
|
((= t :paid) :paid)
|
||||||
|
((= t :fulfilled) :fulfilled)
|
||||||
|
((= t :cancelled) :cancelled)
|
||||||
|
((= t :refunded) :refunded)
|
||||||
|
(:else st))))
|
||||||
|
:new events)))
|
||||||
|
|
||||||
|
(define
|
||||||
|
order-total-of
|
||||||
|
(fn
|
||||||
|
(events)
|
||||||
|
(let
|
||||||
|
((created (filter (fn (e) (= (persist/event-type e) :created)) events)))
|
||||||
|
(if
|
||||||
|
(empty? created)
|
||||||
|
0
|
||||||
|
(get (persist/event-data (first created)) :total)))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
order-paid-amount-of
|
||||||
|
(fn
|
||||||
|
(events)
|
||||||
|
(reduce
|
||||||
|
(fn
|
||||||
|
(acc e)
|
||||||
|
(if
|
||||||
|
(= (persist/event-type e) :paid)
|
||||||
|
(+ acc (get (persist/event-data e) :amount))
|
||||||
|
acc))
|
||||||
|
0
|
||||||
|
events)))
|
||||||
|
|
||||||
|
(define
|
||||||
|
order-refunded-amount-of
|
||||||
|
(fn
|
||||||
|
(events)
|
||||||
|
(reduce
|
||||||
|
(fn
|
||||||
|
(acc e)
|
||||||
|
(if
|
||||||
|
(= (persist/event-type e) :refunded)
|
||||||
|
(+ acc (get (persist/event-data e) :amount))
|
||||||
|
acc))
|
||||||
|
0
|
||||||
|
events)))
|
||||||
|
|
||||||
|
;; Net settled = paid - refunded. Reconciliation compares this to the order
|
||||||
|
;; total, but only once a payment exists.
|
||||||
|
(define
|
||||||
|
order-recon-of
|
||||||
|
(fn
|
||||||
|
(events)
|
||||||
|
(let
|
||||||
|
((net (- (order-paid-amount-of events) (order-refunded-amount-of events)))
|
||||||
|
(total (order-total-of events))
|
||||||
|
(has-paid (some (fn (e) (= (persist/event-type e) :paid)) events)))
|
||||||
|
(cond
|
||||||
|
((not has-paid) :unpaid)
|
||||||
|
((= net total) :ok)
|
||||||
|
((< net total) :underpaid)
|
||||||
|
(:else :overpaid)))))
|
||||||
|
|
||||||
|
;; --- backend-level helpers ---
|
||||||
|
|
||||||
|
(define
|
||||||
|
order-status
|
||||||
|
(fn (b order-id) (order-status-of (order-events b order-id))))
|
||||||
|
(define
|
||||||
|
order-total
|
||||||
|
(fn (b order-id) (order-total-of (order-events b order-id))))
|
||||||
|
(define
|
||||||
|
order-paid
|
||||||
|
(fn (b order-id) (order-paid-amount-of (order-events b order-id))))
|
||||||
|
(define
|
||||||
|
order-recon
|
||||||
|
(fn (b order-id) (order-recon-of (order-events b order-id))))
|
||||||
|
|
||||||
|
(define order-ids (fn (b) (persist/backend-streams b)))
|
||||||
|
|
||||||
|
;; Streams whose net payment does not match the order total (true mismatches,
|
||||||
|
;; excluding orders that are simply not yet paid).
|
||||||
|
(define
|
||||||
|
ledger-mismatches
|
||||||
|
(fn
|
||||||
|
(b)
|
||||||
|
(filter
|
||||||
|
(fn
|
||||||
|
(s)
|
||||||
|
(let
|
||||||
|
((r (order-recon-of (persist/read b s))))
|
||||||
|
(or (= r :underpaid) (= r :overpaid))))
|
||||||
|
(persist/backend-streams b))))
|
||||||
110
lib/commerce/price.sx
Normal file
110
lib/commerce/price.sx
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
;; lib/commerce/price.sx — deterministic subtotal + jurisdiction-relational tax.
|
||||||
|
;;
|
||||||
|
;; A pricing context bundles the inputs that make a total reproducible:
|
||||||
|
;; {:catalog CAT :tax-rules RULES :jurisdiction J :customer C}
|
||||||
|
;; Same context + same cart => identical total, every run.
|
||||||
|
;;
|
||||||
|
;; Tax is NOT a hardcoded VAT rate. Rules are facts indexed by
|
||||||
|
;; (jurisdiction, product-class, customer-class) -> rate-bps
|
||||||
|
;; where rate-bps is an integer in basis points (2000 = 20%). taxo queries
|
||||||
|
;; them multidirectionally. Money stays in integer minor units; rounding is
|
||||||
|
;; half-up per line via integer arithmetic only — never floats.
|
||||||
|
|
||||||
|
(define
|
||||||
|
make-pricing-context
|
||||||
|
(fn (catalog tax-rules jurisdiction customer) {:customer customer :jurisdiction jurisdiction :catalog catalog :tax-rules tax-rules}))
|
||||||
|
|
||||||
|
(define ctx-catalog (fn (ctx) (get ctx :catalog)))
|
||||||
|
|
||||||
|
;; --- unit + line pricing ---
|
||||||
|
|
||||||
|
;; Variant delta, defaulting to 0 when the (sku,variant) has no variant fact.
|
||||||
|
(define
|
||||||
|
variant-delta
|
||||||
|
(fn
|
||||||
|
(cat sku variant)
|
||||||
|
(let
|
||||||
|
((rs (run 1 d (varianto cat sku variant d))))
|
||||||
|
(if (empty? rs) 0 (first rs)))))
|
||||||
|
|
||||||
|
;; Effective unit price = base price + variant delta. nil if sku unknown.
|
||||||
|
(define
|
||||||
|
line-unit-price
|
||||||
|
(fn
|
||||||
|
(cat sku variant)
|
||||||
|
(let
|
||||||
|
((base (catalog-price cat sku)))
|
||||||
|
(if (nil? base) nil (+ base (variant-delta cat sku variant))))))
|
||||||
|
|
||||||
|
;; Extended (line) price = unit price * quantity.
|
||||||
|
(define
|
||||||
|
line-extended
|
||||||
|
(fn
|
||||||
|
(cat line)
|
||||||
|
(*
|
||||||
|
(line-unit-price cat (line-sku line) (line-variant line))
|
||||||
|
(line-qty line))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
cart-subtotal
|
||||||
|
(fn
|
||||||
|
(cat cart)
|
||||||
|
(reduce (fn (acc l) (+ acc (line-extended cat l))) 0 cart)))
|
||||||
|
|
||||||
|
;; --- tax (jurisdiction-relational) ---
|
||||||
|
|
||||||
|
;; rules: (list (list jurisdiction class customer bps) ...)
|
||||||
|
(define
|
||||||
|
taxo
|
||||||
|
(fn
|
||||||
|
(rules juris class cust bps)
|
||||||
|
(membero (list juris class cust bps) rules)))
|
||||||
|
|
||||||
|
;; Deterministic rate lookup; 0 when no rule matches.
|
||||||
|
(define
|
||||||
|
rate-bps
|
||||||
|
(fn
|
||||||
|
(rules juris class cust)
|
||||||
|
(let
|
||||||
|
((rs (run 1 b (taxo rules juris class cust b))))
|
||||||
|
(if (empty? rs) 0 (first rs)))))
|
||||||
|
|
||||||
|
;; Apply a basis-point rate to an integer amount, rounding half up.
|
||||||
|
(define
|
||||||
|
apply-bps
|
||||||
|
(fn (amount bps) (quotient (+ (* amount bps) 5000) 10000)))
|
||||||
|
|
||||||
|
(define
|
||||||
|
line-tax
|
||||||
|
(fn
|
||||||
|
(ctx line)
|
||||||
|
(let
|
||||||
|
((cat (ctx-catalog ctx)))
|
||||||
|
(let
|
||||||
|
((class (catalog-class cat (line-sku line))))
|
||||||
|
(apply-bps
|
||||||
|
(line-extended cat line)
|
||||||
|
(rate-bps
|
||||||
|
(get ctx :tax-rules)
|
||||||
|
(get ctx :jurisdiction)
|
||||||
|
class
|
||||||
|
(get ctx :customer)))))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
cart-tax
|
||||||
|
(fn
|
||||||
|
(ctx cart)
|
||||||
|
(reduce (fn (acc l) (+ acc (line-tax ctx l))) 0 cart)))
|
||||||
|
|
||||||
|
;; --- total ---
|
||||||
|
|
||||||
|
;; Returns {:subtotal :discounts :tax :total}. discounts is 0 until Phase 2.
|
||||||
|
(define
|
||||||
|
cart-total
|
||||||
|
(fn
|
||||||
|
(ctx cart)
|
||||||
|
(let
|
||||||
|
((cat (ctx-catalog ctx)))
|
||||||
|
(let
|
||||||
|
((sub (cart-subtotal cat cart)) (tax (cart-tax ctx cart)))
|
||||||
|
{:subtotal sub :discounts 0 :total (+ sub tax) :tax tax}))))
|
||||||
153
lib/commerce/promo.sx
Normal file
153
lib/commerce/promo.sx
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
;; lib/commerce/promo.sx — promotions as relations over the cart + catalog.
|
||||||
|
;;
|
||||||
|
;; A promo is a tagged tuple; the second field is always its code:
|
||||||
|
;; (:percent code class pct-bps) pct-bps off every line of product-class
|
||||||
|
;; (:fixed code threshold amount) amount off when subtotal >= threshold
|
||||||
|
;; (:bundle code sku n) every nth unit of sku is free
|
||||||
|
;; (:member code class pct-bps) like :percent, members only
|
||||||
|
;;
|
||||||
|
;; A ruleset is a list of promo tuples. The discount a promo yields on a
|
||||||
|
;; given cart is a pure integer computation (minor units); the *enumeration*
|
||||||
|
;; of which promos apply is relational, so promo-applieso runs forward
|
||||||
|
;; ("which codes apply and for how much?") and backward ("which code yields
|
||||||
|
;; this discount?"). Stacking precedence is a separate layer (stack.sx).
|
||||||
|
|
||||||
|
(define promo-kind (fn (p) (nth p 0)))
|
||||||
|
(define promo-code (fn (p) (nth p 1)))
|
||||||
|
|
||||||
|
;; Extended price of all lines whose sku is in product-class `class`.
|
||||||
|
(define
|
||||||
|
class-extended
|
||||||
|
(fn
|
||||||
|
(ctx cart class)
|
||||||
|
(let
|
||||||
|
((cat (ctx-catalog ctx)))
|
||||||
|
(reduce
|
||||||
|
(fn
|
||||||
|
(acc l)
|
||||||
|
(if
|
||||||
|
(= (catalog-class cat (line-sku l)) class)
|
||||||
|
(+ acc (line-extended cat l))
|
||||||
|
acc))
|
||||||
|
0
|
||||||
|
cart))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
sku-qty
|
||||||
|
(fn
|
||||||
|
(cart sku)
|
||||||
|
(reduce
|
||||||
|
(fn (acc l) (if (= (line-sku l) sku) (+ acc (line-qty l)) acc))
|
||||||
|
0
|
||||||
|
cart)))
|
||||||
|
|
||||||
|
;; --- per-type discount amounts (pure, integer minor units) ---
|
||||||
|
|
||||||
|
(define
|
||||||
|
percent-amount
|
||||||
|
(fn
|
||||||
|
(ctx cart p)
|
||||||
|
(apply-bps
|
||||||
|
(class-extended ctx cart (nth p 2))
|
||||||
|
(nth p 3))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
fixed-amount
|
||||||
|
(fn
|
||||||
|
(ctx cart p)
|
||||||
|
(let
|
||||||
|
((sub (cart-subtotal (ctx-catalog ctx) cart)))
|
||||||
|
(if
|
||||||
|
(>= sub (nth p 2))
|
||||||
|
(min (nth p 3) sub)
|
||||||
|
0))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
bundle-amount
|
||||||
|
(fn
|
||||||
|
(ctx cart p)
|
||||||
|
(let
|
||||||
|
((sku (nth p 2)) (n (nth p 3)))
|
||||||
|
(let
|
||||||
|
((free (quotient (sku-qty cart sku) n)))
|
||||||
|
(* free (catalog-price (ctx-catalog ctx) sku))))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
member-amount
|
||||||
|
(fn
|
||||||
|
(ctx cart p)
|
||||||
|
(if
|
||||||
|
(= (get ctx :customer) :member)
|
||||||
|
(apply-bps
|
||||||
|
(class-extended ctx cart (nth p 2))
|
||||||
|
(nth p 3))
|
||||||
|
0)))
|
||||||
|
|
||||||
|
;; Discount this promo yields on this cart (0 if it does not apply).
|
||||||
|
(define
|
||||||
|
promo-amount
|
||||||
|
(fn
|
||||||
|
(ctx cart p)
|
||||||
|
(let
|
||||||
|
((k (promo-kind p)))
|
||||||
|
(cond
|
||||||
|
((= k :percent) (percent-amount ctx cart p))
|
||||||
|
((= k :fixed) (fixed-amount ctx cart p))
|
||||||
|
((= k :bundle) (bundle-amount ctx cart p))
|
||||||
|
((= k :member) (member-amount ctx cart p))
|
||||||
|
(:else 0)))))
|
||||||
|
|
||||||
|
;; --- relational enumeration ---
|
||||||
|
|
||||||
|
;; (code, amount) for every promo in the ruleset (amount may be 0).
|
||||||
|
(define
|
||||||
|
promo-discounto
|
||||||
|
(fn
|
||||||
|
(ctx cart ruleset code amount)
|
||||||
|
(fresh
|
||||||
|
(p)
|
||||||
|
(membero p ruleset)
|
||||||
|
(project
|
||||||
|
(p)
|
||||||
|
(== code (promo-code p))
|
||||||
|
(== amount (promo-amount ctx cart p))))))
|
||||||
|
|
||||||
|
;; (code, amount) restricted to promos that actually apply (amount > 0).
|
||||||
|
(define
|
||||||
|
promo-applieso
|
||||||
|
(fn
|
||||||
|
(ctx cart ruleset code amount)
|
||||||
|
(fresh
|
||||||
|
(p)
|
||||||
|
(membero p ruleset)
|
||||||
|
(project
|
||||||
|
(p)
|
||||||
|
(if
|
||||||
|
(> (promo-amount ctx cart p) 0)
|
||||||
|
(mk-conj
|
||||||
|
(== code (promo-code p))
|
||||||
|
(== amount (promo-amount ctx cart p)))
|
||||||
|
fail)))))
|
||||||
|
|
||||||
|
;; --- deterministic helpers ---
|
||||||
|
|
||||||
|
;; List of (list code amount) for applicable promos, in ruleset order.
|
||||||
|
(define
|
||||||
|
applicable-promos
|
||||||
|
(fn
|
||||||
|
(ctx cart ruleset)
|
||||||
|
(run*
|
||||||
|
pair
|
||||||
|
(fresh
|
||||||
|
(code amount)
|
||||||
|
(promo-applieso ctx cart ruleset code amount)
|
||||||
|
(== pair (list code amount))))))
|
||||||
|
|
||||||
|
;; Discount for one code (0 if absent / inapplicable).
|
||||||
|
(define
|
||||||
|
promo-amount-for
|
||||||
|
(fn
|
||||||
|
(ctx cart ruleset code)
|
||||||
|
(let
|
||||||
|
((rs (run 1 a (promo-applieso ctx cart ruleset code a))))
|
||||||
|
(if (empty? rs) 0 (first rs)))))
|
||||||
36
lib/commerce/quote.sx
Normal file
36
lib/commerce/quote.sx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
;; lib/commerce/quote.sx — the final priced quote: price + promo + stacking.
|
||||||
|
;;
|
||||||
|
;; A quote is the deterministic composition of the pricing pipeline for a
|
||||||
|
;; (context, cart, ruleset, exclusions) tuple:
|
||||||
|
;; {:subtotal S :discount D :tax T :total (S - D + T) :codes (...)}
|
||||||
|
;;
|
||||||
|
;; Tax policy (explicit, for the determinism contract): tax is computed on the
|
||||||
|
;; GROSS per-line amounts (pre-discount), via price.sx cart-tax. The best
|
||||||
|
;; promo stacking reduces the payable total but not the tax base. Same inputs
|
||||||
|
;; always yield the same quote — this is the value the order flow carries.
|
||||||
|
|
||||||
|
(define
|
||||||
|
cart-quote
|
||||||
|
(fn
|
||||||
|
(ctx cart ruleset exclusions)
|
||||||
|
(let
|
||||||
|
((cat (ctx-catalog ctx)))
|
||||||
|
(let
|
||||||
|
((sub (cart-subtotal cat cart))
|
||||||
|
(disc (best-promo-discount ctx cart ruleset exclusions))
|
||||||
|
(tax (cart-tax ctx cart))
|
||||||
|
(codes (best-promo-codes ctx cart ruleset exclusions)))
|
||||||
|
{:codes codes :subtotal sub :discount disc :total (+ (- sub disc) tax) :tax tax}))))
|
||||||
|
|
||||||
|
(define quote-subtotal (fn (q) (get q :subtotal)))
|
||||||
|
(define quote-discount (fn (q) (get q :discount)))
|
||||||
|
(define quote-tax (fn (q) (get q :tax)))
|
||||||
|
(define quote-total (fn (q) (get q :total)))
|
||||||
|
(define quote-codes (fn (q) (get q :codes)))
|
||||||
|
|
||||||
|
;; Session-level convenience (a session is {:ctx :cart}).
|
||||||
|
(define
|
||||||
|
session-quote
|
||||||
|
(fn
|
||||||
|
(sess ruleset exclusions)
|
||||||
|
(cart-quote (get sess :ctx) (get sess :cart) ruleset exclusions)))
|
||||||
15
lib/commerce/scoreboard.json
Normal file
15
lib/commerce/scoreboard.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"suites": {
|
||||||
|
"catalog": {"pass": 16, "fail": 0},
|
||||||
|
"cart": {"pass": 18, "fail": 0},
|
||||||
|
"price": {"pass": 20, "fail": 0},
|
||||||
|
"api": {"pass": 12, "fail": 0},
|
||||||
|
"promo": {"pass": 17, "fail": 0},
|
||||||
|
"stack": {"pass": 16, "fail": 0},
|
||||||
|
"quote": {"pass": 13, "fail": 0},
|
||||||
|
"ledger": {"pass": 20, "fail": 0}
|
||||||
|
},
|
||||||
|
"total_pass": 132,
|
||||||
|
"total_fail": 0,
|
||||||
|
"total": 132
|
||||||
|
}
|
||||||
15
lib/commerce/scoreboard.md
Normal file
15
lib/commerce/scoreboard.md
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# commerce Conformance Scoreboard
|
||||||
|
|
||||||
|
_Generated by `lib/commerce/conformance.sh`_
|
||||||
|
|
||||||
|
| Suite | Pass | Fail | Total |
|
||||||
|
|-------|-----:|-----:|------:|
|
||||||
|
| catalog | 16 | 0 | 16 |
|
||||||
|
| cart | 18 | 0 | 18 |
|
||||||
|
| price | 20 | 0 | 20 |
|
||||||
|
| api | 12 | 0 | 12 |
|
||||||
|
| promo | 17 | 0 | 17 |
|
||||||
|
| stack | 16 | 0 | 16 |
|
||||||
|
| quote | 13 | 0 | 13 |
|
||||||
|
| ledger | 20 | 0 | 20 |
|
||||||
|
| **Total** | **132** | **0** | **132** |
|
||||||
121
lib/commerce/stack.sx
Normal file
121
lib/commerce/stack.sx
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
;; lib/commerce/stack.sx — promotion stacking precedence + best price.
|
||||||
|
;;
|
||||||
|
;; Per the miniKanren design rule, precedence is NOT encoded inside the promo
|
||||||
|
;; rules. promo.sx enumerates which promos apply; this layer enumerates which
|
||||||
|
;; *combinations* are legal and selects the best one by an explicit cost
|
||||||
|
;; function (max total discount = min price).
|
||||||
|
;;
|
||||||
|
;; Exclusivity is a list of unordered code pairs that may not both apply:
|
||||||
|
;; exclusions = (list (list code-a code-b) ...)
|
||||||
|
;; A stacking is a subset of applicable (code amount) pairs containing no
|
||||||
|
;; excluded pair. valid-stackings enumerates them; best-stacking is the
|
||||||
|
;; deterministic selection layer; stacking-by-totalo is the backward query
|
||||||
|
;; ("which legal stacking yields this total discount?").
|
||||||
|
|
||||||
|
(define
|
||||||
|
excluded-pair?
|
||||||
|
(fn
|
||||||
|
(exclusions a b)
|
||||||
|
(some
|
||||||
|
(fn
|
||||||
|
(p)
|
||||||
|
(or
|
||||||
|
(and (= (first p) a) (= (nth p 1) b))
|
||||||
|
(and (= (first p) b) (= (nth p 1) a))))
|
||||||
|
exclusions)))
|
||||||
|
|
||||||
|
;; True when no two distinct codes in the list are mutually excluded.
|
||||||
|
(define
|
||||||
|
compatible?
|
||||||
|
(fn
|
||||||
|
(exclusions codes)
|
||||||
|
(every?
|
||||||
|
(fn
|
||||||
|
(a)
|
||||||
|
(every?
|
||||||
|
(fn (b) (or (= a b) (not (excluded-pair? exclusions a b))))
|
||||||
|
codes))
|
||||||
|
codes)))
|
||||||
|
|
||||||
|
;; All subsets of xs, preserving element order. 2^n entries.
|
||||||
|
(define
|
||||||
|
powerset
|
||||||
|
(fn
|
||||||
|
(xs)
|
||||||
|
(if
|
||||||
|
(empty? xs)
|
||||||
|
(list (list))
|
||||||
|
(let
|
||||||
|
((r (powerset (cdr xs))))
|
||||||
|
(append r (map (fn (s) (cons (first xs) s)) r))))))
|
||||||
|
|
||||||
|
(define stacking-codes (fn (st) (map first st)))
|
||||||
|
|
||||||
|
(define
|
||||||
|
stacking-total
|
||||||
|
(fn
|
||||||
|
(st)
|
||||||
|
(reduce (fn (acc pair) (+ acc (nth pair 1))) 0 st)))
|
||||||
|
|
||||||
|
;; Every legal stacking of the applicable (code amount) pairs.
|
||||||
|
(define
|
||||||
|
valid-stackings
|
||||||
|
(fn
|
||||||
|
(exclusions applicable)
|
||||||
|
(filter
|
||||||
|
(fn (st) (compatible? exclusions (stacking-codes st)))
|
||||||
|
(powerset applicable))))
|
||||||
|
|
||||||
|
;; Deterministic selection: the legal stacking with the greatest total
|
||||||
|
;; discount; ties keep the earlier (stable) candidate, so the result is a
|
||||||
|
;; reproducible function of (exclusions, applicable).
|
||||||
|
(define
|
||||||
|
best-stacking
|
||||||
|
(fn
|
||||||
|
(exclusions applicable)
|
||||||
|
(reduce
|
||||||
|
(fn
|
||||||
|
(best st)
|
||||||
|
(if (> (stacking-total st) (stacking-total best)) st best))
|
||||||
|
(list)
|
||||||
|
(valid-stackings exclusions applicable))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
best-discount
|
||||||
|
(fn
|
||||||
|
(exclusions applicable)
|
||||||
|
(stacking-total (best-stacking exclusions applicable))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
best-codes
|
||||||
|
(fn
|
||||||
|
(exclusions applicable)
|
||||||
|
(stacking-codes (best-stacking exclusions applicable))))
|
||||||
|
|
||||||
|
;; Backward query: legal stackings (as code lists) whose total discount = D.
|
||||||
|
(define
|
||||||
|
stacking-by-totalo
|
||||||
|
(fn
|
||||||
|
(stackings codes total)
|
||||||
|
(fresh
|
||||||
|
(st)
|
||||||
|
(membero st stackings)
|
||||||
|
(project
|
||||||
|
(st)
|
||||||
|
(mk-conj
|
||||||
|
(== codes (stacking-codes st))
|
||||||
|
(== total (stacking-total st)))))))
|
||||||
|
|
||||||
|
;; --- top-level entry: best discount for a cart under a ruleset ---
|
||||||
|
|
||||||
|
(define
|
||||||
|
best-promo-discount
|
||||||
|
(fn
|
||||||
|
(ctx cart ruleset exclusions)
|
||||||
|
(best-discount exclusions (applicable-promos ctx cart ruleset))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
best-promo-codes
|
||||||
|
(fn
|
||||||
|
(ctx cart ruleset exclusions)
|
||||||
|
(best-codes exclusions (applicable-promos ctx cart ruleset))))
|
||||||
73
lib/commerce/tests/api.sx
Normal file
73
lib/commerce/tests/api.sx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
;; lib/commerce/tests/api.sx — public commerce session surface.
|
||||||
|
;; Uses (commerce-test name got expected) provided by conformance.sh.
|
||||||
|
|
||||||
|
(define
|
||||||
|
acat
|
||||||
|
(make-catalog
|
||||||
|
(list
|
||||||
|
(list "widget" 1000 :standard)
|
||||||
|
(list "book" 800 :zero-rated))
|
||||||
|
(list (list "widget" :small -200))
|
||||||
|
(list)))
|
||||||
|
|
||||||
|
(define
|
||||||
|
arules
|
||||||
|
(list
|
||||||
|
(list :uk :standard :guest 2000)
|
||||||
|
(list :uk :zero-rated :guest 0)))
|
||||||
|
|
||||||
|
(define actx (make-pricing-context acat arules :uk :guest))
|
||||||
|
(define sess0 (commerce-session actx))
|
||||||
|
|
||||||
|
;; --- empty session ---
|
||||||
|
|
||||||
|
(commerce-test "new-session-empty" (commerce-cart sess0) empty-cart)
|
||||||
|
(commerce-test "new-count" (commerce-count sess0) 0)
|
||||||
|
(commerce-test "new-total" (commerce-total sess0) {:subtotal 0 :discounts 0 :total 0 :tax 0})
|
||||||
|
|
||||||
|
;; --- add + total ---
|
||||||
|
|
||||||
|
(define
|
||||||
|
sess1
|
||||||
|
(commerce-add
|
||||||
|
(commerce-add sess0 "widget" :small 2)
|
||||||
|
"book"
|
||||||
|
:none 1))
|
||||||
|
|
||||||
|
(commerce-test "add-count" (commerce-count sess1) 3)
|
||||||
|
(commerce-test
|
||||||
|
"add-lines"
|
||||||
|
(commerce-lines sess1)
|
||||||
|
(list (list "widget" :small 2) (list "book" :none 1)))
|
||||||
|
(commerce-test "add-total" (commerce-total sess1) {:subtotal 2400 :discounts 0 :total 2720 :tax 320})
|
||||||
|
|
||||||
|
;; --- mutate ---
|
||||||
|
|
||||||
|
(commerce-test
|
||||||
|
"set-qty"
|
||||||
|
(commerce-lines (commerce-set-qty sess1 "widget" :small 1))
|
||||||
|
(list (list "widget" :small 1) (list "book" :none 1)))
|
||||||
|
|
||||||
|
(commerce-test
|
||||||
|
"remove"
|
||||||
|
(commerce-lines (commerce-remove sess1 "book" :none))
|
||||||
|
(list (list "widget" :small 2)))
|
||||||
|
|
||||||
|
;; --- validation ---
|
||||||
|
|
||||||
|
(commerce-test "can-add-yes" (commerce-can-add? sess0 "widget") true)
|
||||||
|
(commerce-test "can-add-no" (commerce-can-add? sess0 "ghost") false)
|
||||||
|
|
||||||
|
;; --- audit breakdown ---
|
||||||
|
|
||||||
|
(commerce-test
|
||||||
|
"explain"
|
||||||
|
(commerce-explain sess1)
|
||||||
|
(list {:sku "widget" :unit 800 :qty 2 :variant :small :extended 1600 :tax 320} {:sku "book" :unit 800 :qty 1 :variant :none :extended 800 :tax 0}))
|
||||||
|
|
||||||
|
;; --- checkout stub ---
|
||||||
|
|
||||||
|
(commerce-test
|
||||||
|
"checkout-stub"
|
||||||
|
(get (commerce-checkout sess1) :status)
|
||||||
|
:not-implemented)
|
||||||
103
lib/commerce/tests/cart.sx
Normal file
103
lib/commerce/tests/cart.sx
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
;; lib/commerce/tests/cart.sx — cart structure + line operations.
|
||||||
|
;; Uses (commerce-test name got expected) provided by conformance.sh.
|
||||||
|
|
||||||
|
;; --- add ---
|
||||||
|
|
||||||
|
(commerce-test
|
||||||
|
"add-to-empty"
|
||||||
|
(cart-add empty-cart "widget" :small 2)
|
||||||
|
(list (list "widget" :small 2)))
|
||||||
|
|
||||||
|
(commerce-test
|
||||||
|
"add-merges-same-line"
|
||||||
|
(cart-add
|
||||||
|
(cart-add empty-cart "widget" :small 2)
|
||||||
|
"widget"
|
||||||
|
:small 3)
|
||||||
|
(list (list "widget" :small 5)))
|
||||||
|
|
||||||
|
(commerce-test
|
||||||
|
"add-different-variant-separate"
|
||||||
|
(cart-add
|
||||||
|
(cart-add empty-cart "widget" :small 2)
|
||||||
|
"widget"
|
||||||
|
:large 1)
|
||||||
|
(list (list "widget" :small 2) (list "widget" :large 1)))
|
||||||
|
|
||||||
|
(commerce-test
|
||||||
|
"add-different-sku-separate"
|
||||||
|
(cart-add
|
||||||
|
(cart-add empty-cart "widget" :small 2)
|
||||||
|
"gadget"
|
||||||
|
:std 1)
|
||||||
|
(list (list "widget" :small 2) (list "gadget" :std 1)))
|
||||||
|
|
||||||
|
(commerce-test
|
||||||
|
"add-preserves-order"
|
||||||
|
(cart-skus
|
||||||
|
(cart-add
|
||||||
|
(cart-add (cart-add empty-cart "a" :v 1) "b" :v 1)
|
||||||
|
"c"
|
||||||
|
:v 1))
|
||||||
|
(list "a" "b" "c"))
|
||||||
|
|
||||||
|
;; --- qty queries ---
|
||||||
|
|
||||||
|
(define
|
||||||
|
c2
|
||||||
|
(cart-add
|
||||||
|
(cart-add empty-cart "widget" :small 2)
|
||||||
|
"gadget"
|
||||||
|
:std 4))
|
||||||
|
|
||||||
|
(commerce-test "cart-qty-found" (cart-qty c2 "widget" :small) 2)
|
||||||
|
(commerce-test "cart-qty-missing" (cart-qty c2 "widget" :large) 0)
|
||||||
|
(commerce-test "cart-count" (cart-count c2) 6)
|
||||||
|
(commerce-test "cart-empty-yes" (cart-empty? empty-cart) true)
|
||||||
|
(commerce-test "cart-empty-no" (cart-empty? c2) false)
|
||||||
|
|
||||||
|
;; --- set-qty ---
|
||||||
|
|
||||||
|
(commerce-test
|
||||||
|
"set-qty-existing"
|
||||||
|
(cart-set-qty c2 "widget" :small 10)
|
||||||
|
(list (list "widget" :small 10) (list "gadget" :std 4)))
|
||||||
|
|
||||||
|
(commerce-test
|
||||||
|
"set-qty-new-line"
|
||||||
|
(cart-set-qty empty-cart "book" :std 3)
|
||||||
|
(list (list "book" :std 3)))
|
||||||
|
|
||||||
|
(commerce-test
|
||||||
|
"set-qty-zero-removes"
|
||||||
|
(cart-set-qty c2 "widget" :small 0)
|
||||||
|
(list (list "gadget" :std 4)))
|
||||||
|
|
||||||
|
;; --- remove ---
|
||||||
|
|
||||||
|
(commerce-test
|
||||||
|
"remove-line"
|
||||||
|
(cart-remove c2 "gadget" :std)
|
||||||
|
(list (list "widget" :small 2)))
|
||||||
|
|
||||||
|
(commerce-test
|
||||||
|
"remove-missing-noop"
|
||||||
|
(cart-remove c2 "nope" :std)
|
||||||
|
(list (list "widget" :small 2) (list "gadget" :std 4)))
|
||||||
|
|
||||||
|
;; --- relational view ---
|
||||||
|
|
||||||
|
(commerce-test
|
||||||
|
"cart-lineo-forward"
|
||||||
|
(run* q (cart-lineo c2 "gadget" :std q))
|
||||||
|
(list 4))
|
||||||
|
|
||||||
|
(commerce-test
|
||||||
|
"cart-lineo-sku-by-qty-backward"
|
||||||
|
(run* sk (fresh (v) (cart-lineo c2 sk v 4)))
|
||||||
|
(list "gadget"))
|
||||||
|
|
||||||
|
(commerce-test
|
||||||
|
"cart-lineo-all-skus"
|
||||||
|
(run* sk (fresh (v q) (cart-lineo c2 sk v q)))
|
||||||
|
(list "widget" "gadget"))
|
||||||
93
lib/commerce/tests/catalog.sx
Normal file
93
lib/commerce/tests/catalog.sx
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
;; lib/commerce/tests/catalog.sx — catalog facts + relational accessors.
|
||||||
|
;; Uses (commerce-test name got expected) provided by conformance.sh.
|
||||||
|
;; Query vars avoid the name `s` (the run-n macro binds `s` internally).
|
||||||
|
|
||||||
|
(define
|
||||||
|
cat
|
||||||
|
(make-catalog
|
||||||
|
(list
|
||||||
|
(list "widget" 1000 :standard)
|
||||||
|
(list "gadget" 2500 :standard)
|
||||||
|
(list "book" 800 :zero-rated)
|
||||||
|
(list "tea" 1000 :reduced))
|
||||||
|
(list
|
||||||
|
(list "widget" :small -200)
|
||||||
|
(list "widget" :large 500)
|
||||||
|
(list "gadget" :std 0))
|
||||||
|
(list
|
||||||
|
(list "widget" :small 5)
|
||||||
|
(list "widget" :large 0)
|
||||||
|
(list "gadget" :std 12))))
|
||||||
|
|
||||||
|
;; --- forward lookups ---
|
||||||
|
|
||||||
|
(commerce-test
|
||||||
|
"price-forward"
|
||||||
|
(run* p (priceo cat "widget" p))
|
||||||
|
(list 1000))
|
||||||
|
(commerce-test
|
||||||
|
"class-forward"
|
||||||
|
(run* c (classo cat "book" c))
|
||||||
|
(list :zero-rated))
|
||||||
|
(commerce-test
|
||||||
|
"product-forward"
|
||||||
|
(run* q (fresh (p c) (producto cat "gadget" p c) (== q (list p c))))
|
||||||
|
(list (list 2500 :standard)))
|
||||||
|
|
||||||
|
;; --- backward lookups (the showcase) ---
|
||||||
|
|
||||||
|
(commerce-test
|
||||||
|
"sku-by-price-backward"
|
||||||
|
(run* sk (priceo cat sk 1000))
|
||||||
|
(list "widget" "tea"))
|
||||||
|
|
||||||
|
(commerce-test
|
||||||
|
"sku-by-class-backward"
|
||||||
|
(run* sk (classo cat sk :standard))
|
||||||
|
(list "widget" "gadget"))
|
||||||
|
|
||||||
|
(commerce-test
|
||||||
|
"all-prices"
|
||||||
|
(run* p (fresh (sk) (priceo cat sk p)))
|
||||||
|
(list 1000 2500 800 1000))
|
||||||
|
|
||||||
|
;; --- variants + effective unit price ---
|
||||||
|
|
||||||
|
(commerce-test
|
||||||
|
"variant-delta-forward"
|
||||||
|
(run* d (varianto cat "widget" :small d))
|
||||||
|
(list -200))
|
||||||
|
|
||||||
|
(commerce-test
|
||||||
|
"unit-price-small"
|
||||||
|
(run* p (unit-priceo cat "widget" :small p))
|
||||||
|
(list 800))
|
||||||
|
|
||||||
|
(commerce-test
|
||||||
|
"unit-price-large"
|
||||||
|
(run* p (unit-priceo cat "widget" :large p))
|
||||||
|
(list 1500))
|
||||||
|
|
||||||
|
(commerce-test
|
||||||
|
"variant-by-delta-backward"
|
||||||
|
(run* v (varianto cat "widget" v -200))
|
||||||
|
(list :small))
|
||||||
|
|
||||||
|
;; --- stock ---
|
||||||
|
|
||||||
|
(commerce-test
|
||||||
|
"stock-forward"
|
||||||
|
(run* q (stocko cat "widget" :small q))
|
||||||
|
(list 5))
|
||||||
|
|
||||||
|
(commerce-test
|
||||||
|
"in-stock-skus-backward"
|
||||||
|
(run* sk (fresh (v q) (stocko cat sk v q) (lto-i 0 q)))
|
||||||
|
(list "widget" "gadget"))
|
||||||
|
|
||||||
|
;; --- deterministic helpers ---
|
||||||
|
|
||||||
|
(commerce-test "catalog-price-helper" (catalog-price cat "gadget") 2500)
|
||||||
|
(commerce-test "catalog-class-helper" (catalog-class cat "tea") :reduced)
|
||||||
|
(commerce-test "catalog-has-yes" (catalog-has? cat "book") true)
|
||||||
|
(commerce-test "catalog-has-no" (catalog-has? cat "nonesuch") false)
|
||||||
80
lib/commerce/tests/ledger.sx
Normal file
80
lib/commerce/tests/ledger.sx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
;; lib/commerce/tests/ledger.sx — order ledger on persist + idempotent recon.
|
||||||
|
;; Uses (commerce-test name got expected) provided by conformance.sh.
|
||||||
|
|
||||||
|
(define q1 {:codes (list) :subtotal 1000 :discount 0 :total 1200 :tax 200})
|
||||||
|
|
||||||
|
;; --- lifecycle status projection ---
|
||||||
|
|
||||||
|
(define b1 (persist/mem-backend))
|
||||||
|
(define _c1 (order-create b1 "A1" 100 q1))
|
||||||
|
(commerce-test "status-pending" (order-status b1 "A1") :pending)
|
||||||
|
(define _r1 (order-reserve b1 "A1" 101 {:lines 2}))
|
||||||
|
(commerce-test "status-reserved" (order-status b1 "A1") :reserved)
|
||||||
|
(define _p1 (order-pay b1 "A1" "ref-1" 102 1200))
|
||||||
|
(commerce-test "status-paid" (order-status b1 "A1") :paid)
|
||||||
|
(define _f1 (order-fulfil b1 "A1" 103 {:carrier "post"}))
|
||||||
|
(commerce-test "status-fulfilled" (order-status b1 "A1") :fulfilled)
|
||||||
|
|
||||||
|
(commerce-test "total-projection" (order-total b1 "A1") 1200)
|
||||||
|
(commerce-test "paid-projection" (order-paid b1 "A1") 1200)
|
||||||
|
(commerce-test "recon-ok" (order-recon b1 "A1") :ok)
|
||||||
|
(commerce-test "event-count" (len (order-events b1 "A1")) 4)
|
||||||
|
|
||||||
|
;; --- idempotency: replayed webhook does not double-record ---
|
||||||
|
|
||||||
|
(define b2 (persist/mem-backend))
|
||||||
|
(define _c2 (order-create b2 "B1" 200 q1))
|
||||||
|
(define _p2a (order-pay b2 "B1" "sumup-9" 201 1200))
|
||||||
|
(define _p2b (order-pay b2 "B1" "sumup-9" 201 1200))
|
||||||
|
(define _p2c (order-pay b2 "B1" "sumup-9" 201 1200))
|
||||||
|
|
||||||
|
(commerce-test "idem-single-event" (len (order-events b2 "B1")) 2)
|
||||||
|
(commerce-test "idem-paid-once" (order-paid b2 "B1") 1200)
|
||||||
|
(commerce-test "idem-recon-ok" (order-recon b2 "B1") :ok)
|
||||||
|
(commerce-test "idem-same-event" (= _p2a _p2c) true)
|
||||||
|
|
||||||
|
;; --- mismatch detection ---
|
||||||
|
|
||||||
|
(define bun (persist/mem-backend))
|
||||||
|
(define _cu (order-create bun "U1" 300 q1))
|
||||||
|
(commerce-test "unpaid-recon" (order-recon bun "U1") :unpaid)
|
||||||
|
|
||||||
|
(define bup (persist/mem-backend))
|
||||||
|
(define _cp (order-create bup "U2" 300 q1))
|
||||||
|
(define _pp1 (order-pay bup "U2" "r-a" 301 1200))
|
||||||
|
(define _pp2 (order-pay bup "U2" "r-b" 302 1200))
|
||||||
|
(commerce-test "double-charge-overpaid" (order-recon bup "U2") :overpaid)
|
||||||
|
(commerce-test "double-charge-amount" (order-paid bup "U2") 2400)
|
||||||
|
|
||||||
|
(define bsh (persist/mem-backend))
|
||||||
|
(define _cs (order-create bsh "U3" 400 q1))
|
||||||
|
(define _ps (order-pay bsh "U3" "r-short" 401 1000))
|
||||||
|
(commerce-test "underpaid-recon" (order-recon bsh "U3") :underpaid)
|
||||||
|
|
||||||
|
;; --- refund (idempotent) reduces net ---
|
||||||
|
|
||||||
|
(define brf (persist/mem-backend))
|
||||||
|
(define _crf (order-create brf "R1" 500 q1))
|
||||||
|
(define _prf (order-pay brf "R1" "p-1" 501 1200))
|
||||||
|
(define _rf1 (order-refund brf "R1" "rf-1" 502 200))
|
||||||
|
(define _rf2 (order-refund brf "R1" "rf-1" 502 200))
|
||||||
|
(commerce-test "refund-idem-net" (order-recon brf "R1") :underpaid)
|
||||||
|
(commerce-test "refund-idem-events" (len (order-events brf "R1")) 3)
|
||||||
|
|
||||||
|
;; --- cross-ledger reconciliation ---
|
||||||
|
|
||||||
|
(define bL (persist/mem-backend))
|
||||||
|
(define _l1 (order-create bL "OK1" 600 q1))
|
||||||
|
(define _l1p (order-pay bL "OK1" "ok-ref" 601 1200))
|
||||||
|
(define _l2 (order-create bL "OVER1" 600 q1))
|
||||||
|
(define _l2a (order-pay bL "OVER1" "o-a" 602 1200))
|
||||||
|
(define _l2b (order-pay bL "OVER1" "o-b" 603 1200))
|
||||||
|
(define _l3 (order-create bL "UNDER1" 600 q1))
|
||||||
|
(define _l3p (order-pay bL "UNDER1" "u-ref" 604 900))
|
||||||
|
(define _l4 (order-create bL "PENDING1" 600 q1))
|
||||||
|
|
||||||
|
(commerce-test "ledger-order-count" (len (order-ids bL)) 4)
|
||||||
|
(commerce-test
|
||||||
|
"ledger-mismatches"
|
||||||
|
(sort (ledger-mismatches bL))
|
||||||
|
(sort (list (order-stream "OVER1") (order-stream "UNDER1"))))
|
||||||
100
lib/commerce/tests/price.sx
Normal file
100
lib/commerce/tests/price.sx
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
;; lib/commerce/tests/price.sx — subtotal + jurisdiction-relational tax.
|
||||||
|
;; Uses (commerce-test name got expected) provided by conformance.sh.
|
||||||
|
|
||||||
|
(define
|
||||||
|
pcat
|
||||||
|
(make-catalog
|
||||||
|
(list
|
||||||
|
(list "widget" 1000 :standard)
|
||||||
|
(list "book" 800 :zero-rated)
|
||||||
|
(list "tea" 1000 :reduced))
|
||||||
|
(list
|
||||||
|
(list "widget" :small -200)
|
||||||
|
(list "widget" :large 500))
|
||||||
|
(list)))
|
||||||
|
|
||||||
|
(define
|
||||||
|
rules
|
||||||
|
(list
|
||||||
|
(list :uk :standard :guest 2000)
|
||||||
|
(list :uk :reduced :guest 500)
|
||||||
|
(list :uk :zero-rated :guest 0)
|
||||||
|
(list :uk :standard :member 1000)
|
||||||
|
(list :ie :standard :guest 2300)))
|
||||||
|
|
||||||
|
(define gctx (make-pricing-context pcat rules :uk :guest))
|
||||||
|
(define mctx (make-pricing-context pcat rules :uk :member))
|
||||||
|
|
||||||
|
;; --- unit + line pricing ---
|
||||||
|
|
||||||
|
(commerce-test
|
||||||
|
"unit-price-variant"
|
||||||
|
(line-unit-price pcat "widget" :small)
|
||||||
|
800)
|
||||||
|
(commerce-test
|
||||||
|
"unit-price-no-variant"
|
||||||
|
(line-unit-price pcat "widget" :none)
|
||||||
|
1000)
|
||||||
|
(commerce-test "unit-price-unknown" (line-unit-price pcat "ghost" :none) nil)
|
||||||
|
(commerce-test
|
||||||
|
"line-extended"
|
||||||
|
(line-extended pcat (list "widget" :small 2))
|
||||||
|
1600)
|
||||||
|
|
||||||
|
;; --- subtotal ---
|
||||||
|
|
||||||
|
(define
|
||||||
|
cart1
|
||||||
|
(list (list "widget" :small 2) (list "book" :none 1)))
|
||||||
|
|
||||||
|
(commerce-test "subtotal" (cart-subtotal pcat cart1) 2400)
|
||||||
|
(commerce-test "subtotal-empty" (cart-subtotal pcat empty-cart) 0)
|
||||||
|
|
||||||
|
;; --- tax rate lookup (relational, both directions) ---
|
||||||
|
|
||||||
|
(commerce-test
|
||||||
|
"rate-forward"
|
||||||
|
(rate-bps rules :uk :standard :guest)
|
||||||
|
2000)
|
||||||
|
(commerce-test
|
||||||
|
"rate-missing"
|
||||||
|
(rate-bps rules :fr :standard :guest)
|
||||||
|
0)
|
||||||
|
(commerce-test
|
||||||
|
"rate-juris-by-bps-backward"
|
||||||
|
(run* j (fresh (cust) (taxo rules j :standard cust 2300)))
|
||||||
|
(list :ie))
|
||||||
|
(commerce-test
|
||||||
|
"rate-customer-by-bps-backward"
|
||||||
|
(run* cust (taxo rules :uk :standard cust 1000))
|
||||||
|
(list :member))
|
||||||
|
|
||||||
|
;; --- apply-bps rounding (half up, integer only) ---
|
||||||
|
|
||||||
|
(commerce-test "bps-exact" (apply-bps 1600 2000) 320)
|
||||||
|
(commerce-test "bps-round-up" (apply-bps 799 2000) 160)
|
||||||
|
(commerce-test "bps-zero" (apply-bps 800 0) 0)
|
||||||
|
|
||||||
|
;; --- line + cart tax ---
|
||||||
|
|
||||||
|
(commerce-test
|
||||||
|
"line-tax-standard"
|
||||||
|
(line-tax gctx (list "widget" :small 2))
|
||||||
|
320)
|
||||||
|
(commerce-test
|
||||||
|
"line-tax-zero-rated"
|
||||||
|
(line-tax gctx (list "book" :none 1))
|
||||||
|
0)
|
||||||
|
(commerce-test
|
||||||
|
"line-tax-member"
|
||||||
|
(line-tax mctx (list "widget" :small 2))
|
||||||
|
160)
|
||||||
|
(commerce-test "cart-tax-guest" (cart-tax gctx cart1) 320)
|
||||||
|
|
||||||
|
;; --- total dict (deterministic) ---
|
||||||
|
|
||||||
|
(commerce-test "total-guest" (cart-total gctx cart1) {:subtotal 2400 :discounts 0 :total 2720 :tax 320})
|
||||||
|
|
||||||
|
(commerce-test "total-member" (cart-total mctx cart1) {:subtotal 2400 :discounts 0 :total 2560 :tax 160})
|
||||||
|
|
||||||
|
(commerce-test "total-empty" (cart-total gctx empty-cart) {:subtotal 0 :discounts 0 :total 0 :tax 0})
|
||||||
142
lib/commerce/tests/promo.sx
Normal file
142
lib/commerce/tests/promo.sx
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
;; lib/commerce/tests/promo.sx — promo rules + relational enumeration.
|
||||||
|
;; Uses (commerce-test name got expected) provided by conformance.sh.
|
||||||
|
|
||||||
|
(define
|
||||||
|
pcat
|
||||||
|
(make-catalog
|
||||||
|
(list
|
||||||
|
(list "widget" 1000 :standard)
|
||||||
|
(list "book" 800 :zero-rated)
|
||||||
|
(list "tea" 1000 :reduced))
|
||||||
|
(list)
|
||||||
|
(list)))
|
||||||
|
|
||||||
|
(define gctx (make-pricing-context pcat (list) :uk :guest))
|
||||||
|
(define mctx (make-pricing-context pcat (list) :uk :member))
|
||||||
|
|
||||||
|
(define
|
||||||
|
cart
|
||||||
|
(list
|
||||||
|
(list "widget" :none 3)
|
||||||
|
(list "book" :none 1)
|
||||||
|
(list "tea" :none 6)))
|
||||||
|
|
||||||
|
(define
|
||||||
|
ruleset
|
||||||
|
(list
|
||||||
|
(list :percent "TEN" :standard 1000)
|
||||||
|
(list :fixed "FIVER" 5000 500)
|
||||||
|
(list :bundle "B3T" "tea" 3)
|
||||||
|
(list :member "MEM" :standard 1500)))
|
||||||
|
|
||||||
|
;; --- per-type amounts ---
|
||||||
|
|
||||||
|
(commerce-test
|
||||||
|
"percent-amount"
|
||||||
|
(promo-amount gctx cart (list :percent "TEN" :standard 1000))
|
||||||
|
300)
|
||||||
|
|
||||||
|
(commerce-test
|
||||||
|
"fixed-amount-met"
|
||||||
|
(promo-amount gctx cart (list :fixed "FIVER" 5000 500))
|
||||||
|
500)
|
||||||
|
|
||||||
|
(commerce-test
|
||||||
|
"fixed-amount-not-met"
|
||||||
|
(promo-amount
|
||||||
|
gctx
|
||||||
|
(list (list "widget" :none 1))
|
||||||
|
(list :fixed "FIVER" 5000 500))
|
||||||
|
0)
|
||||||
|
|
||||||
|
(commerce-test
|
||||||
|
"fixed-amount-capped"
|
||||||
|
(promo-amount
|
||||||
|
gctx
|
||||||
|
(list (list "book" :none 1))
|
||||||
|
(list :fixed "BIG" 0 9999))
|
||||||
|
800)
|
||||||
|
|
||||||
|
(commerce-test
|
||||||
|
"bundle-amount"
|
||||||
|
(promo-amount gctx cart (list :bundle "B3T" "tea" 3))
|
||||||
|
2000)
|
||||||
|
|
||||||
|
(commerce-test
|
||||||
|
"member-amount-guest"
|
||||||
|
(promo-amount gctx cart (list :member "MEM" :standard 1500))
|
||||||
|
0)
|
||||||
|
|
||||||
|
(commerce-test
|
||||||
|
"member-amount-member"
|
||||||
|
(promo-amount mctx cart (list :member "MEM" :standard 1500))
|
||||||
|
450)
|
||||||
|
|
||||||
|
;; --- relational enumeration: forward ---
|
||||||
|
|
||||||
|
(commerce-test
|
||||||
|
"discounto-all-guest"
|
||||||
|
(run*
|
||||||
|
pair
|
||||||
|
(fresh
|
||||||
|
(code amount)
|
||||||
|
(promo-discounto gctx cart ruleset code amount)
|
||||||
|
(== pair (list code amount))))
|
||||||
|
(list
|
||||||
|
(list "TEN" 300)
|
||||||
|
(list "FIVER" 500)
|
||||||
|
(list "B3T" 2000)
|
||||||
|
(list "MEM" 0)))
|
||||||
|
|
||||||
|
(commerce-test
|
||||||
|
"applicable-guest"
|
||||||
|
(applicable-promos gctx cart ruleset)
|
||||||
|
(list
|
||||||
|
(list "TEN" 300)
|
||||||
|
(list "FIVER" 500)
|
||||||
|
(list "B3T" 2000)))
|
||||||
|
|
||||||
|
(commerce-test
|
||||||
|
"applicable-member"
|
||||||
|
(applicable-promos mctx cart ruleset)
|
||||||
|
(list
|
||||||
|
(list "TEN" 300)
|
||||||
|
(list "FIVER" 500)
|
||||||
|
(list "B3T" 2000)
|
||||||
|
(list "MEM" 450)))
|
||||||
|
|
||||||
|
;; --- relational enumeration: backward (the showcase) ---
|
||||||
|
|
||||||
|
(commerce-test
|
||||||
|
"code-by-discount-2000"
|
||||||
|
(run* code (promo-applieso gctx cart ruleset code 2000))
|
||||||
|
(list "B3T"))
|
||||||
|
|
||||||
|
(commerce-test
|
||||||
|
"code-by-discount-500"
|
||||||
|
(run* code (promo-applieso gctx cart ruleset code 500))
|
||||||
|
(list "FIVER"))
|
||||||
|
|
||||||
|
(commerce-test
|
||||||
|
"code-by-discount-none"
|
||||||
|
(run* code (promo-applieso gctx cart ruleset code 9999))
|
||||||
|
(list))
|
||||||
|
|
||||||
|
;; --- deterministic helpers ---
|
||||||
|
|
||||||
|
(commerce-test
|
||||||
|
"amount-for-ten"
|
||||||
|
(promo-amount-for gctx cart ruleset "TEN")
|
||||||
|
300)
|
||||||
|
(commerce-test
|
||||||
|
"amount-for-mem-guest"
|
||||||
|
(promo-amount-for gctx cart ruleset "MEM")
|
||||||
|
0)
|
||||||
|
(commerce-test
|
||||||
|
"amount-for-mem-member"
|
||||||
|
(promo-amount-for mctx cart ruleset "MEM")
|
||||||
|
450)
|
||||||
|
(commerce-test
|
||||||
|
"amount-for-absent"
|
||||||
|
(promo-amount-for gctx cart ruleset "NOPE")
|
||||||
|
0)
|
||||||
108
lib/commerce/tests/quote.sx
Normal file
108
lib/commerce/tests/quote.sx
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
;; lib/commerce/tests/quote.sx — composed priced quote (price+promo+stacking).
|
||||||
|
;; Uses (commerce-test name got expected) provided by conformance.sh.
|
||||||
|
|
||||||
|
(define
|
||||||
|
pcat
|
||||||
|
(make-catalog
|
||||||
|
(list
|
||||||
|
(list "widget" 1000 :standard)
|
||||||
|
(list "book" 800 :zero-rated)
|
||||||
|
(list "tea" 1000 :reduced))
|
||||||
|
(list)
|
||||||
|
(list)))
|
||||||
|
|
||||||
|
(define
|
||||||
|
tax-rules
|
||||||
|
(list
|
||||||
|
(list :uk :standard :guest 2000)
|
||||||
|
(list :uk :reduced :guest 500)
|
||||||
|
(list :uk :zero-rated :guest 0)
|
||||||
|
(list :uk :standard :member 2000)
|
||||||
|
(list :uk :reduced :member 500)
|
||||||
|
(list :uk :zero-rated :member 0)))
|
||||||
|
|
||||||
|
(define gctx (make-pricing-context pcat tax-rules :uk :guest))
|
||||||
|
(define mctx (make-pricing-context pcat tax-rules :uk :member))
|
||||||
|
|
||||||
|
(define
|
||||||
|
cart
|
||||||
|
(list
|
||||||
|
(list "widget" :none 3)
|
||||||
|
(list "book" :none 1)
|
||||||
|
(list "tea" :none 6)))
|
||||||
|
|
||||||
|
(define
|
||||||
|
ruleset
|
||||||
|
(list
|
||||||
|
(list :percent "TEN" :standard 1000)
|
||||||
|
(list :percent "TWENTY" :standard 2000)
|
||||||
|
(list :fixed "FIVER" 5000 500)
|
||||||
|
(list :bundle "B3T" "tea" 3)
|
||||||
|
(list :member "MEM" :standard 2500)))
|
||||||
|
|
||||||
|
(define
|
||||||
|
exclusions
|
||||||
|
(list (list "TEN" "TWENTY") (list "TEN" "MEM") (list "TWENTY" "MEM")))
|
||||||
|
|
||||||
|
;; subtotal: 3000 + 800 + 6000 = 9800
|
||||||
|
;; tax (gross): widget 600 + tea 300 + book 0 = 900
|
||||||
|
;; guest discount: TWENTY 600 + FIVER 500 + B3T 2000 = 3100
|
||||||
|
;; guest total: 9800 - 3100 + 900 = 7600
|
||||||
|
|
||||||
|
(define gq (cart-quote gctx cart ruleset exclusions))
|
||||||
|
|
||||||
|
(commerce-test "quote-subtotal" (quote-subtotal gq) 9800)
|
||||||
|
(commerce-test "quote-tax" (quote-tax gq) 900)
|
||||||
|
(commerce-test "quote-discount-guest" (quote-discount gq) 3100)
|
||||||
|
(commerce-test "quote-total-guest" (quote-total gq) 7600)
|
||||||
|
(commerce-test
|
||||||
|
"quote-codes-guest"
|
||||||
|
(quote-codes gq)
|
||||||
|
(list "TWENTY" "FIVER" "B3T"))
|
||||||
|
|
||||||
|
(commerce-test "quote-full-guest" gq {:codes (list "TWENTY" "FIVER" "B3T") :subtotal 9800 :discount 3100 :total 7600 :tax 900})
|
||||||
|
|
||||||
|
;; member discount: MEM 750 + FIVER 500 + B3T 2000 = 3250
|
||||||
|
;; member total: 9800 - 3250 + 900 = 7450
|
||||||
|
(define mq (cart-quote mctx cart ruleset exclusions))
|
||||||
|
|
||||||
|
(commerce-test "quote-discount-member" (quote-discount mq) 3250)
|
||||||
|
(commerce-test "quote-total-member" (quote-total mq) 7450)
|
||||||
|
(commerce-test
|
||||||
|
"quote-codes-member"
|
||||||
|
(quote-codes mq)
|
||||||
|
(list "FIVER" "B3T" "MEM"))
|
||||||
|
|
||||||
|
;; --- determinism: same inputs, identical quote ---
|
||||||
|
|
||||||
|
(commerce-test
|
||||||
|
"quote-deterministic"
|
||||||
|
(=
|
||||||
|
(cart-quote gctx cart ruleset exclusions)
|
||||||
|
(cart-quote gctx cart ruleset exclusions))
|
||||||
|
true)
|
||||||
|
|
||||||
|
;; --- no promos: discount 0, total = subtotal + tax ---
|
||||||
|
|
||||||
|
(commerce-test
|
||||||
|
"quote-no-promos"
|
||||||
|
(cart-quote gctx cart (list) (list))
|
||||||
|
{:codes (list) :subtotal 9800 :discount 0 :total 10700 :tax 900})
|
||||||
|
|
||||||
|
;; --- empty cart ---
|
||||||
|
|
||||||
|
(commerce-test
|
||||||
|
"quote-empty"
|
||||||
|
(cart-quote gctx empty-cart ruleset exclusions)
|
||||||
|
{:codes (list) :subtotal 0 :discount 0 :total 0 :tax 0})
|
||||||
|
|
||||||
|
;; --- session convenience ---
|
||||||
|
|
||||||
|
(define
|
||||||
|
sess
|
||||||
|
(commerce-add (commerce-session gctx) "widget" :none 3))
|
||||||
|
|
||||||
|
(commerce-test
|
||||||
|
"session-quote"
|
||||||
|
(quote-total (session-quote sess ruleset exclusions))
|
||||||
|
3000)
|
||||||
127
lib/commerce/tests/stack.sx
Normal file
127
lib/commerce/tests/stack.sx
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
;; lib/commerce/tests/stack.sx — stacking precedence, exclusivity, best price.
|
||||||
|
;; Uses (commerce-test name got expected) provided by conformance.sh.
|
||||||
|
|
||||||
|
(define
|
||||||
|
pcat
|
||||||
|
(make-catalog
|
||||||
|
(list
|
||||||
|
(list "widget" 1000 :standard)
|
||||||
|
(list "book" 800 :zero-rated)
|
||||||
|
(list "tea" 1000 :reduced))
|
||||||
|
(list)
|
||||||
|
(list)))
|
||||||
|
|
||||||
|
(define gctx (make-pricing-context pcat (list) :uk :guest))
|
||||||
|
(define mctx (make-pricing-context pcat (list) :uk :member))
|
||||||
|
|
||||||
|
(define
|
||||||
|
cart
|
||||||
|
(list
|
||||||
|
(list "widget" :none 3)
|
||||||
|
(list "book" :none 1)
|
||||||
|
(list "tea" :none 6)))
|
||||||
|
|
||||||
|
(define
|
||||||
|
ruleset
|
||||||
|
(list
|
||||||
|
(list :percent "TEN" :standard 1000)
|
||||||
|
(list :percent "TWENTY" :standard 2000)
|
||||||
|
(list :fixed "FIVER" 5000 500)
|
||||||
|
(list :bundle "B3T" "tea" 3)
|
||||||
|
(list :member "MEM" :standard 2500)))
|
||||||
|
|
||||||
|
;; The three standard-class discounts are mutually exclusive.
|
||||||
|
(define
|
||||||
|
exclusions
|
||||||
|
(list (list "TEN" "TWENTY") (list "TEN" "MEM") (list "TWENTY" "MEM")))
|
||||||
|
|
||||||
|
;; --- exclusivity predicates ---
|
||||||
|
|
||||||
|
(commerce-test
|
||||||
|
"excluded-pair-direct"
|
||||||
|
(excluded-pair? exclusions "TEN" "TWENTY")
|
||||||
|
true)
|
||||||
|
(commerce-test
|
||||||
|
"excluded-pair-symmetric"
|
||||||
|
(excluded-pair? exclusions "TWENTY" "TEN")
|
||||||
|
true)
|
||||||
|
(commerce-test
|
||||||
|
"excluded-pair-none"
|
||||||
|
(excluded-pair? exclusions "TEN" "FIVER")
|
||||||
|
false)
|
||||||
|
(commerce-test
|
||||||
|
"compatible-yes"
|
||||||
|
(compatible? exclusions (list "FIVER" "B3T" "TWENTY"))
|
||||||
|
true)
|
||||||
|
(commerce-test
|
||||||
|
"compatible-no"
|
||||||
|
(compatible? exclusions (list "TEN" "TWENTY" "B3T"))
|
||||||
|
false)
|
||||||
|
|
||||||
|
;; --- powerset + valid stackings ---
|
||||||
|
|
||||||
|
(commerce-test
|
||||||
|
"powerset-size"
|
||||||
|
(len (powerset (list 1 2 3 4)))
|
||||||
|
16)
|
||||||
|
|
||||||
|
(define gappl (applicable-promos gctx cart ruleset))
|
||||||
|
|
||||||
|
(commerce-test "applicable-guest-count" (len gappl) 4)
|
||||||
|
|
||||||
|
;; 16 subsets minus the 4 containing both TEN and TWENTY = 12 legal.
|
||||||
|
(commerce-test
|
||||||
|
"valid-stackings-count"
|
||||||
|
(len (valid-stackings exclusions gappl))
|
||||||
|
12)
|
||||||
|
|
||||||
|
(commerce-test
|
||||||
|
"stacking-total"
|
||||||
|
(stacking-total (list (list "TWENTY" 600) (list "B3T" 2000)))
|
||||||
|
2600)
|
||||||
|
|
||||||
|
;; --- best price (deterministic selection) ---
|
||||||
|
|
||||||
|
(commerce-test
|
||||||
|
"best-discount-guest"
|
||||||
|
(best-promo-discount gctx cart ruleset exclusions)
|
||||||
|
3100)
|
||||||
|
(commerce-test
|
||||||
|
"best-codes-guest"
|
||||||
|
(best-promo-codes gctx cart ruleset exclusions)
|
||||||
|
(list "TWENTY" "FIVER" "B3T"))
|
||||||
|
|
||||||
|
;; exclusivity holds: the cheaper conflicting code is dropped.
|
||||||
|
(commerce-test
|
||||||
|
"best-excludes-ten"
|
||||||
|
(some
|
||||||
|
(fn (c) (= c "TEN"))
|
||||||
|
(best-promo-codes gctx cart ruleset exclusions))
|
||||||
|
false)
|
||||||
|
|
||||||
|
;; --- member vs guest ---
|
||||||
|
|
||||||
|
(commerce-test
|
||||||
|
"best-discount-member"
|
||||||
|
(best-promo-discount mctx cart ruleset exclusions)
|
||||||
|
3250)
|
||||||
|
(commerce-test
|
||||||
|
"best-codes-member"
|
||||||
|
(best-promo-codes mctx cart ruleset exclusions)
|
||||||
|
(list "FIVER" "B3T" "MEM"))
|
||||||
|
|
||||||
|
;; --- best price backward query (the showcase) ---
|
||||||
|
|
||||||
|
(commerce-test
|
||||||
|
"stacking-by-total-backward"
|
||||||
|
(run*
|
||||||
|
codes
|
||||||
|
(stacking-by-totalo (valid-stackings exclusions gappl) codes 3100))
|
||||||
|
(list (list "TWENTY" "FIVER" "B3T")))
|
||||||
|
|
||||||
|
;; --- edge: no applicable promos ---
|
||||||
|
|
||||||
|
(commerce-test
|
||||||
|
"best-empty"
|
||||||
|
(best-promo-discount gctx empty-cart ruleset exclusions)
|
||||||
|
0)
|
||||||
@@ -1561,7 +1561,66 @@
|
|||||||
(er-register-pure-bif! "crypto" "hash" 2 er-bif-crypto-hash)
|
(er-register-pure-bif! "crypto" "hash" 2 er-bif-crypto-hash)
|
||||||
(er-register-pure-bif! "cid" "from_bytes" 1 er-bif-cid-from-bytes)
|
(er-register-pure-bif! "cid" "from_bytes" 1 er-bif-cid-from-bytes)
|
||||||
(er-register-pure-bif! "cid" "to_string" 1 er-bif-cid-to-string)
|
(er-register-pure-bif! "cid" "to_string" 1 er-bif-cid-to-string)
|
||||||
|
|
||||||
|
;; ── binary_to_list / list_to_binary (Step 3b — term codec) ──────
|
||||||
|
;; Standard Erlang semantics:
|
||||||
|
;; binary_to_list(<<B1,B2,...>>) -> [B1, B2, ...] (Erlang cons of ints)
|
||||||
|
;; list_to_binary(IoList) -> <<...>> (flattens nested
|
||||||
|
;; iolists; elements are byte ints 0-255 or binaries)
|
||||||
|
;; Bad arg / out-of-range byte / non-iolist element -> error:badarg.
|
||||||
|
|
||||||
|
(define er-bif-binary-to-list
|
||||||
|
(fn (vs)
|
||||||
|
(let ((v (nth vs 0)))
|
||||||
|
(cond
|
||||||
|
(not (er-binary? v))
|
||||||
|
(raise (er-mk-error-marker (er-mk-atom "badarg")))
|
||||||
|
:else
|
||||||
|
(let ((bs (get v :bytes)) (out (er-mk-nil)))
|
||||||
|
(for-each
|
||||||
|
(fn (i)
|
||||||
|
(set! out (er-mk-cons (nth bs (- (- (len bs) 1) i)) out)))
|
||||||
|
(range 0 (len bs)))
|
||||||
|
out)))))
|
||||||
|
|
||||||
|
;; Walk an Erlang iolist, appending bytes to `acc` (a mutable SX list).
|
||||||
|
;; Accepts: nil, cons-of-X, binary, integer in 0..255. Anything else
|
||||||
|
;; signals failure by setting (nth fail 0) to true.
|
||||||
|
(define er-iolist-walk!
|
||||||
|
(fn (v acc fail)
|
||||||
|
(cond
|
||||||
|
(nth fail 0) nil
|
||||||
|
(er-nil? v) nil
|
||||||
|
(er-cons? v)
|
||||||
|
(do (er-iolist-walk! (get v :head) acc fail)
|
||||||
|
(er-iolist-walk! (get v :tail) acc fail))
|
||||||
|
(er-binary? v)
|
||||||
|
(for-each
|
||||||
|
(fn (i) (append! acc (nth (get v :bytes) i)))
|
||||||
|
(range 0 (len (get v :bytes))))
|
||||||
|
(= (type-of v) "number")
|
||||||
|
(cond
|
||||||
|
(and (>= v 0) (<= v 255)) (append! acc v)
|
||||||
|
:else (set-nth! fail 0 true))
|
||||||
|
:else (set-nth! fail 0 true))))
|
||||||
|
|
||||||
|
(define er-bif-list-to-binary
|
||||||
|
(fn (vs)
|
||||||
|
(let ((v (nth vs 0)) (acc (list)) (fail (list false)))
|
||||||
|
(cond
|
||||||
|
(not (or (er-nil? v) (er-cons? v) (er-binary? v)))
|
||||||
|
(raise (er-mk-error-marker (er-mk-atom "badarg")))
|
||||||
|
:else
|
||||||
|
(do
|
||||||
|
(er-iolist-walk! v acc fail)
|
||||||
|
(cond
|
||||||
|
(nth fail 0)
|
||||||
|
(raise (er-mk-error-marker (er-mk-atom "badarg")))
|
||||||
|
:else (er-mk-binary acc)))))))
|
||||||
|
|
||||||
(er-register-bif! "file" "list_dir" 1 er-bif-file-list-dir)
|
(er-register-bif! "file" "list_dir" 1 er-bif-file-list-dir)
|
||||||
|
(er-register-pure-bif! "erlang" "binary_to_list" 1 er-bif-binary-to-list)
|
||||||
|
(er-register-pure-bif! "erlang" "list_to_binary" 1 er-bif-list-to-binary)
|
||||||
(er-mk-atom "ok")))
|
(er-mk-atom "ok")))
|
||||||
|
|
||||||
;; Register everything at load time.
|
;; Register everything at load time.
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
{
|
{
|
||||||
"language": "erlang",
|
"language": "erlang",
|
||||||
"total_pass": 729,
|
"total_pass": 761,
|
||||||
"total": 729,
|
"total": 761,
|
||||||
"suites": [
|
"suites": [
|
||||||
{"name":"tokenize","pass":62,"total":62,"status":"ok"},
|
{"name":"tokenize","pass":62,"total":62,"status":"ok"},
|
||||||
{"name":"parse","pass":52,"total":52,"status":"ok"},
|
{"name":"parse","pass":52,"total":52,"status":"ok"},
|
||||||
{"name":"eval","pass":385,"total":385,"status":"ok"},
|
{"name":"eval","pass":408,"total":408,"status":"ok"},
|
||||||
{"name":"runtime","pass":93,"total":93,"status":"ok"},
|
{"name":"runtime","pass":93,"total":93,"status":"ok"},
|
||||||
{"name":"ring","pass":4,"total":4,"status":"ok"},
|
{"name":"ring","pass":4,"total":4,"status":"ok"},
|
||||||
{"name":"ping-pong","pass":4,"total":4,"status":"ok"},
|
{"name":"ping-pong","pass":4,"total":4,"status":"ok"},
|
||||||
{"name":"bank","pass":8,"total":8,"status":"ok"},
|
{"name":"bank","pass":8,"total":8,"status":"ok"},
|
||||||
{"name":"echo","pass":7,"total":7,"status":"ok"},
|
{"name":"echo","pass":7,"total":7,"status":"ok"},
|
||||||
{"name":"fib","pass":8,"total":8,"status":"ok"},
|
{"name":"fib","pass":8,"total":8,"status":"ok"},
|
||||||
{"name":"ffi","pass":28,"total":28,"status":"ok"},
|
{"name":"ffi","pass":37,"total":37,"status":"ok"},
|
||||||
{"name":"vm","pass":78,"total":78,"status":"ok"}
|
{"name":"vm","pass":78,"total":78,"status":"ok"}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
# Erlang-on-SX Scoreboard
|
# Erlang-on-SX Scoreboard
|
||||||
|
|
||||||
**Total: 729 / 729 tests passing**
|
**Total: 761 / 761 tests passing**
|
||||||
|
|
||||||
| | Suite | Pass | Total |
|
| | Suite | Pass | Total |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| ✅ | tokenize | 62 | 62 |
|
| ✅ | tokenize | 62 | 62 |
|
||||||
| ✅ | parse | 52 | 52 |
|
| ✅ | parse | 52 | 52 |
|
||||||
| ✅ | eval | 385 | 385 |
|
| ✅ | eval | 408 | 408 |
|
||||||
| ✅ | runtime | 93 | 93 |
|
| ✅ | runtime | 93 | 93 |
|
||||||
| ✅ | ring | 4 | 4 |
|
| ✅ | ring | 4 | 4 |
|
||||||
| ✅ | ping-pong | 4 | 4 |
|
| ✅ | ping-pong | 4 | 4 |
|
||||||
| ✅ | bank | 8 | 8 |
|
| ✅ | bank | 8 | 8 |
|
||||||
| ✅ | echo | 7 | 7 |
|
| ✅ | echo | 7 | 7 |
|
||||||
| ✅ | fib | 8 | 8 |
|
| ✅ | fib | 8 | 8 |
|
||||||
| ✅ | ffi | 28 | 28 |
|
| ✅ | ffi | 37 | 37 |
|
||||||
| ✅ | vm | 78 | 78 |
|
| ✅ | vm | 78 | 78 |
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -228,9 +228,10 @@
|
|||||||
(er-eval-test "tuple_size 0" (ev "tuple_size({})") 0)
|
(er-eval-test "tuple_size 0" (ev "tuple_size({})") 0)
|
||||||
|
|
||||||
;; ── BIFs: atom / list conversions ───────────────────────────────
|
;; ── BIFs: atom / list conversions ───────────────────────────────
|
||||||
(er-eval-test "atom_to_list" (ev "atom_to_list(hello)") "hello")
|
(er-eval-test "atom_to_list -> charlist length" (ev "length(atom_to_list(hello))") 5)
|
||||||
|
(er-eval-test "atom_to_list -> head $h" (ev "hd(atom_to_list(hello))") 104)
|
||||||
(er-eval-test "list_to_atom roundtrip"
|
(er-eval-test "list_to_atom roundtrip"
|
||||||
(nm (ev "list_to_atom(atom_to_list(foo))")) "foo")
|
(nm (ev "list_to_atom(atom_to_list(foo))")) "foo") ;; round-trip via charlist
|
||||||
(er-eval-test "list_to_atom fresh"
|
(er-eval-test "list_to_atom fresh"
|
||||||
(nm (ev "list_to_atom(\"bar\")")) "bar")
|
(nm (ev "list_to_atom(\"bar\")")) "bar")
|
||||||
|
|
||||||
@@ -1060,11 +1061,13 @@
|
|||||||
(er-eval-test "list_to_tuple roundtrip"
|
(er-eval-test "list_to_tuple roundtrip"
|
||||||
(ev "tuple_size(list_to_tuple([10, 20, 30]))") 3)
|
(ev "tuple_size(list_to_tuple([10, 20, 30]))") 3)
|
||||||
|
|
||||||
(er-eval-test "integer_to_list" (ev "integer_to_list(42)") "42")
|
(er-eval-test "integer_to_list -> charlist length" (ev "length(integer_to_list(42))") 2)
|
||||||
(er-eval-test "integer_to_list neg" (ev "integer_to_list(-99)") "-99")
|
(er-eval-test "integer_to_list 42 head $4" (ev "hd(integer_to_list(42))") 52)
|
||||||
|
(er-eval-test "integer_to_list neg -> charlist length" (ev "length(integer_to_list(-99))") 3)
|
||||||
|
(er-eval-test "integer_to_list -99 head $-" (ev "hd(integer_to_list(-99))") 45)
|
||||||
(er-eval-test "list_to_integer" (ev "list_to_integer(\"123\")") 123)
|
(er-eval-test "list_to_integer" (ev "list_to_integer(\"123\")") 123)
|
||||||
(er-eval-test "list_to_integer roundtrip"
|
(er-eval-test "list_to_integer roundtrip"
|
||||||
(ev "list_to_integer(integer_to_list(7))") 7)
|
(ev "list_to_integer(integer_to_list(7))") 7) ;; round-trip via charlist
|
||||||
|
|
||||||
(er-eval-test "is_function fun"
|
(er-eval-test "is_function fun"
|
||||||
(nm (ev "F = fun (X) -> X end, is_function(F)")) "true")
|
(nm (ev "F = fun (X) -> X end, is_function(F)")) "true")
|
||||||
@@ -1341,6 +1344,42 @@
|
|||||||
(get (nth (get er-rt-cap-result :elements) 4) :name) "true")
|
(get (nth (get er-rt-cap-result :elements) 4) :name) "true")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
;; ── $X char literals (Step 3b substrate fix 2026-06-04) ──────────
|
||||||
|
(er-eval-test "char $A" (ev "$A") 65)
|
||||||
|
(er-eval-test "char $a" (ev "$a") 97)
|
||||||
|
(er-eval-test "char $0 is digit, not escape-NUL" (ev "$0") 48)
|
||||||
|
(er-eval-test "char $\\n is newline (10)" (ev "$\\n") 10)
|
||||||
|
(er-eval-test "char $\\t is tab (9)" (ev "$\\t") 9)
|
||||||
|
(er-eval-test "char $\\r is CR (13)" (ev "$\\r") 13)
|
||||||
|
(er-eval-test "char $\\s is space (32)" (ev "$\\s") 32)
|
||||||
|
(er-eval-test "char $\\0 is NUL (0)" (ev "$\\0") 0)
|
||||||
|
(er-eval-test "char $\\\\ is backslash (92)" (ev "$\\\\") 92)
|
||||||
|
(er-eval-test "[$h,$i] head is 104" (ev "hd([$h, $i])") 104)
|
||||||
|
(er-eval-test "list_to_binary char-list -> bytes"
|
||||||
|
(ev "byte_size(list_to_binary([$f, $e, $d]))") 3)
|
||||||
|
(er-eval-test "list_to_binary char-list round-trip"
|
||||||
|
(nm (ev "list_to_binary([$h, $i]) =:= <<104, 105>>")) "true")
|
||||||
|
|
||||||
|
|
||||||
|
;; ── atom_to_list / integer_to_list charlist semantics (Step 3b substrate fix #3) ──
|
||||||
|
(er-eval-test "atom_to_list hd is char code"
|
||||||
|
(ev "hd(atom_to_list(hi))") 104)
|
||||||
|
(er-eval-test "atom_to_list maps to bytes via list_to_binary"
|
||||||
|
(ev "byte_size(list_to_binary(atom_to_list(hello)))") 5)
|
||||||
|
(er-eval-test "atom_to_list -> list_to_binary -> bytes content"
|
||||||
|
(nm (ev "list_to_binary(atom_to_list(ok)) =:= <<111, 107>>")) "true")
|
||||||
|
(er-eval-test "integer_to_list 12345 -> 5 chars"
|
||||||
|
(ev "length(integer_to_list(12345))") 5)
|
||||||
|
(er-eval-test "integer_to_list -> bytes -> back"
|
||||||
|
(ev "list_to_integer(integer_to_list(99999))") 99999)
|
||||||
|
(er-eval-test "list_to_atom from charlist"
|
||||||
|
(nm (ev "list_to_atom([$f, $o, $o])")) "foo")
|
||||||
|
(er-eval-test "list_to_atom from SX-string back-compat"
|
||||||
|
(nm (ev "list_to_atom(\"bar\")")) "bar")
|
||||||
|
(er-eval-test "list_to_integer from charlist"
|
||||||
|
(ev "list_to_integer([$1, $0, $0])") 100)
|
||||||
|
|
||||||
(define
|
(define
|
||||||
er-eval-test-summary
|
er-eval-test-summary
|
||||||
(str "eval " er-eval-test-pass "/" er-eval-test-count))
|
(str "eval " er-eval-test-pass "/" er-eval-test-count))
|
||||||
|
|||||||
@@ -160,6 +160,51 @@
|
|||||||
(ffi-nm (ffi-ev "element(2, file:list_dir(\"/no/such/dir/xyz\"))"))
|
(ffi-nm (ffi-ev "element(2, file:list_dir(\"/no/such/dir/xyz\"))"))
|
||||||
"enoent")
|
"enoent")
|
||||||
|
|
||||||
|
(er-ffi-test
|
||||||
|
"binary_to_list <<1,2,3>> length"
|
||||||
|
(ffi-ev "length(binary_to_list(<<1,2,3,4,5>>))")
|
||||||
|
5)
|
||||||
|
|
||||||
|
(er-ffi-test
|
||||||
|
"binary_to_list hd byte"
|
||||||
|
(ffi-ev "hd(binary_to_list(<<7,8,9>>))")
|
||||||
|
7)
|
||||||
|
|
||||||
|
(er-ffi-test
|
||||||
|
"binary_to_list empty -> []"
|
||||||
|
(ffi-nm (ffi-ev "case binary_to_list(<<>>) of [] -> empty end"))
|
||||||
|
"empty")
|
||||||
|
|
||||||
|
(er-ffi-test
|
||||||
|
"list_to_binary flat list bytes"
|
||||||
|
(ffi-ev "byte_size(list_to_binary([1,2,3]))")
|
||||||
|
3)
|
||||||
|
|
||||||
|
(er-ffi-test
|
||||||
|
"list_to_binary nested iolist"
|
||||||
|
(ffi-ev "byte_size(list_to_binary([1, <<2,3>>, [4, [5]]]))")
|
||||||
|
5)
|
||||||
|
|
||||||
|
(er-ffi-test
|
||||||
|
"list_to_binary round-trip via binary_to_list"
|
||||||
|
(ffi-nm (ffi-ev "list_to_binary(binary_to_list(<<10,20,30>>)) =:= <<10,20,30>>"))
|
||||||
|
"true")
|
||||||
|
|
||||||
|
(er-ffi-test
|
||||||
|
"binary_to_list non-binary -> error:badarg"
|
||||||
|
(ffi-nm (ffi-ev "try binary_to_list(42) catch error:badarg -> ok end"))
|
||||||
|
"ok")
|
||||||
|
|
||||||
|
(er-ffi-test
|
||||||
|
"list_to_binary out-of-range byte -> error:badarg"
|
||||||
|
(ffi-nm (ffi-ev "try list_to_binary([300]) catch error:badarg -> ok end"))
|
||||||
|
"ok")
|
||||||
|
|
||||||
|
(er-ffi-test
|
||||||
|
"list_to_binary non-iolist -> error:badarg"
|
||||||
|
(ffi-nm (ffi-ev "try list_to_binary(42) catch error:badarg -> ok end"))
|
||||||
|
"ok")
|
||||||
|
|
||||||
;; ── Still deferred (no host primitive): httpc (HTTP client, v2),
|
;; ── Still deferred (no host primitive): httpc (HTTP client, v2),
|
||||||
;; sqlite-* (v2 indexes). Assert NOT registered so a future iteration
|
;; sqlite-* (v2 indexes). Assert NOT registered so a future iteration
|
||||||
;; that wires them without updating this suite fails fast.
|
;; that wires them without updating this suite fails fast.
|
||||||
|
|||||||
@@ -229,13 +229,37 @@
|
|||||||
(= ch "$")
|
(= ch "$")
|
||||||
(do
|
(do
|
||||||
(er-advance! 1)
|
(er-advance! 1)
|
||||||
(if
|
;; Emit the char's decimal code as the integer token value
|
||||||
(and (< pos src-len) (= (er-cur) "\\"))
|
;; (was: raw "$X" text — parse-number then returned nil).
|
||||||
(do
|
(let
|
||||||
(er-advance! 1)
|
((code (cond
|
||||||
(when (< pos src-len) (er-advance! 1)))
|
(>= pos src-len) 0
|
||||||
(when (< pos src-len) (er-advance! 1)))
|
(= (er-cur) "\\")
|
||||||
(er-emit! "integer" (slice src start pos) start)
|
(do
|
||||||
|
(er-advance! 1)
|
||||||
|
(let ((esc (if (< pos src-len) (er-cur) "")))
|
||||||
|
(when (< pos src-len) (er-advance! 1))
|
||||||
|
(cond
|
||||||
|
(= esc "n") 10
|
||||||
|
(= esc "t") 9
|
||||||
|
(= esc "r") 13
|
||||||
|
(= esc "s") 32
|
||||||
|
(= esc "b") 8
|
||||||
|
(= esc "e") 27
|
||||||
|
(= esc "f") 12
|
||||||
|
(= esc "v") 11
|
||||||
|
(= esc "d") 127
|
||||||
|
(= esc "0") 0
|
||||||
|
(= esc "\\") 92
|
||||||
|
(= esc "\"") 34
|
||||||
|
(= esc "'") 39
|
||||||
|
(= esc "") 0
|
||||||
|
:else (char->integer (nth (string->list esc) 0)))))
|
||||||
|
:else
|
||||||
|
(let ((c (er-cur)))
|
||||||
|
(er-advance! 1)
|
||||||
|
(char->integer (nth (string->list c) 0))))))
|
||||||
|
(er-emit! "integer" (str code) start))
|
||||||
(scan!))
|
(scan!))
|
||||||
(er-lower? ch)
|
(er-lower? ch)
|
||||||
(do
|
(do
|
||||||
|
|||||||
@@ -107,7 +107,12 @@
|
|||||||
(let
|
(let
|
||||||
((ty (get node :type)))
|
((ty (get node :type)))
|
||||||
(cond
|
(cond
|
||||||
(= ty "integer") (parse-number (get node :value))
|
(= ty "integer")
|
||||||
|
(let ((n (parse-number (get node :value))))
|
||||||
|
(cond
|
||||||
|
(= n nil) (error (str "Erlang: invalid integer literal: "
|
||||||
|
(get node :value)))
|
||||||
|
:else (truncate n)))
|
||||||
(= ty "float") (parse-number (get node :value))
|
(= ty "float") (parse-number (get node :value))
|
||||||
(= ty "atom") (er-mk-atom (get node :value))
|
(= ty "atom") (er-mk-atom (get node :value))
|
||||||
(= ty "string") (get node :value)
|
(= ty "string") (get node :value)
|
||||||
@@ -821,16 +826,30 @@
|
|||||||
(len (get v :elements))
|
(len (get v :elements))
|
||||||
(error "Erlang: tuple_size: not a tuple")))))
|
(error "Erlang: tuple_size: not a tuple")))))
|
||||||
|
|
||||||
|
(define er-string->charlist
|
||||||
|
(fn (s)
|
||||||
|
(let ((cs (string->list s)) (out (er-mk-nil)))
|
||||||
|
(for-each
|
||||||
|
(fn (i)
|
||||||
|
(set! out (er-mk-cons
|
||||||
|
(char->integer (nth cs (- (- (len cs) 1) i)))
|
||||||
|
out)))
|
||||||
|
(range 0 (len cs)))
|
||||||
|
out)))
|
||||||
|
|
||||||
(define
|
(define
|
||||||
er-bif-atom-to-list
|
er-bif-atom-to-list
|
||||||
(fn
|
(fn
|
||||||
(vs)
|
(vs)
|
||||||
(let
|
(let
|
||||||
((v (er-bif-arg1 vs "atom_to_list")))
|
((v (er-bif-arg1 vs "atom_to_list")))
|
||||||
|
;; Standard Erlang: atom_to_list/1 returns an Erlang charlist
|
||||||
|
;; (list of integer char codes). Was: SX string of :name —
|
||||||
|
;; unusable from Erlang-land for [Char|T] / ++ / binary segments.
|
||||||
(if
|
(if
|
||||||
(er-atom? v)
|
(er-atom? v)
|
||||||
(get v :name)
|
(er-string->charlist (get v :name))
|
||||||
(error "Erlang: atom_to_list: not an atom")))))
|
(raise (er-mk-error-marker (er-mk-atom "badarg")))))))
|
||||||
|
|
||||||
(define
|
(define
|
||||||
er-bif-list-to-atom
|
er-bif-list-to-atom
|
||||||
@@ -838,10 +857,11 @@
|
|||||||
(vs)
|
(vs)
|
||||||
(let
|
(let
|
||||||
((v (er-bif-arg1 vs "list_to_atom")))
|
((v (er-bif-arg1 vs "list_to_atom")))
|
||||||
(if
|
;; Accept Erlang charlist (cons of ints) or SX string.
|
||||||
(= (type-of v) "string")
|
(let ((s (er-source-to-string v)))
|
||||||
(er-mk-atom v)
|
(cond
|
||||||
(error "Erlang: list_to_atom: not a string")))))
|
(= s nil) (raise (er-mk-error-marker (er-mk-atom "badarg")))
|
||||||
|
:else (er-mk-atom s))))))
|
||||||
|
|
||||||
;; ── lists module ─────────────────────────────────────────────────
|
;; ── lists module ─────────────────────────────────────────────────
|
||||||
(define
|
(define
|
||||||
@@ -1597,10 +1617,12 @@
|
|||||||
(vs)
|
(vs)
|
||||||
(let
|
(let
|
||||||
((v (er-bif-arg1 vs "integer_to_list")))
|
((v (er-bif-arg1 vs "integer_to_list")))
|
||||||
|
;; Standard Erlang: integer_to_list/1 returns an Erlang charlist
|
||||||
|
;; (e.g. integer_to_list(42) -> [$4, $2] -> [52, 50]).
|
||||||
(cond
|
(cond
|
||||||
(not (= (type-of v) "number"))
|
(not (= (type-of v) "number"))
|
||||||
(raise (er-mk-error-marker (er-mk-atom "badarg")))
|
(raise (er-mk-error-marker (er-mk-atom "badarg")))
|
||||||
:else (str v)))))
|
:else (er-string->charlist (str v))))))
|
||||||
|
|
||||||
(define
|
(define
|
||||||
er-bif-list-to-integer
|
er-bif-list-to-integer
|
||||||
@@ -1608,15 +1630,14 @@
|
|||||||
(vs)
|
(vs)
|
||||||
(let
|
(let
|
||||||
((v (er-bif-arg1 vs "list_to_integer")))
|
((v (er-bif-arg1 vs "list_to_integer")))
|
||||||
(cond
|
;; Accept Erlang charlist (cons of ints) or SX string.
|
||||||
(not (= (type-of v) "string"))
|
(let ((s (er-source-to-string v)))
|
||||||
(raise (er-mk-error-marker (er-mk-atom "badarg")))
|
(cond
|
||||||
:else (let
|
(= s nil) (raise (er-mk-error-marker (er-mk-atom "badarg")))
|
||||||
((n (parse-number v)))
|
:else (let ((n (parse-number s)))
|
||||||
(cond
|
(cond
|
||||||
(= n nil)
|
(= n nil) (raise (er-mk-error-marker (er-mk-atom "badarg")))
|
||||||
(raise (er-mk-error-marker (er-mk-atom "badarg")))
|
:else n)))))))
|
||||||
:else n))))))
|
|
||||||
|
|
||||||
(define
|
(define
|
||||||
er-bif-is-function
|
er-bif-is-function
|
||||||
|
|||||||
38
lib/feed/acl.sx
Normal file
38
lib/feed/acl.sx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
; feed/acl — per-viewer visibility filtering. The same candidate stream yields
|
||||||
|
; different timelines for different viewers, so ACL is applied per request and
|
||||||
|
; pre-ACL timelines are never cached.
|
||||||
|
;
|
||||||
|
; permit? is injected: (permit? viewer activity) -> bool. Wire a real acl-sx
|
||||||
|
; predicate here; feed/permit-acl? is a self-contained default that reads an
|
||||||
|
; optional :visible-to allowlist on the activity.
|
||||||
|
;
|
||||||
|
; Requires: lib/feed/normalize.sx, lib/feed/stream.sx, lib/feed/fanout.sx
|
||||||
|
; (feed/-elem?), lib/feed/rank.sx (feed/top).
|
||||||
|
|
||||||
|
; default permit: actor always sees own activity; absent/nil :visible-to is
|
||||||
|
; public; otherwise viewer must be in the allowlist.
|
||||||
|
(define
|
||||||
|
feed/permit-acl?
|
||||||
|
(fn
|
||||||
|
(viewer a)
|
||||||
|
(or
|
||||||
|
(equal? viewer (get a :actor))
|
||||||
|
(let
|
||||||
|
((allowed (get a :visible-to nil)))
|
||||||
|
(if (= allowed nil) true (feed/-elem? viewer allowed))))))
|
||||||
|
|
||||||
|
(define feed/permit-public? (fn (viewer a) true))
|
||||||
|
|
||||||
|
; filter a stream to what viewer may read
|
||||||
|
(define
|
||||||
|
feed/visible
|
||||||
|
(fn
|
||||||
|
(stream viewer permit?)
|
||||||
|
(feed/filter stream (fn (a) (permit? viewer a)))))
|
||||||
|
|
||||||
|
; the capstone: candidate stream -> ACL for viewer -> rank -> top-N
|
||||||
|
(define
|
||||||
|
feed/timeline
|
||||||
|
(fn
|
||||||
|
(stream viewer permit? score-fn n)
|
||||||
|
(feed/top (feed/visible stream viewer permit?) score-fn n)))
|
||||||
62
lib/feed/aggregate.sx
Normal file
62
lib/feed/aggregate.sx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
; feed/aggregate — group-by / counting via key-reduce. Keys must be strings
|
||||||
|
; (dict keys), so composite keys (actor, day) are joined into one string.
|
||||||
|
;
|
||||||
|
; Requires: lib/feed/normalize.sx, lib/feed/stream.sx.
|
||||||
|
|
||||||
|
; group activities into a dict: key-string -> (list of activities), order-preserving
|
||||||
|
(define
|
||||||
|
feed/group-by
|
||||||
|
(fn
|
||||||
|
(stream key-fn)
|
||||||
|
(reduce
|
||||||
|
(fn
|
||||||
|
(g a)
|
||||||
|
(let
|
||||||
|
((k (key-fn a)))
|
||||||
|
(assoc g k (append (get g k (list)) (list a)))))
|
||||||
|
{}
|
||||||
|
(feed/items stream))))
|
||||||
|
|
||||||
|
; key-string -> count
|
||||||
|
(define
|
||||||
|
feed/group-count
|
||||||
|
(fn
|
||||||
|
(stream key-fn)
|
||||||
|
(reduce
|
||||||
|
(fn
|
||||||
|
(g a)
|
||||||
|
(let
|
||||||
|
((k (key-fn a)))
|
||||||
|
(assoc g k (+ (get g k 0) 1))))
|
||||||
|
{}
|
||||||
|
(feed/items stream))))
|
||||||
|
|
||||||
|
; --- composite keys ---------------------------------------------------------
|
||||||
|
|
||||||
|
(define feed/day (fn (at window) (floor (/ at window))))
|
||||||
|
|
||||||
|
; (actor, day-bucket) -> "actor#day"
|
||||||
|
(define
|
||||||
|
feed/actor-day-key
|
||||||
|
(fn
|
||||||
|
(window)
|
||||||
|
(fn
|
||||||
|
(a)
|
||||||
|
(string-append
|
||||||
|
(get a :actor)
|
||||||
|
"#"
|
||||||
|
(number->string (feed/day (get a :at) window))))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
feed/by-actor-day
|
||||||
|
(fn (stream window) (feed/group-count stream (feed/actor-day-key window))))
|
||||||
|
|
||||||
|
; per-actor activity counts
|
||||||
|
(define
|
||||||
|
feed/actor-counts
|
||||||
|
(fn (stream) (feed/group-count stream feed/actor)))
|
||||||
|
|
||||||
|
; per-object activity counts (engagement)
|
||||||
|
(define
|
||||||
|
feed/object-counts
|
||||||
|
(fn (stream) (feed/group-count stream feed/object)))
|
||||||
24
lib/feed/api.sx
Normal file
24
lib/feed/api.sx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
; feed/api — ergonomic API over the stream layer for non-APL callers.
|
||||||
|
; A single mutable activity log; post appends, all returns it as a stream.
|
||||||
|
;
|
||||||
|
; Requires: lib/feed/normalize.sx, lib/feed/stream.sx (loaded by harness).
|
||||||
|
|
||||||
|
(define feed/-log (list))
|
||||||
|
|
||||||
|
; post — normalize then append. Returns the stored activity.
|
||||||
|
(define
|
||||||
|
feed/post
|
||||||
|
(fn
|
||||||
|
(raw)
|
||||||
|
(let
|
||||||
|
((a (feed/normalize raw)))
|
||||||
|
(begin (set! feed/-log (append feed/-log (list a))) a))))
|
||||||
|
|
||||||
|
; all — the whole log as a stream (insertion order)
|
||||||
|
(define feed/all (fn () (feed/stream feed/-log)))
|
||||||
|
|
||||||
|
; reset! — clear the log (test hygiene)
|
||||||
|
(define feed/reset! (fn () (begin (set! feed/-log (list)) nil)))
|
||||||
|
|
||||||
|
; size — number of posted activities
|
||||||
|
(define feed/size (fn () (len feed/-log)))
|
||||||
125
lib/feed/conformance.sh
Executable file
125
lib/feed/conformance.sh
Executable file
@@ -0,0 +1,125 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# lib/feed/conformance.sh — run feed 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=(basic fanout rank integration content notify home dedupe trending mute page thread)
|
||||||
|
|
||||||
|
OUT_JSON="lib/feed/scoreboard.json"
|
||||||
|
OUT_MD="lib/feed/scoreboard.md"
|
||||||
|
|
||||||
|
run_suite() {
|
||||||
|
local suite=$1
|
||||||
|
local file="lib/feed/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/feed/normalize.sx")
|
||||||
|
(load "lib/feed/stream.sx")
|
||||||
|
(load "lib/feed/api.sx")
|
||||||
|
(load "lib/feed/fanout.sx")
|
||||||
|
(load "lib/feed/dedupe.sx")
|
||||||
|
(load "lib/feed/aggregate.sx")
|
||||||
|
(load "lib/feed/rank.sx")
|
||||||
|
(load "lib/feed/acl.sx")
|
||||||
|
(load "lib/feed/fed.sx")
|
||||||
|
(load "lib/feed/content.sx")
|
||||||
|
(load "lib/feed/notify.sx")
|
||||||
|
(load "lib/feed/home.sx")
|
||||||
|
(load "lib/feed/trending.sx")
|
||||||
|
(load "lib/feed/mute.sx")
|
||||||
|
(load "lib/feed/page.sx")
|
||||||
|
(load "lib/feed/thread.sx")
|
||||||
|
(epoch 2)
|
||||||
|
(eval "(define feed-test-pass 0)")
|
||||||
|
(eval "(define feed-test-fail 0)")
|
||||||
|
(eval "(define feed-test (fn (name got expected) (if (= got expected) (set! feed-test-pass (+ feed-test-pass 1)) (set! feed-test-fail (+ feed-test-fail 1)))))")
|
||||||
|
(epoch 3)
|
||||||
|
(load "${file}")
|
||||||
|
(epoch 4)
|
||||||
|
(eval "(list feed-test-pass feed-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 feed 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 '# feed Conformance Scoreboard\n\n'
|
||||||
|
printf '_Generated by `lib/feed/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))"
|
||||||
|
} > "$OUT_MD"
|
||||||
|
|
||||||
|
echo "Wrote $OUT_JSON and $OUT_MD" >&2
|
||||||
|
echo "Total: $TOTAL_PASS pass, $TOTAL_FAIL fail" >&2
|
||||||
|
|
||||||
|
[ "$TOTAL_FAIL" -eq 0 ]
|
||||||
68
lib/feed/content.sx
Normal file
68
lib/feed/content.sx
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
; feed/content — TF-IDF relevance over activity :tags. Rare tags carry more
|
||||||
|
; signal, so an activity matching an uncommon tag ranks above one matching a
|
||||||
|
; common tag. Composes with rank.sx: feed/tfidf-score is just another scorer.
|
||||||
|
;
|
||||||
|
; Requires: lib/feed/normalize.sx, lib/feed/stream.sx, lib/feed/fanout.sx
|
||||||
|
; (feed/-distinct), lib/feed/rank.sx (feed/rank).
|
||||||
|
|
||||||
|
; document frequency: tag -> number of activities whose :tags contain it
|
||||||
|
; (a tag repeated within one activity counts once toward df)
|
||||||
|
(define
|
||||||
|
feed/tag-df
|
||||||
|
(fn
|
||||||
|
(stream)
|
||||||
|
(reduce
|
||||||
|
(fn
|
||||||
|
(df a)
|
||||||
|
(reduce
|
||||||
|
(fn (d t) (assoc d t (+ (get d t 0) 1)))
|
||||||
|
df
|
||||||
|
(feed/-distinct (get a :tags))))
|
||||||
|
{}
|
||||||
|
(feed/items stream))))
|
||||||
|
|
||||||
|
; inverse document frequency: tag -> log(N / df)
|
||||||
|
(define
|
||||||
|
feed/tag-idf
|
||||||
|
(fn
|
||||||
|
(stream)
|
||||||
|
(let
|
||||||
|
((n (feed/count stream)) (df (feed/tag-df stream)))
|
||||||
|
(reduce
|
||||||
|
(fn (idf t) (assoc idf t (log (/ n (get df t)))))
|
||||||
|
{}
|
||||||
|
(keys df)))))
|
||||||
|
|
||||||
|
; term frequency within one activity: tag -> occurrence count
|
||||||
|
(define
|
||||||
|
feed/-tf
|
||||||
|
(fn
|
||||||
|
(a)
|
||||||
|
(reduce
|
||||||
|
(fn (tf t) (assoc tf t (+ (get tf t 0) 1)))
|
||||||
|
{}
|
||||||
|
(get a :tags))))
|
||||||
|
|
||||||
|
; relevance of an activity to a query (list of tags) given precomputed idf:
|
||||||
|
; sum over query tags of tf(tag in activity) * idf(tag in corpus)
|
||||||
|
(define
|
||||||
|
feed/tfidf-score
|
||||||
|
(fn
|
||||||
|
(idf query)
|
||||||
|
(fn
|
||||||
|
(a)
|
||||||
|
(let
|
||||||
|
((tf (feed/-tf a)))
|
||||||
|
(reduce
|
||||||
|
(fn
|
||||||
|
(acc t)
|
||||||
|
(+ acc (* (get tf t 0) (get idf t 0))))
|
||||||
|
0
|
||||||
|
query)))))
|
||||||
|
|
||||||
|
; rank a stream by relevance to query tags (idf computed over the stream itself)
|
||||||
|
(define
|
||||||
|
feed/by-relevance
|
||||||
|
(fn
|
||||||
|
(stream query)
|
||||||
|
(feed/rank stream (feed/tfidf-score (feed/tag-idf stream) query))))
|
||||||
76
lib/feed/dedupe.sx
Normal file
76
lib/feed/dedupe.sx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
; feed/dedupe — collapse duplicate items, keeping first occurrence per key.
|
||||||
|
; Each verb may want its own key (see briefing): "alice posted X" keys on
|
||||||
|
; (actor verb object) — distinct per actor; "alice liked X / bob liked X"
|
||||||
|
; collapse on (verb object) so the cross-actor likes fold into one.
|
||||||
|
;
|
||||||
|
; Requires: lib/feed/normalize.sx, lib/feed/stream.sx, lib/feed/fanout.sx
|
||||||
|
; (feed/-elem? lives in fanout.sx).
|
||||||
|
|
||||||
|
; generic: dedupe a stream by key-fn, first occurrence wins (stable)
|
||||||
|
(define
|
||||||
|
feed/-dedup-by
|
||||||
|
(fn
|
||||||
|
(items key-fn)
|
||||||
|
(get
|
||||||
|
(reduce
|
||||||
|
(fn
|
||||||
|
(st x)
|
||||||
|
(let
|
||||||
|
((k (key-fn x)))
|
||||||
|
(if (feed/-elem? k (get st :seen)) st {:seen (append (get st :seen) (list k)) :out (append (get st :out) (list x))})))
|
||||||
|
{:seen (list) :out (list)}
|
||||||
|
items)
|
||||||
|
:out)))
|
||||||
|
|
||||||
|
(define
|
||||||
|
feed/dedupe
|
||||||
|
(fn
|
||||||
|
(stream key-fn)
|
||||||
|
(feed/stream (feed/-dedup-by (feed/items stream) key-fn))))
|
||||||
|
|
||||||
|
; --- keys -------------------------------------------------------------------
|
||||||
|
|
||||||
|
(define
|
||||||
|
feed/activity-key
|
||||||
|
(fn (a) (list (get a :actor) (get a :verb) (get a :object))))
|
||||||
|
|
||||||
|
; collapse cross-actor duplicates of the same verb+object (e.g. likes)
|
||||||
|
(define feed/collapse-key (fn (a) (list (get a :verb) (get a :object))))
|
||||||
|
|
||||||
|
; per-receiver inbox key — one inbox event per (receiver, actor, verb, object)
|
||||||
|
(define
|
||||||
|
feed/event-key
|
||||||
|
(fn
|
||||||
|
(ev)
|
||||||
|
(let
|
||||||
|
((a (get ev :activity)))
|
||||||
|
(list (get ev :to) (get a :actor) (get a :verb) (get a :object)))))
|
||||||
|
|
||||||
|
; verbs whose duplicates collapse across actors (reactions, not authorship).
|
||||||
|
; rebindable: callers can (set! feed/collapse-verbs ...) to tune the policy.
|
||||||
|
(define
|
||||||
|
feed/collapse-verbs
|
||||||
|
(list "like" "favourite" "follow" "boost" "repost"))
|
||||||
|
|
||||||
|
; per-verb key: collapse-verbs fold on (verb object); the rest key on
|
||||||
|
; (actor verb object).
|
||||||
|
(define
|
||||||
|
feed/smart-key
|
||||||
|
(fn
|
||||||
|
(a)
|
||||||
|
(if
|
||||||
|
(feed/-elem? (get a :verb) feed/collapse-verbs)
|
||||||
|
(feed/collapse-key a)
|
||||||
|
(feed/activity-key a))))
|
||||||
|
|
||||||
|
; --- ready-made dedupers ----------------------------------------------------
|
||||||
|
|
||||||
|
(define feed/dedupe-activities (fn (s) (feed/dedupe s feed/activity-key)))
|
||||||
|
|
||||||
|
(define feed/dedupe-collapse (fn (s) (feed/dedupe s feed/collapse-key)))
|
||||||
|
|
||||||
|
; verb-aware: reactions collapse cross-actor, posts stay distinct per actor
|
||||||
|
(define feed/dedupe-smart (fn (s) (feed/dedupe s feed/smart-key)))
|
||||||
|
|
||||||
|
; dedupe an inbox: at most one event per receiver per (actor verb object)
|
||||||
|
(define feed/dedupe-inbox (fn (inbox) (feed/dedupe inbox feed/event-key)))
|
||||||
114
lib/feed/fanout.sx
Normal file
114
lib/feed/fanout.sx
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
; feed/fanout — THE SHOWCASE. Fan activities out to followers via the APL outer
|
||||||
|
; product (∘.×). activities ∘.× audience → an (activity × follower) matrix of
|
||||||
|
; inbox events; flatten to a vector; guard-keep only real follow edges.
|
||||||
|
;
|
||||||
|
; Requires: lib/apl/runtime.sx, lib/feed/normalize.sx, lib/feed/stream.sx.
|
||||||
|
;
|
||||||
|
; NOTE: apl-outer's combiner result is run through (if (scalar? r) (disclose r) r).
|
||||||
|
; A bare dict counts as a scalar (shape ()) and disclose nils it — so the combiner
|
||||||
|
; must (enclose ...) its event dict; apl-outer then discloses it back intact.
|
||||||
|
|
||||||
|
; --- graph: {followee -> (list of followers)} -------------------------------
|
||||||
|
|
||||||
|
(define feed/followers (fn (graph user) (get graph user (list))))
|
||||||
|
|
||||||
|
; build a graph from (follower followee) edges: "follower follows followee"
|
||||||
|
(define
|
||||||
|
feed/follow-graph
|
||||||
|
(fn
|
||||||
|
(edges)
|
||||||
|
(reduce
|
||||||
|
(fn
|
||||||
|
(g e)
|
||||||
|
(let
|
||||||
|
((follower (first e)) (followee (nth e 1)))
|
||||||
|
(assoc
|
||||||
|
g
|
||||||
|
followee
|
||||||
|
(append (feed/followers g followee) (list follower)))))
|
||||||
|
{}
|
||||||
|
edges)))
|
||||||
|
|
||||||
|
; --- helpers ----------------------------------------------------------------
|
||||||
|
|
||||||
|
; unwrap an apl-scalar (has :ravel) back to its value; pass activities through
|
||||||
|
(define
|
||||||
|
feed/-val
|
||||||
|
(fn
|
||||||
|
(x)
|
||||||
|
(if (and (= (type-of x) "dict") (has-key? x :ravel)) (disclose x) x)))
|
||||||
|
|
||||||
|
(define feed/-elem? (fn (x lst) (some (fn (y) (equal? x y)) lst)))
|
||||||
|
|
||||||
|
(define
|
||||||
|
feed/-distinct
|
||||||
|
(fn
|
||||||
|
(lst)
|
||||||
|
(if
|
||||||
|
(= (len lst) 0)
|
||||||
|
(list)
|
||||||
|
(get (apl-unique (make-array (list (len lst)) lst)) :ravel))))
|
||||||
|
|
||||||
|
; rank-2 matrix -> rank-1 stream of its ravel
|
||||||
|
(define feed/-flatten (fn (arr) (feed/stream (get arr :ravel))))
|
||||||
|
|
||||||
|
; distinct receivers across the whole graph, sorted for determinism
|
||||||
|
; (dict key order is unspecified, so sort to pin audience/recipient ordering)
|
||||||
|
(define
|
||||||
|
feed/audience
|
||||||
|
(fn
|
||||||
|
(graph)
|
||||||
|
(sort
|
||||||
|
(feed/-distinct
|
||||||
|
(reduce
|
||||||
|
(fn (acc k) (append acc (feed/followers graph k)))
|
||||||
|
(list)
|
||||||
|
(keys graph))))))
|
||||||
|
|
||||||
|
; --- the outer product ------------------------------------------------------
|
||||||
|
|
||||||
|
; one (activity, follower) inbox event, enclosed so apl-outer keeps the dict
|
||||||
|
(define feed/-mk-event (fn (a f) (enclose {:activity (feed/-val a) :to (feed/-val f)})))
|
||||||
|
|
||||||
|
; keep events where :to actually follows the activity's actor
|
||||||
|
(define
|
||||||
|
feed/-edge?
|
||||||
|
(fn
|
||||||
|
(graph)
|
||||||
|
(fn
|
||||||
|
(ev)
|
||||||
|
(feed/-elem?
|
||||||
|
(get ev :to)
|
||||||
|
(feed/followers graph (get (get ev :activity) :actor))))))
|
||||||
|
|
||||||
|
; fanout — activities ∘.× audience, flatten, guard-keep real edges
|
||||||
|
(define
|
||||||
|
feed/fanout
|
||||||
|
(fn
|
||||||
|
(stream graph)
|
||||||
|
(let
|
||||||
|
((matrix (apl-outer feed/-mk-event stream (feed/stream (feed/audience graph)))))
|
||||||
|
(feed/filter (feed/-flatten matrix) (feed/-edge? graph)))))
|
||||||
|
|
||||||
|
; --- inbox queries ----------------------------------------------------------
|
||||||
|
|
||||||
|
(define
|
||||||
|
feed/inbox-for
|
||||||
|
(fn
|
||||||
|
(inbox user)
|
||||||
|
(feed/filter inbox (fn (ev) (equal? (get ev :to) user)))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
feed/recipients
|
||||||
|
(fn
|
||||||
|
(inbox)
|
||||||
|
(feed/-distinct (map (fn (ev) (get ev :to)) (feed/items inbox)))))
|
||||||
|
|
||||||
|
; the activities (unwrapped) destined for a user
|
||||||
|
(define
|
||||||
|
feed/inbox-activities
|
||||||
|
(fn
|
||||||
|
(inbox user)
|
||||||
|
(map
|
||||||
|
(fn (ev) (get ev :activity))
|
||||||
|
(feed/items (feed/inbox-for inbox user)))))
|
||||||
60
lib/feed/fed.sx
Normal file
60
lib/feed/fed.sx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
; feed/fed — federation. Outbound: a local post fans out, then splits into local
|
||||||
|
; vs remote inboxes; remote events are handed to an injected send-fn. Inbound:
|
||||||
|
; peer activities merge into the local stream, deduped. Backfill: pull peer
|
||||||
|
; history via an injected fetch-fn and merge.
|
||||||
|
;
|
||||||
|
; remote? / send-fn / fetch-fn are injected so real fed-sx transport wires in here
|
||||||
|
; without feed depending on it.
|
||||||
|
;
|
||||||
|
; Requires: lib/feed/normalize.sx, lib/feed/stream.sx, lib/feed/fanout.sx,
|
||||||
|
; lib/feed/dedupe.sx.
|
||||||
|
|
||||||
|
; --- merge / ingest ---------------------------------------------------------
|
||||||
|
|
||||||
|
(define
|
||||||
|
feed/merge
|
||||||
|
(fn (s1 s2) (feed/stream (append (feed/items s1) (feed/items s2)))))
|
||||||
|
|
||||||
|
; merge a peer stream into local, dropping (actor verb object) duplicates
|
||||||
|
(define
|
||||||
|
feed/ingest
|
||||||
|
(fn (local peer) (feed/dedupe-activities (feed/merge local peer))))
|
||||||
|
|
||||||
|
; --- inbound ----------------------------------------------------------------
|
||||||
|
|
||||||
|
; peer pushes raw activities to the local inbox; normalize + ingest
|
||||||
|
(define
|
||||||
|
feed/inbound
|
||||||
|
(fn
|
||||||
|
(local raw-activities)
|
||||||
|
(feed/ingest local (feed/stream (map feed/normalize raw-activities)))))
|
||||||
|
|
||||||
|
; backfill on subscribe: pull peer history via fetch-fn, normalize, ingest
|
||||||
|
(define
|
||||||
|
feed/backfill
|
||||||
|
(fn (local fetch-fn peer-id) (feed/inbound local (fetch-fn peer-id))))
|
||||||
|
|
||||||
|
; --- outbound ---------------------------------------------------------------
|
||||||
|
|
||||||
|
; split an inbox into local vs remote deliveries by viewer-id predicate
|
||||||
|
(define feed/partition-inbox (fn (inbox remote?) {:local (feed/filter inbox (fn (ev) (not (remote? (get ev :to))))) :remote (feed/filter inbox (fn (ev) (remote? (get ev :to))))}))
|
||||||
|
|
||||||
|
; fan a stream out over the graph, then partition by locality
|
||||||
|
(define
|
||||||
|
feed/federate
|
||||||
|
(fn
|
||||||
|
(stream graph remote?)
|
||||||
|
(feed/partition-inbox (feed/fanout stream graph) remote?)))
|
||||||
|
|
||||||
|
; deliver: hand each remote event to send-fn, return the local inbox to enqueue
|
||||||
|
(define
|
||||||
|
feed/deliver
|
||||||
|
(fn
|
||||||
|
(stream graph remote? send-fn)
|
||||||
|
(let
|
||||||
|
((parts (feed/federate stream graph remote?)))
|
||||||
|
(begin
|
||||||
|
(for-each
|
||||||
|
(fn (ev) (send-fn (get ev :to) (get ev :activity)))
|
||||||
|
(feed/items (get parts :remote)))
|
||||||
|
(get parts :local)))))
|
||||||
23
lib/feed/home.sx
Normal file
23
lib/feed/home.sx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
; feed/home — the capstone. A user's home timeline is the whole pipeline as one
|
||||||
|
; line: fan all activities out over the follow graph, take the events landing in
|
||||||
|
; the viewer's inbox, dedupe cross-posts, apply the viewer's ACL, rank, take N.
|
||||||
|
;
|
||||||
|
; Requires: fanout.sx, dedupe.sx, acl.sx (feed/timeline), rank.sx, stream.sx.
|
||||||
|
|
||||||
|
; the activities in a user's inbox, as a stream
|
||||||
|
(define
|
||||||
|
feed/inbox-stream
|
||||||
|
(fn (inbox user) (feed/stream (feed/inbox-activities inbox user))))
|
||||||
|
|
||||||
|
; fanout ∘ inbox ∘ dedupe ∘ ACL ∘ rank ∘ take
|
||||||
|
(define
|
||||||
|
feed/home
|
||||||
|
(fn
|
||||||
|
(stream graph viewer permit? score-fn n)
|
||||||
|
(feed/timeline
|
||||||
|
(feed/dedupe-activities
|
||||||
|
(feed/inbox-stream (feed/fanout stream graph) viewer))
|
||||||
|
viewer
|
||||||
|
permit?
|
||||||
|
score-fn
|
||||||
|
n)))
|
||||||
44
lib/feed/mute.sx
Normal file
44
lib/feed/mute.sx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
; feed/mute — viewer-controlled filtering. ACL (acl.sx) is author-controlled
|
||||||
|
; visibility; mute is the reader's own preference: hide muted actors or tags.
|
||||||
|
; Like ACL it is per-viewer and applied per request, never cached.
|
||||||
|
;
|
||||||
|
; Requires: lib/feed/normalize.sx, lib/feed/stream.sx, lib/feed/fanout.sx
|
||||||
|
; (feed/-elem?).
|
||||||
|
|
||||||
|
; drop activities authored by a muted actor
|
||||||
|
(define
|
||||||
|
feed/mute-actors
|
||||||
|
(fn
|
||||||
|
(stream actors)
|
||||||
|
(feed/filter
|
||||||
|
stream
|
||||||
|
(fn (a) (not (feed/-elem? (get a :actor) actors))))))
|
||||||
|
|
||||||
|
; drop activities carrying any muted tag
|
||||||
|
(define
|
||||||
|
feed/mute-tags
|
||||||
|
(fn
|
||||||
|
(stream tags)
|
||||||
|
(feed/filter
|
||||||
|
stream
|
||||||
|
(fn (a) (not (some (fn (t) (feed/-elem? t tags)) (get a :tags)))))))
|
||||||
|
|
||||||
|
; drop activities about a muted object (thread mute)
|
||||||
|
(define
|
||||||
|
feed/mute-objects
|
||||||
|
(fn
|
||||||
|
(stream objects)
|
||||||
|
(feed/filter
|
||||||
|
stream
|
||||||
|
(fn (a) (not (feed/-elem? (get a :object) objects))))))
|
||||||
|
|
||||||
|
; apply a viewer preference bag: {:mute-actors (...) :mute-tags (...) :mute-objects (...)}
|
||||||
|
(define
|
||||||
|
feed/apply-prefs
|
||||||
|
(fn
|
||||||
|
(stream prefs)
|
||||||
|
(feed/mute-objects
|
||||||
|
(feed/mute-tags
|
||||||
|
(feed/mute-actors stream (get prefs :mute-actors (list)))
|
||||||
|
(get prefs :mute-tags (list)))
|
||||||
|
(get prefs :mute-objects (list)))))
|
||||||
31
lib/feed/normalize.sx
Normal file
31
lib/feed/normalize.sx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
; feed/normalize — coerce arbitrary input into the canonical activity record.
|
||||||
|
; An activity is a small dict {:actor :verb :object :at :tags}; a stream is an
|
||||||
|
; APL vector of such dicts (see stream.sx). Extra keys on the raw input survive
|
||||||
|
; (e.g. :visible-to for ACL, peer metadata for federation) — :tags is the
|
||||||
|
; flexible bag but the record is not closed.
|
||||||
|
|
||||||
|
(define feed/activity-keys (list :actor :verb :object :at :tags))
|
||||||
|
|
||||||
|
(define
|
||||||
|
feed/normalize
|
||||||
|
(fn
|
||||||
|
(raw)
|
||||||
|
(let
|
||||||
|
((d (if (= (type-of raw) "dict") raw {})))
|
||||||
|
(merge d {:actor (get d :actor "") :object (get d :object nil) :at (get d :at 0) :tags (let ((t (get d :tags (list)))) (if (list? t) t (list t))) :verb (get d :verb "post")}))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
feed/activity
|
||||||
|
(fn (actor verb object at tags) (feed/normalize {:actor actor :object object :at at :tags tags :verb verb})))
|
||||||
|
|
||||||
|
(define feed/actor (fn (a) (get a :actor)))
|
||||||
|
(define feed/verb (fn (a) (get a :verb)))
|
||||||
|
(define feed/object (fn (a) (get a :object)))
|
||||||
|
(define feed/at (fn (a) (get a :at)))
|
||||||
|
(define feed/tags (fn (a) (get a :tags)))
|
||||||
|
|
||||||
|
(define
|
||||||
|
feed/activity?
|
||||||
|
(fn
|
||||||
|
(a)
|
||||||
|
(and (= (type-of a) "dict") (has-key? a :actor) (has-key? a :verb))))
|
||||||
45
lib/feed/notify.sx
Normal file
45
lib/feed/notify.sx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
; feed/notify — a notification feed is a thin layer over a recipient's inbox:
|
||||||
|
; the events directed at a user, optionally verb-filtered, and a digest that
|
||||||
|
; collapses "alice, bob and 1 other liked X" by (verb, object).
|
||||||
|
;
|
||||||
|
; Requires: lib/feed/normalize.sx, lib/feed/stream.sx, lib/feed/fanout.sx
|
||||||
|
; (feed/inbox-for, feed/-elem?).
|
||||||
|
|
||||||
|
; all inbox events for a user (their raw notifications)
|
||||||
|
(define feed/notifications (fn (inbox user) (feed/inbox-for inbox user)))
|
||||||
|
|
||||||
|
; restrict to notification-worthy verbs (e.g. (list "like" "reply" "follow"))
|
||||||
|
(define
|
||||||
|
feed/notify-verbs
|
||||||
|
(fn
|
||||||
|
(inbox user verbs)
|
||||||
|
(feed/filter
|
||||||
|
(feed/inbox-for inbox user)
|
||||||
|
(fn (ev) (feed/-elem? (get (get ev :activity) :verb) verbs)))))
|
||||||
|
|
||||||
|
; group key "verb|object" — deterministic, sortable
|
||||||
|
(define
|
||||||
|
feed/-notify-key
|
||||||
|
(fn
|
||||||
|
(ev)
|
||||||
|
(let
|
||||||
|
((a (get ev :activity)))
|
||||||
|
(string-append (get a :verb) "|" (get a :object)))))
|
||||||
|
|
||||||
|
; digest: one entry per (verb, object) with the distinct actors and a count,
|
||||||
|
; ordered by key for determinism.
|
||||||
|
(define
|
||||||
|
feed/notify-digest
|
||||||
|
(fn
|
||||||
|
(inbox user)
|
||||||
|
(let
|
||||||
|
((events (feed/items (feed/inbox-for inbox user))))
|
||||||
|
(let
|
||||||
|
((groups (reduce (fn (g ev) (let ((a (get ev :activity)) (k (feed/-notify-key ev))) (let ((cur (get g k {:object (get a :object) :actors (list) :verb (get a :verb)}))) (assoc g k (assoc cur :actors (append (get cur :actors) (list (get a :actor)))))))) {} events)))
|
||||||
|
(map
|
||||||
|
(fn
|
||||||
|
(k)
|
||||||
|
(let
|
||||||
|
((grp (get groups k)))
|
||||||
|
(assoc grp :count (len (get grp :actors)))))
|
||||||
|
(sort (keys groups)))))))
|
||||||
50
lib/feed/page.sx
Normal file
50
lib/feed/page.sx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
; feed/page — pagination. Offset/limit for indexed access, and cursor-based
|
||||||
|
; (by :at) for recency feeds, which is stable under inserts: a cursor is the
|
||||||
|
; :at of the last item seen, and the next page is the newest items older than it.
|
||||||
|
;
|
||||||
|
; Requires: lib/feed/stream.sx (feed/recent, feed/take, feed/filter).
|
||||||
|
|
||||||
|
; --- offset / limit ---------------------------------------------------------
|
||||||
|
|
||||||
|
(define
|
||||||
|
feed/page
|
||||||
|
(fn
|
||||||
|
(stream offset limit)
|
||||||
|
(feed/stream (take (drop (feed/items stream) offset) limit))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
feed/page-count
|
||||||
|
(fn (stream limit) (ceil (/ (feed/count stream) limit))))
|
||||||
|
|
||||||
|
; --- cursor (recency feeds) -------------------------------------------------
|
||||||
|
|
||||||
|
; activities strictly older than cursor (scroll down / load older)
|
||||||
|
(define
|
||||||
|
feed/before
|
||||||
|
(fn
|
||||||
|
(stream cursor)
|
||||||
|
(feed/filter stream (fn (a) (< (get a :at) cursor)))))
|
||||||
|
|
||||||
|
; activities strictly newer than cursor (load newer / "N new posts")
|
||||||
|
(define
|
||||||
|
feed/after
|
||||||
|
(fn
|
||||||
|
(stream cursor)
|
||||||
|
(feed/filter stream (fn (a) (> (get a :at) cursor)))))
|
||||||
|
|
||||||
|
; one page: the `limit` newest activities older than cursor, newest first
|
||||||
|
(define
|
||||||
|
feed/page-before
|
||||||
|
(fn
|
||||||
|
(stream cursor limit)
|
||||||
|
(feed/take (feed/recent (feed/before stream cursor)) limit)))
|
||||||
|
|
||||||
|
; cursor to fetch the next (older) page: :at of the last item of a page,
|
||||||
|
; or nil when the page is empty (end of feed)
|
||||||
|
(define
|
||||||
|
feed/next-cursor
|
||||||
|
(fn
|
||||||
|
(page)
|
||||||
|
(let
|
||||||
|
((items (feed/items page)))
|
||||||
|
(if (= (len items) 0) nil (get (last items) :at)))))
|
||||||
92
lib/feed/rank.sx
Normal file
92
lib/feed/rank.sx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
; feed/rank — scoring + ranking. Scorers are (activity -> number). Ranking is a
|
||||||
|
; stable two-pass grade-down: first by :at descending (the tiebreak), then by
|
||||||
|
; score descending — so ties resolve by recency, then by input order. Fully
|
||||||
|
; deterministic on ties.
|
||||||
|
;
|
||||||
|
; Requires: lib/apl/runtime.sx, lib/feed/normalize.sx, lib/feed/stream.sx.
|
||||||
|
|
||||||
|
; --- scorers ----------------------------------------------------------------
|
||||||
|
|
||||||
|
; recency: half-life decay. score = 0.5 ^ (age / half-life). at==now -> 1.0.
|
||||||
|
(define
|
||||||
|
feed/recency
|
||||||
|
(fn
|
||||||
|
(now half-life)
|
||||||
|
(fn (a) (expt 0.5 (/ (- now (get a :at)) half-life)))))
|
||||||
|
|
||||||
|
; velocity: how many of this actor's activities fall in (at-window, at] —
|
||||||
|
; a burst of recent activity scores higher.
|
||||||
|
(define
|
||||||
|
feed/velocity
|
||||||
|
(fn
|
||||||
|
(stream window)
|
||||||
|
(fn
|
||||||
|
(a)
|
||||||
|
(len
|
||||||
|
(filter
|
||||||
|
(fn
|
||||||
|
(b)
|
||||||
|
(and
|
||||||
|
(equal? (get b :actor) (get a :actor))
|
||||||
|
(<= (get b :at) (get a :at))
|
||||||
|
(> (get b :at) (- (get a :at) window))))
|
||||||
|
(feed/items stream))))))
|
||||||
|
|
||||||
|
; engagement: how many activities in the stream touch this activity's :object
|
||||||
|
(define
|
||||||
|
feed/engagement
|
||||||
|
(fn
|
||||||
|
(stream)
|
||||||
|
(fn
|
||||||
|
(a)
|
||||||
|
(len
|
||||||
|
(filter
|
||||||
|
(fn (b) (equal? (get b :object) (get a :object)))
|
||||||
|
(feed/items stream))))))
|
||||||
|
|
||||||
|
; composite: weighted sum. parts = (list (list weight scorer) ...)
|
||||||
|
(define
|
||||||
|
feed/composite
|
||||||
|
(fn
|
||||||
|
(parts)
|
||||||
|
(fn
|
||||||
|
(a)
|
||||||
|
(reduce
|
||||||
|
(fn (acc p) (+ acc (* (first p) ((nth p 1) a))))
|
||||||
|
0
|
||||||
|
parts))))
|
||||||
|
|
||||||
|
; --- ranking ----------------------------------------------------------------
|
||||||
|
|
||||||
|
; stable reorder of items by key-fn, descending (grade-down is stable)
|
||||||
|
(define
|
||||||
|
feed/-desc-by
|
||||||
|
(fn
|
||||||
|
(items key-fn)
|
||||||
|
(let
|
||||||
|
((keys (make-array (list (len items)) (map key-fn items))))
|
||||||
|
(let
|
||||||
|
((order (get (apl-grade-down keys) :ravel)))
|
||||||
|
(map (fn (i) (nth items (- i 1))) order)))))
|
||||||
|
|
||||||
|
; rank by score descending; ties -> :at descending -> input order
|
||||||
|
(define
|
||||||
|
feed/rank
|
||||||
|
(fn
|
||||||
|
(stream score-fn)
|
||||||
|
(let
|
||||||
|
((by-at (feed/-desc-by (feed/items stream) feed/at)))
|
||||||
|
(feed/stream (feed/-desc-by by-at score-fn)))))
|
||||||
|
|
||||||
|
; attach a :score to each activity (for inspection / debugging)
|
||||||
|
(define
|
||||||
|
feed/with-scores
|
||||||
|
(fn
|
||||||
|
(stream score-fn)
|
||||||
|
(feed/stream
|
||||||
|
(map (fn (a) (assoc a :score (score-fn a))) (feed/items stream)))))
|
||||||
|
|
||||||
|
; top-N ranked timeline
|
||||||
|
(define
|
||||||
|
feed/top
|
||||||
|
(fn (stream score-fn n) (feed/take (feed/rank stream score-fn) n)))
|
||||||
19
lib/feed/scoreboard.json
Normal file
19
lib/feed/scoreboard.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"suites": {
|
||||||
|
"basic": {"pass": 30, "fail": 0},
|
||||||
|
"fanout": {"pass": 29, "fail": 0},
|
||||||
|
"rank": {"pass": 24, "fail": 0},
|
||||||
|
"integration": {"pass": 22, "fail": 0},
|
||||||
|
"content": {"pass": 15, "fail": 0},
|
||||||
|
"notify": {"pass": 8, "fail": 0},
|
||||||
|
"home": {"pass": 6, "fail": 0},
|
||||||
|
"dedupe": {"pass": 9, "fail": 0},
|
||||||
|
"trending": {"pass": 11, "fail": 0},
|
||||||
|
"mute": {"pass": 9, "fail": 0},
|
||||||
|
"page": {"pass": 14, "fail": 0},
|
||||||
|
"thread": {"pass": 12, "fail": 0}
|
||||||
|
},
|
||||||
|
"total_pass": 189,
|
||||||
|
"total_fail": 0,
|
||||||
|
"total": 189
|
||||||
|
}
|
||||||
19
lib/feed/scoreboard.md
Normal file
19
lib/feed/scoreboard.md
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# feed Conformance Scoreboard
|
||||||
|
|
||||||
|
_Generated by `lib/feed/conformance.sh`_
|
||||||
|
|
||||||
|
| Suite | Pass | Fail | Total |
|
||||||
|
|-------|-----:|-----:|------:|
|
||||||
|
| basic | 30 | 0 | 30 |
|
||||||
|
| fanout | 29 | 0 | 29 |
|
||||||
|
| rank | 24 | 0 | 24 |
|
||||||
|
| integration | 22 | 0 | 22 |
|
||||||
|
| content | 15 | 0 | 15 |
|
||||||
|
| notify | 8 | 0 | 8 |
|
||||||
|
| home | 6 | 0 | 6 |
|
||||||
|
| dedupe | 9 | 0 | 9 |
|
||||||
|
| trending | 11 | 0 | 11 |
|
||||||
|
| mute | 9 | 0 | 9 |
|
||||||
|
| page | 14 | 0 | 14 |
|
||||||
|
| thread | 12 | 0 | 12 |
|
||||||
|
| **Total** | **189** | **0** | **189** |
|
||||||
75
lib/feed/stream.sx
Normal file
75
lib/feed/stream.sx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
; feed/stream — a stream is an APL vector (rank-1 array) whose ravel holds
|
||||||
|
; activity dicts. Operations lift APL primitives onto this shape: filter via
|
||||||
|
; compress (/), sort via grade (⍋), take via ↑, reverse via ⌽.
|
||||||
|
;
|
||||||
|
; Requires: lib/apl/runtime.sx, lib/feed/normalize.sx (loaded by harness).
|
||||||
|
|
||||||
|
(define feed/stream (fn (acts) (make-array (list (len acts)) acts)))
|
||||||
|
|
||||||
|
(define feed/items (fn (s) (get s :ravel)))
|
||||||
|
|
||||||
|
(define feed/count (fn (s) (len (get s :ravel))))
|
||||||
|
|
||||||
|
(define feed/empty (feed/stream (list)))
|
||||||
|
|
||||||
|
(define feed/empty? (fn (s) (= (feed/count s) 0)))
|
||||||
|
|
||||||
|
; filter — bool mask ∘ compress. pred : activity -> truthy
|
||||||
|
(define
|
||||||
|
feed/filter
|
||||||
|
(fn
|
||||||
|
(s pred)
|
||||||
|
(let
|
||||||
|
((items (get s :ravel)))
|
||||||
|
(let
|
||||||
|
((mask (make-array (list (len items)) (map (fn (a) (if (pred a) 1 0)) items))))
|
||||||
|
(apl-compress mask s)))))
|
||||||
|
|
||||||
|
; sort-by — ascending, stable on ties (grade-up is stable). key-fn : activity -> number
|
||||||
|
(define
|
||||||
|
feed/sort-by
|
||||||
|
(fn
|
||||||
|
(s key-fn)
|
||||||
|
(let
|
||||||
|
((items (get s :ravel)))
|
||||||
|
(let
|
||||||
|
((keys (make-array (list (len items)) (map key-fn items))))
|
||||||
|
(let
|
||||||
|
((order (get (apl-grade-up keys) :ravel)))
|
||||||
|
(feed/stream (map (fn (i) (nth items (- i 1))) order)))))))
|
||||||
|
|
||||||
|
(define feed/sort-by-at (fn (s) (feed/sort-by s feed/at)))
|
||||||
|
|
||||||
|
; newest-first: ascending sort then reverse (⌽)
|
||||||
|
(define feed/recent (fn (s) (apl-reverse (feed/sort-by-at s))))
|
||||||
|
|
||||||
|
; take N (↑), clamped to stream length so it never over-takes/pads
|
||||||
|
(define
|
||||||
|
feed/take
|
||||||
|
(fn
|
||||||
|
(s n)
|
||||||
|
(let
|
||||||
|
((c (feed/count s)))
|
||||||
|
(if (>= n c) s (apl-take (apl-scalar n) s)))))
|
||||||
|
|
||||||
|
(define feed/reverse (fn (s) (apl-reverse s)))
|
||||||
|
|
||||||
|
; common predicates
|
||||||
|
(define
|
||||||
|
feed/by-actor
|
||||||
|
(fn (s actor) (feed/filter s (fn (a) (equal? (get a :actor) actor)))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
feed/by-verb
|
||||||
|
(fn (s verb) (feed/filter s (fn (a) (equal? (get a :verb) verb)))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
feed/by-object
|
||||||
|
(fn
|
||||||
|
(s object)
|
||||||
|
(feed/filter s (fn (a) (equal? (get a :object) object)))))
|
||||||
|
|
||||||
|
; activities at or after timestamp t
|
||||||
|
(define
|
||||||
|
feed/since
|
||||||
|
(fn (s t) (feed/filter s (fn (a) (>= (get a :at) t)))))
|
||||||
118
lib/feed/tests/basic.sx
Normal file
118
lib/feed/tests/basic.sx
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
; Phase 1 — normalize, stream ops, api. Uses the feed-test harness
|
||||||
|
; (feed-test name got expected) provided by conformance.sh.
|
||||||
|
|
||||||
|
; ---------- normalize ----------
|
||||||
|
|
||||||
|
(feed-test
|
||||||
|
"normalize default actor"
|
||||||
|
(feed/actor (feed/normalize {}))
|
||||||
|
"")
|
||||||
|
(feed-test
|
||||||
|
"normalize default verb"
|
||||||
|
(feed/verb (feed/normalize {}))
|
||||||
|
"post")
|
||||||
|
(feed-test
|
||||||
|
"normalize default at"
|
||||||
|
(feed/at (feed/normalize {}))
|
||||||
|
0)
|
||||||
|
(feed-test
|
||||||
|
"normalize default object"
|
||||||
|
(feed/object (feed/normalize {}))
|
||||||
|
nil)
|
||||||
|
(feed-test
|
||||||
|
"normalize default tags"
|
||||||
|
(feed/tags (feed/normalize {}))
|
||||||
|
(list))
|
||||||
|
(feed-test
|
||||||
|
"normalize keeps actor"
|
||||||
|
(feed/actor (feed/normalize {:actor "alice"}))
|
||||||
|
"alice")
|
||||||
|
(feed-test
|
||||||
|
"normalize keeps verb"
|
||||||
|
(feed/verb (feed/normalize {:verb "like"}))
|
||||||
|
"like")
|
||||||
|
(feed-test
|
||||||
|
"normalize scalar tag -> list"
|
||||||
|
(feed/tags (feed/normalize {:tags "x"}))
|
||||||
|
(list "x"))
|
||||||
|
(feed-test
|
||||||
|
"normalize list tags kept"
|
||||||
|
(feed/tags (feed/normalize {:tags (list "a" "b")}))
|
||||||
|
(list "a" "b"))
|
||||||
|
(feed-test
|
||||||
|
"activity constructor at"
|
||||||
|
(feed/at (feed/activity "a" "post" "o" 5 (list)))
|
||||||
|
5)
|
||||||
|
(feed-test
|
||||||
|
"activity? on activity"
|
||||||
|
(feed/activity? (feed/normalize {:actor "a"}))
|
||||||
|
true)
|
||||||
|
(feed-test "activity? on number" (feed/activity? 5) false)
|
||||||
|
(feed-test "activity? on bare dict" (feed/activity? {:foo 1}) false)
|
||||||
|
|
||||||
|
; ---------- stream ----------
|
||||||
|
|
||||||
|
(define
|
||||||
|
S
|
||||||
|
(feed/stream
|
||||||
|
(list
|
||||||
|
(feed/activity "alice" "post" "p1" 30 (list))
|
||||||
|
(feed/activity "bob" "like" "p1" 10 (list))
|
||||||
|
(feed/activity "alice" "post" "p2" 20 (list)))))
|
||||||
|
|
||||||
|
(feed-test "stream count" (feed/count S) 3)
|
||||||
|
(feed-test "stream items len" (len (feed/items S)) 3)
|
||||||
|
(feed-test
|
||||||
|
"sort-by-at actors asc"
|
||||||
|
(map feed/actor (feed/items (feed/sort-by-at S)))
|
||||||
|
(list "bob" "alice" "alice"))
|
||||||
|
(feed-test
|
||||||
|
"recent newest first"
|
||||||
|
(map feed/at (feed/items (feed/recent S)))
|
||||||
|
(list 30 20 10))
|
||||||
|
(feed-test
|
||||||
|
"take 2 of recent"
|
||||||
|
(feed/count (feed/take (feed/recent S) 2))
|
||||||
|
2)
|
||||||
|
(feed-test
|
||||||
|
"take clamps past end"
|
||||||
|
(feed/count (feed/take S 10))
|
||||||
|
3)
|
||||||
|
(feed-test
|
||||||
|
"by-actor alice count"
|
||||||
|
(feed/count (feed/by-actor S "alice"))
|
||||||
|
2)
|
||||||
|
(feed-test
|
||||||
|
"by-verb like actor"
|
||||||
|
(map feed/actor (feed/items (feed/by-verb S "like")))
|
||||||
|
(list "bob"))
|
||||||
|
(feed-test
|
||||||
|
"by-object p1 count"
|
||||||
|
(feed/count (feed/by-object S "p1"))
|
||||||
|
2)
|
||||||
|
(feed-test
|
||||||
|
"since 20 count"
|
||||||
|
(feed/count (feed/since S 20))
|
||||||
|
2)
|
||||||
|
(feed-test
|
||||||
|
"reverse ats"
|
||||||
|
(map feed/at (feed/items (feed/reverse S)))
|
||||||
|
(list 20 10 30))
|
||||||
|
(feed-test "empty? on empty" (feed/empty? feed/empty) true)
|
||||||
|
(feed-test
|
||||||
|
"empty? on filtered-out"
|
||||||
|
(feed/empty? (feed/by-actor S "zzz"))
|
||||||
|
true)
|
||||||
|
|
||||||
|
; ---------- api ----------
|
||||||
|
|
||||||
|
(feed/reset!)
|
||||||
|
(feed/post {:actor "x" :at 1 :verb "post"})
|
||||||
|
(feed/post {:actor "y" :at 2 :verb "like"})
|
||||||
|
(feed-test "api size after posts" (feed/size) 2)
|
||||||
|
(feed-test "api all count" (feed/count (feed/all)) 2)
|
||||||
|
(feed-test
|
||||||
|
"post returns normalized verb"
|
||||||
|
(feed/verb (feed/post {:actor "z"}))
|
||||||
|
"post")
|
||||||
|
(feed-test "api size after third post" (feed/size) 3)
|
||||||
85
lib/feed/tests/content.sx
Normal file
85
lib/feed/tests/content.sx
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
; Follow-up — TF-IDF content ranking over :tags. (feed-test name got expected)
|
||||||
|
|
||||||
|
(define
|
||||||
|
corpus
|
||||||
|
(feed/stream
|
||||||
|
(list
|
||||||
|
(feed/normalize {:actor "u" :object "o1" :at 10 :tags (list "cats" "funny")})
|
||||||
|
(feed/normalize {:actor "u" :object "o2" :at 20 :tags (list "cats" "news")})
|
||||||
|
(feed/normalize {:actor "u" :object "o3" :at 30 :tags (list "politics" "news")})
|
||||||
|
(feed/normalize {:actor "u" :object "o4" :at 40 :tags (list "cats")}))))
|
||||||
|
|
||||||
|
; ---------- document frequency ----------
|
||||||
|
|
||||||
|
(feed-test "df cats" (get (feed/tag-df corpus) "cats") 3)
|
||||||
|
(feed-test "df news" (get (feed/tag-df corpus) "news") 2)
|
||||||
|
(feed-test "df funny" (get (feed/tag-df corpus) "funny") 1)
|
||||||
|
(feed-test "df politics" (get (feed/tag-df corpus) "politics") 1)
|
||||||
|
(feed-test "df full" (feed/tag-df corpus) {:news 2 :funny 1 :politics 1 :cats 3})
|
||||||
|
|
||||||
|
; ---------- inverse document frequency ----------
|
||||||
|
|
||||||
|
(feed-test
|
||||||
|
"idf news = log(4/2)"
|
||||||
|
(get (feed/tag-idf corpus) "news")
|
||||||
|
(log 2))
|
||||||
|
(feed-test
|
||||||
|
"idf funny = log(4/1)"
|
||||||
|
(get (feed/tag-idf corpus) "funny")
|
||||||
|
(log 4))
|
||||||
|
(feed-test
|
||||||
|
"rarer tag has higher idf"
|
||||||
|
(>
|
||||||
|
(get (feed/tag-idf corpus) "funny")
|
||||||
|
(get (feed/tag-idf corpus) "cats"))
|
||||||
|
true)
|
||||||
|
|
||||||
|
; ---------- tf-idf scoring ----------
|
||||||
|
|
||||||
|
(define idf (feed/tag-idf corpus))
|
||||||
|
|
||||||
|
(feed-test
|
||||||
|
"score query funny on o1"
|
||||||
|
((feed/tfidf-score idf (list "funny")) (feed/normalize {:actor "u" :object "x" :tags (list "cats" "funny")}))
|
||||||
|
(log 4))
|
||||||
|
(feed-test
|
||||||
|
"score query funny on non-match"
|
||||||
|
((feed/tfidf-score idf (list "funny")) (feed/normalize {:actor "u" :object "x" :tags (list "cats")}))
|
||||||
|
0)
|
||||||
|
(feed-test
|
||||||
|
"unknown query tag scores 0"
|
||||||
|
((feed/tfidf-score idf (list "zzz")) (feed/normalize {:actor "u" :object "x" :tags (list "cats")}))
|
||||||
|
0)
|
||||||
|
|
||||||
|
; ---------- ranking by relevance ----------
|
||||||
|
|
||||||
|
; query news: o2,o3 match (score log2), o1,o4 don't (0); ties break by :at desc
|
||||||
|
(feed-test
|
||||||
|
"by-relevance news order"
|
||||||
|
(map
|
||||||
|
(fn (a) (get a :object))
|
||||||
|
(feed/items (feed/by-relevance corpus (list "news"))))
|
||||||
|
(list "o3" "o2" "o4" "o1"))
|
||||||
|
|
||||||
|
; query funny: only o1 matches -> ranks first
|
||||||
|
(feed-test
|
||||||
|
"by-relevance funny first"
|
||||||
|
(get
|
||||||
|
(nth (feed/items (feed/by-relevance corpus (list "funny"))) 0)
|
||||||
|
:object)
|
||||||
|
"o1")
|
||||||
|
|
||||||
|
; query (cats news): o2 carries both tags -> highest combined tf-idf
|
||||||
|
(feed-test
|
||||||
|
"by-relevance cats+news top"
|
||||||
|
(get
|
||||||
|
(nth
|
||||||
|
(feed/items (feed/by-relevance corpus (list "cats" "news")))
|
||||||
|
0)
|
||||||
|
:object)
|
||||||
|
"o2")
|
||||||
|
|
||||||
|
(feed-test
|
||||||
|
"by-relevance preserves count"
|
||||||
|
(feed/count (feed/by-relevance corpus (list "cats")))
|
||||||
|
4)
|
||||||
56
lib/feed/tests/dedupe.sx
Normal file
56
lib/feed/tests/dedupe.sx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
; Follow-up — verb-aware (smart) dedupe. (feed-test name got expected)
|
||||||
|
|
||||||
|
; reactions (like/follow) collapse cross-actor; posts stay distinct per actor
|
||||||
|
(define
|
||||||
|
M
|
||||||
|
(feed/stream
|
||||||
|
(list
|
||||||
|
(feed/activity "alice" "like" "X" 1 (list))
|
||||||
|
(feed/activity "bob" "like" "X" 2 (list))
|
||||||
|
(feed/activity "alice" "post" "P" 3 (list))
|
||||||
|
(feed/activity "bob" "post" "P" 4 (list))
|
||||||
|
(feed/activity "alice" "follow" "C" 5 (list))
|
||||||
|
(feed/activity "bob" "follow" "C" 6 (list))))) ; collapses
|
||||||
|
|
||||||
|
(feed-test
|
||||||
|
"smart dedupe total"
|
||||||
|
(feed/count (feed/dedupe-smart M))
|
||||||
|
4)
|
||||||
|
(feed-test
|
||||||
|
"smart keeps both posts"
|
||||||
|
(feed/count (feed/by-verb (feed/dedupe-smart M) "post"))
|
||||||
|
2)
|
||||||
|
(feed-test
|
||||||
|
"smart collapses likes to one"
|
||||||
|
(feed/count (feed/by-verb (feed/dedupe-smart M) "like"))
|
||||||
|
1)
|
||||||
|
(feed-test
|
||||||
|
"smart collapses follows to one"
|
||||||
|
(feed/count (feed/by-verb (feed/dedupe-smart M) "follow"))
|
||||||
|
1)
|
||||||
|
(feed-test
|
||||||
|
"collapsed like keeps first actor"
|
||||||
|
(map feed/actor (feed/items (feed/by-verb (feed/dedupe-smart M) "like")))
|
||||||
|
(list "alice"))
|
||||||
|
|
||||||
|
; contrast: plain activity dedupe keeps cross-actor likes distinct
|
||||||
|
(feed-test
|
||||||
|
"activity dedupe keeps both likes"
|
||||||
|
(feed/count (feed/by-verb (feed/dedupe-activities M) "like"))
|
||||||
|
2)
|
||||||
|
|
||||||
|
; contrast: blanket collapse folds the two posts (same verb+object) too
|
||||||
|
(feed-test
|
||||||
|
"collapse dedupe folds posts"
|
||||||
|
(feed/count (feed/by-verb (feed/dedupe-collapse M) "post"))
|
||||||
|
1)
|
||||||
|
|
||||||
|
; smart-key dispatch
|
||||||
|
(feed-test
|
||||||
|
"smart-key reaction -> (verb object)"
|
||||||
|
(feed/smart-key (feed/activity "alice" "like" "X" 0 (list)))
|
||||||
|
(list "like" "X"))
|
||||||
|
(feed-test
|
||||||
|
"smart-key post -> (actor verb object)"
|
||||||
|
(feed/smart-key (feed/activity "alice" "post" "P" 0 (list)))
|
||||||
|
(list "alice" "post" "P"))
|
||||||
187
lib/feed/tests/fanout.sx
Normal file
187
lib/feed/tests/fanout.sx
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
; Phase 2 — fanout via outer product + dedupe. (feed-test name got expected)
|
||||||
|
|
||||||
|
; ---------- graph ----------
|
||||||
|
|
||||||
|
; edges: (follower followee). bob,carol follow alice; carol,dave follow bob.
|
||||||
|
(define
|
||||||
|
G
|
||||||
|
(feed/follow-graph
|
||||||
|
(list
|
||||||
|
(list "bob" "alice")
|
||||||
|
(list "carol" "alice")
|
||||||
|
(list "carol" "bob")
|
||||||
|
(list "dave" "bob"))))
|
||||||
|
|
||||||
|
(feed-test "followers alice" (feed/followers G "alice") (list "bob" "carol"))
|
||||||
|
(feed-test "followers bob" (feed/followers G "bob") (list "carol" "dave"))
|
||||||
|
(feed-test "followers unknown" (feed/followers G "zzz") (list))
|
||||||
|
(feed-test "audience distinct" (feed/audience G) (list "bob" "carol" "dave"))
|
||||||
|
|
||||||
|
; ---------- fanout ----------
|
||||||
|
|
||||||
|
(define
|
||||||
|
S
|
||||||
|
(feed/stream
|
||||||
|
(list
|
||||||
|
(feed/activity "alice" "post" "p1" 10 (list))
|
||||||
|
(feed/activity "alice" "post" "p2" 20 (list))
|
||||||
|
(feed/activity "bob" "like" "p1" 30 (list)))))
|
||||||
|
|
||||||
|
(define IB (feed/fanout S G))
|
||||||
|
|
||||||
|
(feed-test "fanout total edges" (feed/count IB) 6)
|
||||||
|
(feed-test
|
||||||
|
"inbox bob count"
|
||||||
|
(feed/count (feed/inbox-for IB "bob"))
|
||||||
|
2)
|
||||||
|
(feed-test
|
||||||
|
"inbox carol count"
|
||||||
|
(feed/count (feed/inbox-for IB "carol"))
|
||||||
|
3)
|
||||||
|
(feed-test
|
||||||
|
"inbox dave count"
|
||||||
|
(feed/count (feed/inbox-for IB "dave"))
|
||||||
|
1)
|
||||||
|
(feed-test
|
||||||
|
"inbox alice (follows none)"
|
||||||
|
(feed/count (feed/inbox-for IB "alice"))
|
||||||
|
0)
|
||||||
|
(feed-test
|
||||||
|
"recipients order"
|
||||||
|
(feed/recipients IB)
|
||||||
|
(list "bob" "carol" "dave"))
|
||||||
|
(feed-test
|
||||||
|
"bob inbox objects"
|
||||||
|
(map (fn (a) (get a :object)) (feed/inbox-activities IB "bob"))
|
||||||
|
(list "p1" "p2"))
|
||||||
|
(feed-test
|
||||||
|
"dave inbox objects"
|
||||||
|
(map (fn (a) (get a :object)) (feed/inbox-activities IB "dave"))
|
||||||
|
(list "p1"))
|
||||||
|
(feed-test
|
||||||
|
"dave inbox verb"
|
||||||
|
(map (fn (a) (get a :verb)) (feed/inbox-activities IB "dave"))
|
||||||
|
(list "like"))
|
||||||
|
|
||||||
|
; empty graph → no audience → no edges
|
||||||
|
(feed-test
|
||||||
|
"empty graph fanout"
|
||||||
|
(feed/count (feed/fanout S {}))
|
||||||
|
0)
|
||||||
|
|
||||||
|
; actor nobody follows produces no edges
|
||||||
|
(define
|
||||||
|
Sghost
|
||||||
|
(feed/stream (list (feed/activity "ghost" "post" "g1" 5 (list)))))
|
||||||
|
(feed-test
|
||||||
|
"unfollowed actor fanout"
|
||||||
|
(feed/count (feed/fanout Sghost G))
|
||||||
|
0)
|
||||||
|
|
||||||
|
; ---------- high fanout (popular actor) ----------
|
||||||
|
|
||||||
|
(define
|
||||||
|
Gstar
|
||||||
|
(feed/follow-graph
|
||||||
|
(list
|
||||||
|
(list "u1" "star")
|
||||||
|
(list "u2" "star")
|
||||||
|
(list "u3" "star")
|
||||||
|
(list "u4" "star")
|
||||||
|
(list "u5" "star"))))
|
||||||
|
(define
|
||||||
|
Sstar
|
||||||
|
(feed/stream (list (feed/activity "star" "post" "s1" 1 (list)))))
|
||||||
|
(feed-test
|
||||||
|
"star fanout count"
|
||||||
|
(feed/count (feed/fanout Sstar Gstar))
|
||||||
|
5)
|
||||||
|
(feed-test "star audience size" (len (feed/audience Gstar)) 5)
|
||||||
|
|
||||||
|
; ---------- mutual follow ----------
|
||||||
|
|
||||||
|
(define Gmut (feed/follow-graph (list (list "a" "b") (list "b" "a"))))
|
||||||
|
(define
|
||||||
|
Smut
|
||||||
|
(feed/stream
|
||||||
|
(list
|
||||||
|
(feed/activity "a" "post" "pa" 1 (list))
|
||||||
|
(feed/activity "b" "post" "pb" 2 (list)))))
|
||||||
|
(define IBmut (feed/fanout Smut Gmut))
|
||||||
|
(feed-test "mutual total" (feed/count IBmut) 2)
|
||||||
|
(feed-test
|
||||||
|
"mutual a gets pb"
|
||||||
|
(map (fn (x) (get x :object)) (feed/inbox-activities IBmut "a"))
|
||||||
|
(list "pb"))
|
||||||
|
(feed-test
|
||||||
|
"mutual b gets pa"
|
||||||
|
(map (fn (x) (get x :object)) (feed/inbox-activities IBmut "b"))
|
||||||
|
(list "pa"))
|
||||||
|
|
||||||
|
; ---------- dedupe ----------
|
||||||
|
|
||||||
|
(define
|
||||||
|
Sdup2
|
||||||
|
(feed/stream
|
||||||
|
(list
|
||||||
|
(feed/activity "alice" "post" "p1" 1 (list))
|
||||||
|
(feed/activity "alice" "post" "p1" 9 (list))
|
||||||
|
(feed/activity "alice" "post" "p2" 2 (list)))))
|
||||||
|
(feed-test
|
||||||
|
"dedupe-activities collapses dup"
|
||||||
|
(feed/count (feed/dedupe-activities Sdup2))
|
||||||
|
2)
|
||||||
|
(feed-test
|
||||||
|
"dedupe-activities keeps distinct"
|
||||||
|
(map
|
||||||
|
(fn (a) (get a :object))
|
||||||
|
(feed/items (feed/dedupe-activities Sdup2)))
|
||||||
|
(list "p1" "p2"))
|
||||||
|
|
||||||
|
(define
|
||||||
|
Slikes
|
||||||
|
(feed/stream
|
||||||
|
(list
|
||||||
|
(feed/activity "alice" "like" "X" 1 (list))
|
||||||
|
(feed/activity "bob" "like" "X" 2 (list))
|
||||||
|
(feed/activity "carol" "like" "Y" 3 (list)))))
|
||||||
|
(feed-test
|
||||||
|
"collapse cross-actor likes"
|
||||||
|
(feed/count (feed/dedupe-collapse Slikes))
|
||||||
|
2)
|
||||||
|
(feed-test
|
||||||
|
"collapse keeps distinct objects"
|
||||||
|
(map
|
||||||
|
(fn (a) (get a :object))
|
||||||
|
(feed/items (feed/dedupe-collapse Slikes)))
|
||||||
|
(list "X" "Y"))
|
||||||
|
|
||||||
|
(feed-test
|
||||||
|
"activity-key shape"
|
||||||
|
(feed/activity-key (feed/activity "a" "post" "o" 0 (list)))
|
||||||
|
(list "a" "post" "o"))
|
||||||
|
(feed-test
|
||||||
|
"collapse-key shape"
|
||||||
|
(feed/collapse-key (feed/activity "a" "like" "o" 0 (list)))
|
||||||
|
(list "like" "o"))
|
||||||
|
|
||||||
|
; cross-post: alice posts p1 twice → bob's inbox has it twice → dedupe-inbox → once
|
||||||
|
(define
|
||||||
|
Scross
|
||||||
|
(feed/stream
|
||||||
|
(list
|
||||||
|
(feed/activity "alice" "post" "p1" 1 (list))
|
||||||
|
(feed/activity "alice" "post" "p1" 5 (list)))))
|
||||||
|
(define IBcross (feed/fanout Scross G))
|
||||||
|
(feed-test
|
||||||
|
"cross-post raw bob count"
|
||||||
|
(feed/count (feed/inbox-for IBcross "bob"))
|
||||||
|
2)
|
||||||
|
(feed-test
|
||||||
|
"cross-post deduped bob count"
|
||||||
|
(feed/count (feed/inbox-for (feed/dedupe-inbox IBcross) "bob"))
|
||||||
|
1)
|
||||||
|
(feed-test
|
||||||
|
"dedupe-inbox keeps distinct receivers"
|
||||||
|
(feed/count (feed/dedupe-inbox IBcross))
|
||||||
|
2)
|
||||||
73
lib/feed/tests/home.sx
Normal file
73
lib/feed/tests/home.sx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
; Follow-up — feed/home capstone pipeline. (feed-test name got expected)
|
||||||
|
|
||||||
|
; alice follows star and bob (edges: follower followee)
|
||||||
|
(define
|
||||||
|
G
|
||||||
|
(feed/follow-graph (list (list "alice" "star") (list "alice" "bob"))))
|
||||||
|
|
||||||
|
; star posts s1 then s2; bob posts b1; star re-posts s1 (cross-post dup);
|
||||||
|
; zoe posts z1 (alice does NOT follow zoe)
|
||||||
|
(define
|
||||||
|
S
|
||||||
|
(feed/stream
|
||||||
|
(list
|
||||||
|
(feed/activity "star" "post" "s1" 10 (list))
|
||||||
|
(feed/activity "star" "post" "s2" 20 (list))
|
||||||
|
(feed/activity "bob" "post" "b1" 15 (list))
|
||||||
|
(feed/activity "star" "post" "s1" 5 (list))
|
||||||
|
(feed/activity "zoe" "post" "z1" 30 (list)))))
|
||||||
|
|
||||||
|
(define rec (feed/recency 100 10))
|
||||||
|
|
||||||
|
(feed-test
|
||||||
|
"home count (deduped, followed only)"
|
||||||
|
(feed/count (feed/home S G "alice" feed/permit-public? rec 10))
|
||||||
|
3)
|
||||||
|
|
||||||
|
(feed-test
|
||||||
|
"home order by recency"
|
||||||
|
(map
|
||||||
|
(fn (a) (get a :object))
|
||||||
|
(feed/items (feed/home S G "alice" feed/permit-public? rec 10)))
|
||||||
|
(list "s2" "b1" "s1"))
|
||||||
|
|
||||||
|
(feed-test
|
||||||
|
"home excludes unfollowed zoe"
|
||||||
|
(feed/-elem?
|
||||||
|
"z1"
|
||||||
|
(map
|
||||||
|
(fn (a) (get a :object))
|
||||||
|
(feed/items (feed/home S G "alice" feed/permit-public? rec 10))))
|
||||||
|
false)
|
||||||
|
|
||||||
|
(feed-test
|
||||||
|
"home top-2"
|
||||||
|
(map
|
||||||
|
(fn (a) (get a :object))
|
||||||
|
(feed/items (feed/home S G "alice" feed/permit-public? rec 2)))
|
||||||
|
(list "s2" "b1"))
|
||||||
|
|
||||||
|
(feed-test
|
||||||
|
"home dedupes cross-post (one s1)"
|
||||||
|
(len
|
||||||
|
(filter
|
||||||
|
(fn (o) (equal? o "s1"))
|
||||||
|
(map
|
||||||
|
(fn (a) (get a :object))
|
||||||
|
(feed/items
|
||||||
|
(feed/home S G "alice" feed/permit-public? rec 10)))))
|
||||||
|
1)
|
||||||
|
|
||||||
|
; ACL applied per-viewer in the home pipeline
|
||||||
|
(define
|
||||||
|
Sacl
|
||||||
|
(feed/stream
|
||||||
|
(list (feed/normalize {:actor "star" :object "pub" :at 20}) (feed/normalize {:actor "star" :object "sec" :visible-to (list "carol") :at 25}))))
|
||||||
|
(define Gacl (feed/follow-graph (list (list "alice" "star"))))
|
||||||
|
|
||||||
|
(feed-test
|
||||||
|
"home hides activity alice not permitted"
|
||||||
|
(map
|
||||||
|
(fn (a) (get a :object))
|
||||||
|
(feed/items (feed/home Sacl Gacl "alice" feed/permit-acl? rec 10)))
|
||||||
|
(list "pub"))
|
||||||
155
lib/feed/tests/integration.sx
Normal file
155
lib/feed/tests/integration.sx
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
; Phase 4 — visibility (ACL) + federation, and the end-to-end timeline.
|
||||||
|
; (feed-test name got expected)
|
||||||
|
|
||||||
|
; ---------- ACL visibility ----------
|
||||||
|
; pub: public. sec: bob, allows carol. dm: frank, allows dave.
|
||||||
|
|
||||||
|
(define
|
||||||
|
C
|
||||||
|
(feed/stream
|
||||||
|
(list
|
||||||
|
(feed/normalize {:actor "alice" :object "pub" :at 10})
|
||||||
|
(feed/normalize {:actor "bob" :object "sec" :visible-to (list "carol") :at 20})
|
||||||
|
(feed/normalize {:actor "frank" :object "dm" :visible-to (list "dave") :at 30}))))
|
||||||
|
|
||||||
|
(feed-test
|
||||||
|
"public visible to anyone"
|
||||||
|
(feed/count (feed/visible C "zoe" feed/permit-acl?))
|
||||||
|
1)
|
||||||
|
(feed-test
|
||||||
|
"carol sees allowlisted + public"
|
||||||
|
(feed/count (feed/visible C "carol" feed/permit-acl?))
|
||||||
|
2)
|
||||||
|
(feed-test
|
||||||
|
"dave sees dm + public"
|
||||||
|
(feed/count (feed/visible C "dave" feed/permit-acl?))
|
||||||
|
2)
|
||||||
|
(feed-test
|
||||||
|
"author always sees own private"
|
||||||
|
(feed/count (feed/visible C "frank" feed/permit-acl?))
|
||||||
|
2)
|
||||||
|
(feed-test
|
||||||
|
"permit-public? lets all through"
|
||||||
|
(feed/count (feed/visible C "zoe" feed/permit-public?))
|
||||||
|
3)
|
||||||
|
(feed-test
|
||||||
|
"visible objects for dave"
|
||||||
|
(map
|
||||||
|
(fn (a) (get a :object))
|
||||||
|
(feed/items (feed/visible C "dave" feed/permit-acl?)))
|
||||||
|
(list "pub" "dm"))
|
||||||
|
|
||||||
|
; per-viewer: same stream, different timelines
|
||||||
|
(feed-test
|
||||||
|
"zoe timeline differs from carol"
|
||||||
|
(not
|
||||||
|
(=
|
||||||
|
(feed/count (feed/visible C "zoe" feed/permit-acl?))
|
||||||
|
(feed/count (feed/visible C "carol" feed/permit-acl?))))
|
||||||
|
true)
|
||||||
|
|
||||||
|
; ---------- federation: merge / ingest ----------
|
||||||
|
|
||||||
|
(define
|
||||||
|
L
|
||||||
|
(feed/stream
|
||||||
|
(list
|
||||||
|
(feed/activity "alice" "post" "p1" 10 (list))
|
||||||
|
(feed/activity "alice" "post" "p2" 20 (list)))))
|
||||||
|
(define
|
||||||
|
P
|
||||||
|
(feed/stream
|
||||||
|
(list
|
||||||
|
(feed/activity "alice" "post" "p2" 20 (list))
|
||||||
|
(feed/activity "peer" "post" "p9" 25 (list)))))
|
||||||
|
|
||||||
|
(feed-test "merge concatenates" (feed/count (feed/merge L P)) 4)
|
||||||
|
(feed-test
|
||||||
|
"ingest dedupes overlap"
|
||||||
|
(feed/count (feed/ingest L P))
|
||||||
|
3)
|
||||||
|
|
||||||
|
(feed-test
|
||||||
|
"inbound normalizes + ingests"
|
||||||
|
(feed/count (feed/inbound L (list {:actor "peer" :object "p9" :at 25} {:actor "alice" :object "p1" :at 10})))
|
||||||
|
3)
|
||||||
|
|
||||||
|
; backfill via injected fetch-fn
|
||||||
|
(define peer-history (fn (peer-id) (list {:actor peer-id :object "h1" :at 1} {:actor peer-id :object "h2" :at 2})))
|
||||||
|
(feed-test
|
||||||
|
"backfill merges peer history"
|
||||||
|
(feed/count (feed/backfill L peer-history "remote"))
|
||||||
|
4)
|
||||||
|
(feed-test
|
||||||
|
"backfill objects present"
|
||||||
|
(map
|
||||||
|
(fn (a) (get a :object))
|
||||||
|
(feed/items
|
||||||
|
(feed/by-actor (feed/backfill L peer-history "remote") "remote")))
|
||||||
|
(list "h1" "h2"))
|
||||||
|
|
||||||
|
; ---------- federation: outbound partition ----------
|
||||||
|
|
||||||
|
; bob (local), alice@remote + carol@remote (remote) follow star
|
||||||
|
(define
|
||||||
|
Gf
|
||||||
|
(feed/follow-graph
|
||||||
|
(list
|
||||||
|
(list "bob" "star")
|
||||||
|
(list "alice@remote" "star")
|
||||||
|
(list "carol@remote" "star"))))
|
||||||
|
(define
|
||||||
|
Sf
|
||||||
|
(feed/stream (list (feed/activity "star" "post" "s1" 1 (list)))))
|
||||||
|
(define
|
||||||
|
remote?
|
||||||
|
(fn (id) (feed/-elem? id (list "alice@remote" "carol@remote"))))
|
||||||
|
(define parts (feed/federate Sf Gf remote?))
|
||||||
|
|
||||||
|
(feed-test "local deliveries" (feed/count (get parts :local)) 1)
|
||||||
|
(feed-test "remote deliveries" (feed/count (get parts :remote)) 2)
|
||||||
|
(feed-test
|
||||||
|
"local recipient is bob"
|
||||||
|
(feed/recipients (get parts :local))
|
||||||
|
(list "bob"))
|
||||||
|
|
||||||
|
; deliver: send-fn receives each remote event, local inbox returned
|
||||||
|
(define sent (list))
|
||||||
|
(define send-fn (fn (to act) (set! sent (append sent (list to)))))
|
||||||
|
(define local-inbox (feed/deliver Sf Gf remote? send-fn))
|
||||||
|
(feed-test "deliver returns local inbox" (feed/count local-inbox) 1)
|
||||||
|
(feed-test "deliver sent to both remotes" (len sent) 2)
|
||||||
|
(feed-test "deliver remote targets" sent (list "alice@remote" "carol@remote"))
|
||||||
|
|
||||||
|
; ---------- end-to-end: federated, ACL-filtered, ranked timeline ----------
|
||||||
|
|
||||||
|
(define
|
||||||
|
base
|
||||||
|
(feed/stream
|
||||||
|
(list
|
||||||
|
(feed/normalize {:actor "alice" :object "a1" :at 100})
|
||||||
|
(feed/normalize {:actor "bob" :object "b1" :visible-to (list "carol") :at 90})
|
||||||
|
(feed/normalize {:actor "eve" :object "e1" :visible-to (list "dave") :at 80}))))
|
||||||
|
(define federated (feed/inbound base (list {:actor "peer" :object "x1" :at 110})))
|
||||||
|
(define rec (feed/recency 120 10))
|
||||||
|
(define
|
||||||
|
carol-tl
|
||||||
|
(feed/timeline federated "carol" feed/permit-acl? rec 3))
|
||||||
|
|
||||||
|
; eve's :visible-to excludes carol -> filtered out; peer/alice public, bob allows carol
|
||||||
|
(feed-test "carol federated timeline count" (feed/count carol-tl) 3)
|
||||||
|
(feed-test
|
||||||
|
"carol timeline order (recency)"
|
||||||
|
(map (fn (a) (get a :object)) (feed/items carol-tl))
|
||||||
|
(list "x1" "a1" "b1"))
|
||||||
|
(feed-test
|
||||||
|
"eve dm excluded from carol"
|
||||||
|
(feed/-elem? "e1" (map (fn (a) (get a :object)) (feed/items carol-tl)))
|
||||||
|
false)
|
||||||
|
(feed-test
|
||||||
|
"dave sees eve dm not bob"
|
||||||
|
(map
|
||||||
|
(fn (a) (get a :object))
|
||||||
|
(feed/items
|
||||||
|
(feed/timeline federated "dave" feed/permit-acl? rec 5)))
|
||||||
|
(list "x1" "a1" "e1"))
|
||||||
68
lib/feed/tests/mute.sx
Normal file
68
lib/feed/tests/mute.sx
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
; Follow-up — viewer mute/block filtering. (feed-test name got expected)
|
||||||
|
|
||||||
|
(define
|
||||||
|
S
|
||||||
|
(feed/stream
|
||||||
|
(list
|
||||||
|
(feed/normalize {:actor "alice" :object "P1" :at 1 :tags (list "news")})
|
||||||
|
(feed/normalize {:actor "bob" :object "P2" :at 2 :tags (list "spam")})
|
||||||
|
(feed/normalize {:actor "alice" :object "P3" :at 3 :tags (list "cats")})
|
||||||
|
(feed/normalize {:actor "carol" :object "P4" :at 4 :tags (list "news" "spam")}))))
|
||||||
|
|
||||||
|
; ---------- mute actors ----------
|
||||||
|
|
||||||
|
(feed-test
|
||||||
|
"mute bob drops his post"
|
||||||
|
(map
|
||||||
|
(fn (a) (get a :object))
|
||||||
|
(feed/items (feed/mute-actors S (list "bob"))))
|
||||||
|
(list "P1" "P3" "P4"))
|
||||||
|
(feed-test
|
||||||
|
"mute alice drops two"
|
||||||
|
(feed/count (feed/mute-actors S (list "alice")))
|
||||||
|
2)
|
||||||
|
(feed-test
|
||||||
|
"mute nobody keeps all"
|
||||||
|
(feed/count (feed/mute-actors S (list)))
|
||||||
|
4)
|
||||||
|
|
||||||
|
; ---------- mute tags ----------
|
||||||
|
|
||||||
|
(feed-test
|
||||||
|
"mute spam tag drops two"
|
||||||
|
(map
|
||||||
|
(fn (a) (get a :object))
|
||||||
|
(feed/items (feed/mute-tags S (list "spam"))))
|
||||||
|
(list "P1" "P3"))
|
||||||
|
(feed-test
|
||||||
|
"mute news+cats leaves spam-only"
|
||||||
|
(map
|
||||||
|
(fn (a) (get a :object))
|
||||||
|
(feed/items (feed/mute-tags S (list "news" "cats"))))
|
||||||
|
(list "P2"))
|
||||||
|
|
||||||
|
; ---------- mute objects ----------
|
||||||
|
|
||||||
|
(feed-test
|
||||||
|
"mute object P3 (thread mute)"
|
||||||
|
(feed/count (feed/mute-objects S (list "P3")))
|
||||||
|
3)
|
||||||
|
|
||||||
|
; ---------- combined prefs ----------
|
||||||
|
|
||||||
|
(feed-test
|
||||||
|
"apply-prefs actors + tags"
|
||||||
|
(map
|
||||||
|
(fn (a) (get a :object))
|
||||||
|
(feed/items (feed/apply-prefs S {:mute-actors (list "bob") :mute-tags (list "cats")})))
|
||||||
|
(list "P1" "P4"))
|
||||||
|
(feed-test
|
||||||
|
"apply-prefs empty keeps all"
|
||||||
|
(feed/count (feed/apply-prefs S {}))
|
||||||
|
4)
|
||||||
|
(feed-test
|
||||||
|
"apply-prefs all three filters"
|
||||||
|
(map
|
||||||
|
(fn (a) (get a :object))
|
||||||
|
(feed/items (feed/apply-prefs S {:mute-objects (list "P3") :mute-actors (list "carol") :mute-tags (list "spam")})))
|
||||||
|
(list "P1"))
|
||||||
69
lib/feed/tests/notify.sx
Normal file
69
lib/feed/tests/notify.sx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
; Follow-up — notification feed over an inbox. (feed-test name got expected)
|
||||||
|
|
||||||
|
; an inbox is a stream of {:to receiver :activity act} events
|
||||||
|
(define mk-ev (fn (to act) {:activity act :to to}))
|
||||||
|
|
||||||
|
(define
|
||||||
|
IB
|
||||||
|
(feed/stream
|
||||||
|
(list
|
||||||
|
(mk-ev "alice" (feed/activity "bob" "like" "P" 10 (list)))
|
||||||
|
(mk-ev "alice" (feed/activity "carol" "like" "P" 20 (list)))
|
||||||
|
(mk-ev "alice" (feed/activity "dave" "reply" "Q" 30 (list)))
|
||||||
|
(mk-ev "bob" (feed/activity "eve" "like" "R" 40 (list))))))
|
||||||
|
|
||||||
|
; ---------- raw notifications ----------
|
||||||
|
|
||||||
|
(feed-test
|
||||||
|
"alice notification count"
|
||||||
|
(feed/count (feed/notifications IB "alice"))
|
||||||
|
3)
|
||||||
|
(feed-test
|
||||||
|
"bob notification count"
|
||||||
|
(feed/count (feed/notifications IB "bob"))
|
||||||
|
1)
|
||||||
|
(feed-test
|
||||||
|
"zoe no notifications"
|
||||||
|
(feed/count (feed/notifications IB "zoe"))
|
||||||
|
0)
|
||||||
|
|
||||||
|
; ---------- verb filtering ----------
|
||||||
|
|
||||||
|
(feed-test
|
||||||
|
"alice likes only"
|
||||||
|
(feed/count (feed/notify-verbs IB "alice" (list "like")))
|
||||||
|
2)
|
||||||
|
(feed-test
|
||||||
|
"alice replies only"
|
||||||
|
(feed/count (feed/notify-verbs IB "alice" (list "reply")))
|
||||||
|
1)
|
||||||
|
(feed-test
|
||||||
|
"alice like+reply"
|
||||||
|
(feed/count (feed/notify-verbs IB "alice" (list "like" "reply")))
|
||||||
|
3)
|
||||||
|
(feed-test
|
||||||
|
"alice follow (none)"
|
||||||
|
(feed/count (feed/notify-verbs IB "alice" (list "follow")))
|
||||||
|
0)
|
||||||
|
|
||||||
|
; ---------- digest ----------
|
||||||
|
|
||||||
|
(define dig (feed/notify-digest IB "alice"))
|
||||||
|
|
||||||
|
(feed-test "digest group count" (len dig) 2)
|
||||||
|
(feed-test
|
||||||
|
"digest sorted by key (like|P before reply|Q)"
|
||||||
|
(map (fn (g) (get g :object)) dig)
|
||||||
|
(list "P" "Q"))
|
||||||
|
(feed-test
|
||||||
|
"like group actors"
|
||||||
|
(get (nth dig 0) :actors)
|
||||||
|
(list "bob" "carol"))
|
||||||
|
(feed-test "like group count" (get (nth dig 0) :count) 2)
|
||||||
|
(feed-test "like group verb" (get (nth dig 0) :verb) "like")
|
||||||
|
(feed-test "reply group count" (get (nth dig 1) :count) 1)
|
||||||
|
(feed-test
|
||||||
|
"reply group actors"
|
||||||
|
(get (nth dig 1) :actors)
|
||||||
|
(list "dave"))
|
||||||
|
(feed-test "empty digest for zoe" (feed/notify-digest IB "zoe") (list))
|
||||||
86
lib/feed/tests/page.sx
Normal file
86
lib/feed/tests/page.sx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
; Follow-up — pagination (offset + cursor). (feed-test name got expected)
|
||||||
|
|
||||||
|
; ---------- offset / limit ----------
|
||||||
|
|
||||||
|
(define
|
||||||
|
O
|
||||||
|
(feed/stream
|
||||||
|
(list
|
||||||
|
(feed/activity "u" "post" "o1" 1 (list))
|
||||||
|
(feed/activity "u" "post" "o2" 2 (list))
|
||||||
|
(feed/activity "u" "post" "o3" 3 (list))
|
||||||
|
(feed/activity "u" "post" "o4" 4 (list))
|
||||||
|
(feed/activity "u" "post" "o5" 5 (list)))))
|
||||||
|
|
||||||
|
(feed-test
|
||||||
|
"page 1"
|
||||||
|
(map
|
||||||
|
(fn (a) (get a :object))
|
||||||
|
(feed/items (feed/page O 0 2)))
|
||||||
|
(list "o1" "o2"))
|
||||||
|
(feed-test
|
||||||
|
"page 2"
|
||||||
|
(map
|
||||||
|
(fn (a) (get a :object))
|
||||||
|
(feed/items (feed/page O 2 2)))
|
||||||
|
(list "o3" "o4"))
|
||||||
|
(feed-test
|
||||||
|
"page 3 (partial)"
|
||||||
|
(map
|
||||||
|
(fn (a) (get a :object))
|
||||||
|
(feed/items (feed/page O 4 2)))
|
||||||
|
(list "o5"))
|
||||||
|
(feed-test
|
||||||
|
"page past end empty"
|
||||||
|
(feed/count (feed/page O 10 2))
|
||||||
|
0)
|
||||||
|
(feed-test "page-count 5/2 = 3" (feed/page-count O 2) 3)
|
||||||
|
(feed-test "page-count 5/5 = 1" (feed/page-count O 5) 1)
|
||||||
|
|
||||||
|
; ---------- cursor (recency) ----------
|
||||||
|
|
||||||
|
(define
|
||||||
|
R
|
||||||
|
(feed/stream
|
||||||
|
(list
|
||||||
|
(feed/activity "u" "post" "a" 50 (list))
|
||||||
|
(feed/activity "u" "post" "b" 40 (list))
|
||||||
|
(feed/activity "u" "post" "c" 30 (list))
|
||||||
|
(feed/activity "u" "post" "d" 20 (list))
|
||||||
|
(feed/activity "u" "post" "e" 10 (list)))))
|
||||||
|
|
||||||
|
(define p1 (feed/page-before R 100 2))
|
||||||
|
(feed-test
|
||||||
|
"cursor page 1 newest first"
|
||||||
|
(map (fn (a) (get a :object)) (feed/items p1))
|
||||||
|
(list "a" "b"))
|
||||||
|
(feed-test "next cursor after page 1" (feed/next-cursor p1) 40)
|
||||||
|
|
||||||
|
(define p2 (feed/page-before R (feed/next-cursor p1) 2))
|
||||||
|
(feed-test
|
||||||
|
"cursor page 2"
|
||||||
|
(map (fn (a) (get a :object)) (feed/items p2))
|
||||||
|
(list "c" "d"))
|
||||||
|
(feed-test "next cursor after page 2" (feed/next-cursor p2) 20)
|
||||||
|
|
||||||
|
(define p3 (feed/page-before R (feed/next-cursor p2) 2))
|
||||||
|
(feed-test
|
||||||
|
"cursor page 3 (partial)"
|
||||||
|
(map (fn (a) (get a :object)) (feed/items p3))
|
||||||
|
(list "e"))
|
||||||
|
|
||||||
|
(feed-test
|
||||||
|
"empty page nil cursor"
|
||||||
|
(feed/next-cursor (feed/page-before R 5 2))
|
||||||
|
nil)
|
||||||
|
|
||||||
|
(feed-test
|
||||||
|
"after cursor loads newer"
|
||||||
|
(map
|
||||||
|
(fn (a) (get a :object))
|
||||||
|
(feed/items (feed/recent (feed/after R 30))))
|
||||||
|
(list "a" "b"))
|
||||||
|
(feed-test
|
||||||
|
"before cursor count"
|
||||||
|
(feed/count (feed/before R 30))
|
||||||
|
2)
|
||||||
160
lib/feed/tests/rank.sx
Normal file
160
lib/feed/tests/rank.sx
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
; Phase 3 — aggregation + ranking. (feed-test name got expected)
|
||||||
|
|
||||||
|
; ---------- aggregation ----------
|
||||||
|
|
||||||
|
(define
|
||||||
|
A
|
||||||
|
(feed/stream
|
||||||
|
(list
|
||||||
|
(feed/activity "alice" "post" "p1" 5 (list))
|
||||||
|
(feed/activity "alice" "post" "p2" 15 (list))
|
||||||
|
(feed/activity "bob" "post" "p3" 25 (list))
|
||||||
|
(feed/activity "alice" "like" "p1" 35 (list)))))
|
||||||
|
|
||||||
|
(feed-test "actor-counts" (feed/actor-counts A) {:alice 3 :bob 1})
|
||||||
|
(feed-test "object-counts" (feed/object-counts A) {:p2 1 :p3 1 :p1 2})
|
||||||
|
(feed-test
|
||||||
|
"group-by actor alice len"
|
||||||
|
(len (get (feed/group-by A feed/actor) "alice"))
|
||||||
|
3)
|
||||||
|
(feed-test
|
||||||
|
"group-count empty"
|
||||||
|
(feed/group-count feed/empty feed/actor)
|
||||||
|
{})
|
||||||
|
|
||||||
|
; day bucketing
|
||||||
|
(define
|
||||||
|
D
|
||||||
|
(feed/stream
|
||||||
|
(list
|
||||||
|
(feed/activity "alice" "post" "p1" 5 (list))
|
||||||
|
(feed/activity "alice" "post" "p2" 8 (list))
|
||||||
|
(feed/activity "alice" "post" "p3" 12 (list)))))
|
||||||
|
|
||||||
|
(feed-test "feed/day floor" (feed/day 12 10) 1)
|
||||||
|
(feed-test "feed/day same bucket" (feed/day 8 10) 0)
|
||||||
|
(feed-test "by-actor-day" (feed/by-actor-day D 10) {:alice#0 2 :alice#1 1})
|
||||||
|
|
||||||
|
; ---------- recency ----------
|
||||||
|
|
||||||
|
(define rec (feed/recency 100 10))
|
||||||
|
(feed-test
|
||||||
|
"recency at=now -> 1"
|
||||||
|
(rec (feed/activity "x" "post" "o" 100 (list)))
|
||||||
|
1)
|
||||||
|
(feed-test
|
||||||
|
"recency age=hl -> .5"
|
||||||
|
(rec (feed/activity "x" "post" "o" 90 (list)))
|
||||||
|
0.5)
|
||||||
|
(feed-test
|
||||||
|
"recency age=2hl -> .25"
|
||||||
|
(rec (feed/activity "x" "post" "o" 80 (list)))
|
||||||
|
0.25)
|
||||||
|
|
||||||
|
; ---------- velocity ----------
|
||||||
|
|
||||||
|
(define vel (feed/velocity D 10))
|
||||||
|
(feed-test
|
||||||
|
"velocity burst (at=12)"
|
||||||
|
(vel (feed/activity "alice" "post" "z" 12 (list)))
|
||||||
|
3)
|
||||||
|
(feed-test
|
||||||
|
"velocity mid (at=8)"
|
||||||
|
(vel (feed/activity "alice" "post" "z" 8 (list)))
|
||||||
|
2)
|
||||||
|
(feed-test
|
||||||
|
"velocity first (at=5)"
|
||||||
|
(vel (feed/activity "alice" "post" "z" 5 (list)))
|
||||||
|
1)
|
||||||
|
(feed-test
|
||||||
|
"velocity other actor"
|
||||||
|
(vel (feed/activity "bob" "post" "z" 12 (list)))
|
||||||
|
0)
|
||||||
|
|
||||||
|
; ---------- engagement ----------
|
||||||
|
|
||||||
|
(define eng (feed/engagement A))
|
||||||
|
(feed-test
|
||||||
|
"engagement p1"
|
||||||
|
(eng (feed/activity "x" "post" "p1" 0 (list)))
|
||||||
|
2)
|
||||||
|
(feed-test
|
||||||
|
"engagement p2"
|
||||||
|
(eng (feed/activity "x" "post" "p2" 0 (list)))
|
||||||
|
1)
|
||||||
|
|
||||||
|
; ---------- composite ----------
|
||||||
|
|
||||||
|
(define
|
||||||
|
cmp1
|
||||||
|
(feed/composite (list (list 2 (fn (a) (get a :at))))))
|
||||||
|
(feed-test
|
||||||
|
"composite single part"
|
||||||
|
(cmp1 (feed/activity "x" "post" "o" 5 (list)))
|
||||||
|
10)
|
||||||
|
(define
|
||||||
|
cmp2
|
||||||
|
(feed/composite
|
||||||
|
(list
|
||||||
|
(list 2 (fn (a) (get a :at)))
|
||||||
|
(list 3 (fn (a) 1)))))
|
||||||
|
(feed-test
|
||||||
|
"composite two parts"
|
||||||
|
(cmp2 (feed/activity "x" "post" "o" 5 (list)))
|
||||||
|
13)
|
||||||
|
|
||||||
|
; ---------- ranking ----------
|
||||||
|
|
||||||
|
(define
|
||||||
|
R
|
||||||
|
(feed/stream
|
||||||
|
(list
|
||||||
|
(feed/activity "u" "post" "oC" 80 (list))
|
||||||
|
(feed/activity "u" "post" "oA" 100 (list))
|
||||||
|
(feed/activity "u" "post" "oB" 90 (list)))))
|
||||||
|
|
||||||
|
(feed-test
|
||||||
|
"rank by recency objects"
|
||||||
|
(map (fn (a) (get a :object)) (feed/items (feed/rank R rec)))
|
||||||
|
(list "oA" "oB" "oC"))
|
||||||
|
(feed-test
|
||||||
|
"top-2 by recency"
|
||||||
|
(map (fn (a) (get a :object)) (feed/items (feed/top R rec 2)))
|
||||||
|
(list "oA" "oB"))
|
||||||
|
(feed-test "top-2 count" (feed/count (feed/top R rec 2)) 2)
|
||||||
|
|
||||||
|
; constant score -> tiebreak by :at descending
|
||||||
|
(define
|
||||||
|
T
|
||||||
|
(feed/stream
|
||||||
|
(list
|
||||||
|
(feed/activity "u" "post" "f" 10 (list))
|
||||||
|
(feed/activity "u" "post" "g" 30 (list))
|
||||||
|
(feed/activity "u" "post" "h" 20 (list)))))
|
||||||
|
(feed-test
|
||||||
|
"tiebreak at-desc"
|
||||||
|
(map
|
||||||
|
(fn (a) (get a :object))
|
||||||
|
(feed/items (feed/rank T (fn (a) 0))))
|
||||||
|
(list "g" "h" "f"))
|
||||||
|
|
||||||
|
; equal score AND equal :at -> stable input order
|
||||||
|
(define
|
||||||
|
E
|
||||||
|
(feed/stream
|
||||||
|
(list
|
||||||
|
(feed/activity "u" "post" "first" 50 (list))
|
||||||
|
(feed/activity "u" "post" "second" 50 (list)))))
|
||||||
|
(feed-test
|
||||||
|
"stable equal-key input order"
|
||||||
|
(map
|
||||||
|
(fn (a) (get a :object))
|
||||||
|
(feed/items (feed/rank E (fn (a) 0))))
|
||||||
|
(list "first" "second"))
|
||||||
|
|
||||||
|
(feed-test
|
||||||
|
"with-scores attaches score"
|
||||||
|
(get (nth (feed/items (feed/with-scores R rec)) 1) :score)
|
||||||
|
1)
|
||||||
|
|
||||||
|
(feed-test "rank preserves count" (feed/count (feed/rank A rec)) 4)
|
||||||
49
lib/feed/tests/thread.sx
Normal file
49
lib/feed/tests/thread.sx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
; Follow-up — conversation threading via :reply-to closure. (feed-test name got expected)
|
||||||
|
|
||||||
|
(define
|
||||||
|
S
|
||||||
|
(feed/stream
|
||||||
|
(list
|
||||||
|
(feed/normalize {:actor "a" :object "root" :at 1})
|
||||||
|
(feed/normalize {:actor "b" :object "r1" :at 2 :verb "reply" :reply-to "root"})
|
||||||
|
(feed/normalize {:actor "c" :object "r2" :at 3 :verb "reply" :reply-to "root"})
|
||||||
|
(feed/normalize {:actor "d" :object "r3" :at 4 :verb "reply" :reply-to "r1"})
|
||||||
|
(feed/normalize {:actor "e" :object "x" :at 5}))))
|
||||||
|
|
||||||
|
; ---------- direct replies ----------
|
||||||
|
|
||||||
|
(feed-test "direct replies to root" (feed/reply-count S "root") 2)
|
||||||
|
(feed-test "direct replies to r1" (feed/reply-count S "r1") 1)
|
||||||
|
(feed-test "no replies to r3" (feed/reply-count S "r3") 0)
|
||||||
|
(feed-test
|
||||||
|
"replies objects to root"
|
||||||
|
(map (fn (a) (get a :object)) (feed/items (feed/replies S "root")))
|
||||||
|
(list "r1" "r2"))
|
||||||
|
|
||||||
|
; ---------- thread closure ----------
|
||||||
|
|
||||||
|
(feed-test
|
||||||
|
"thread objects root (transitive)"
|
||||||
|
(feed/thread-objects S "root")
|
||||||
|
(list "root" "r1" "r2" "r3"))
|
||||||
|
(feed-test
|
||||||
|
"thread root chronological"
|
||||||
|
(map (fn (a) (get a :object)) (feed/items (feed/thread S "root")))
|
||||||
|
(list "root" "r1" "r2" "r3"))
|
||||||
|
(feed-test "thread size root" (feed/thread-size S "root") 4)
|
||||||
|
(feed-test
|
||||||
|
"thread excludes unrelated x"
|
||||||
|
(feed/-elem?
|
||||||
|
"x"
|
||||||
|
(map (fn (a) (get a :object)) (feed/items (feed/thread S "root"))))
|
||||||
|
false)
|
||||||
|
|
||||||
|
; ---------- sub-thread ----------
|
||||||
|
|
||||||
|
(feed-test
|
||||||
|
"thread from r1 (sub-tree)"
|
||||||
|
(map (fn (a) (get a :object)) (feed/items (feed/thread S "r1")))
|
||||||
|
(list "r1" "r3"))
|
||||||
|
(feed-test "thread size r1" (feed/thread-size S "r1") 2)
|
||||||
|
(feed-test "leaf thread is itself" (feed/thread-size S "r3") 1)
|
||||||
|
(feed-test "unrelated thread is itself" (feed/thread-size S "x") 1)
|
||||||
82
lib/feed/tests/trending.sx
Normal file
82
lib/feed/tests/trending.sx
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
; Follow-up — trending objects/actors by recent activity. (feed-test name got expected)
|
||||||
|
|
||||||
|
; window (50,100]: X@60,X@70 (a), Y@80 (b), Z@90 (c); W@40 is too old
|
||||||
|
(define
|
||||||
|
S
|
||||||
|
(feed/stream
|
||||||
|
(list
|
||||||
|
(feed/activity "a" "post" "X" 60 (list))
|
||||||
|
(feed/activity "a" "post" "X" 70 (list))
|
||||||
|
(feed/activity "b" "post" "Y" 80 (list))
|
||||||
|
(feed/activity "c" "post" "Z" 90 (list))
|
||||||
|
(feed/activity "d" "post" "W" 40 (list)))))
|
||||||
|
|
||||||
|
; ---------- trending objects ----------
|
||||||
|
|
||||||
|
(feed-test
|
||||||
|
"trending count (3 in window)"
|
||||||
|
(len (feed/trending S 100 50 10))
|
||||||
|
3)
|
||||||
|
(feed-test
|
||||||
|
"trending top object"
|
||||||
|
(get
|
||||||
|
(nth (feed/trending S 100 50 10) 0)
|
||||||
|
:object)
|
||||||
|
"X")
|
||||||
|
(feed-test
|
||||||
|
"trending top count"
|
||||||
|
(get
|
||||||
|
(nth (feed/trending S 100 50 10) 0)
|
||||||
|
:count)
|
||||||
|
2)
|
||||||
|
(feed-test
|
||||||
|
"trending order (count desc, key asc tiebreak)"
|
||||||
|
(map
|
||||||
|
(fn (e) (get e :object))
|
||||||
|
(feed/trending S 100 50 10))
|
||||||
|
(list "X" "Y" "Z"))
|
||||||
|
(feed-test
|
||||||
|
"trending top-2"
|
||||||
|
(map
|
||||||
|
(fn (e) (get e :object))
|
||||||
|
(feed/trending S 100 50 2))
|
||||||
|
(list "X" "Y"))
|
||||||
|
(feed-test
|
||||||
|
"old object W excluded"
|
||||||
|
(feed/-elem?
|
||||||
|
"W"
|
||||||
|
(map
|
||||||
|
(fn (e) (get e :object))
|
||||||
|
(feed/trending S 100 50 10)))
|
||||||
|
false)
|
||||||
|
(feed-test
|
||||||
|
"narrow window keeps only newest"
|
||||||
|
(map
|
||||||
|
(fn (e) (get e :object))
|
||||||
|
(feed/trending S 100 15 10))
|
||||||
|
(list "Z"))
|
||||||
|
(feed-test
|
||||||
|
"empty window -> nothing"
|
||||||
|
(feed/trending S 100 5 10)
|
||||||
|
(list))
|
||||||
|
|
||||||
|
; ---------- trending actors ----------
|
||||||
|
|
||||||
|
(feed-test
|
||||||
|
"trending actor top"
|
||||||
|
(get
|
||||||
|
(nth (feed/trending-actors S 100 50 10) 0)
|
||||||
|
:actor)
|
||||||
|
"a")
|
||||||
|
(feed-test
|
||||||
|
"trending actor count"
|
||||||
|
(get
|
||||||
|
(nth (feed/trending-actors S 100 50 10) 0)
|
||||||
|
:count)
|
||||||
|
2)
|
||||||
|
(feed-test
|
||||||
|
"trending actors order"
|
||||||
|
(map
|
||||||
|
(fn (e) (get e :actor))
|
||||||
|
(feed/trending-actors S 100 50 10))
|
||||||
|
(list "a" "b" "c"))
|
||||||
59
lib/feed/thread.sx
Normal file
59
lib/feed/thread.sx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
; feed/thread — conversation threading. A reply carries :reply-to <parent-object>
|
||||||
|
; (normalize preserves it). A thread is the transitive closure over :reply-to from
|
||||||
|
; a root object: root + replies + replies-to-replies, gathered chronologically.
|
||||||
|
;
|
||||||
|
; Requires: lib/feed/normalize.sx, lib/feed/stream.sx, lib/feed/fanout.sx
|
||||||
|
; (feed/-elem?, feed/-distinct).
|
||||||
|
|
||||||
|
; direct replies to an object
|
||||||
|
(define
|
||||||
|
feed/replies
|
||||||
|
(fn
|
||||||
|
(stream object)
|
||||||
|
(feed/filter stream (fn (a) (equal? (get a :reply-to) object)))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
feed/reply-count
|
||||||
|
(fn (stream object) (feed/count (feed/replies stream object))))
|
||||||
|
|
||||||
|
; iterate f from x until the result stops growing (set-closure fixpoint)
|
||||||
|
(define
|
||||||
|
feed/-fixpoint
|
||||||
|
(fn
|
||||||
|
(f x)
|
||||||
|
(let
|
||||||
|
((nx (f x)))
|
||||||
|
(if (= (len nx) (len x)) x (feed/-fixpoint f nx)))))
|
||||||
|
|
||||||
|
; the set of object-ids in the thread rooted at `root`
|
||||||
|
(define
|
||||||
|
feed/thread-objects
|
||||||
|
(fn
|
||||||
|
(stream root)
|
||||||
|
(let
|
||||||
|
((all (feed/items stream)))
|
||||||
|
(feed/-fixpoint
|
||||||
|
(fn
|
||||||
|
(acc)
|
||||||
|
(feed/-distinct
|
||||||
|
(append
|
||||||
|
acc
|
||||||
|
(map
|
||||||
|
(fn (a) (get a :object))
|
||||||
|
(filter (fn (a) (feed/-elem? (get a :reply-to) acc)) all)))))
|
||||||
|
(list root)))))
|
||||||
|
|
||||||
|
; the full thread as a chronological stream (root + all descendants)
|
||||||
|
(define
|
||||||
|
feed/thread
|
||||||
|
(fn
|
||||||
|
(stream root)
|
||||||
|
(let
|
||||||
|
((objs (feed/thread-objects stream root)))
|
||||||
|
(feed/sort-by-at
|
||||||
|
(feed/filter stream (fn (a) (feed/-elem? (get a :object) objs)))))))
|
||||||
|
|
||||||
|
; how many activities are in the thread (root counts as 1)
|
||||||
|
(define
|
||||||
|
feed/thread-size
|
||||||
|
(fn (stream root) (feed/count (feed/thread stream root))))
|
||||||
42
lib/feed/trending.sx
Normal file
42
lib/feed/trending.sx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
; feed/trending — what's hot right now: objects (or actors) ranked by activity
|
||||||
|
; count within a recency window. Deterministic: count descending, ties broken by
|
||||||
|
; key ascending (entries are pre-sorted by key, then stable grade-down by count).
|
||||||
|
;
|
||||||
|
; Requires: lib/feed/stream.sx, lib/feed/aggregate.sx (object/actor-counts),
|
||||||
|
; lib/feed/rank.sx (feed/-desc-by).
|
||||||
|
|
||||||
|
; activities within (now-window, now]
|
||||||
|
(define
|
||||||
|
feed/-recent
|
||||||
|
(fn
|
||||||
|
(stream now window)
|
||||||
|
(feed/filter
|
||||||
|
stream
|
||||||
|
(fn (a) (and (<= (get a :at) now) (> (get a :at) (- now window)))))))
|
||||||
|
|
||||||
|
; counts dict -> top-N entries {label key, :count n}, count desc, key asc
|
||||||
|
(define
|
||||||
|
feed/-top-counts
|
||||||
|
(fn
|
||||||
|
(counts label n)
|
||||||
|
(let
|
||||||
|
((entries (map (fn (k) (assoc {:count (get counts k)} label k)) (sort (keys counts)))))
|
||||||
|
(take (feed/-desc-by entries (fn (e) (get e :count))) n))))
|
||||||
|
|
||||||
|
; top-N trending objects in the window
|
||||||
|
(define
|
||||||
|
feed/trending
|
||||||
|
(fn
|
||||||
|
(stream now window n)
|
||||||
|
(feed/-top-counts
|
||||||
|
(feed/object-counts (feed/-recent stream now window))
|
||||||
|
:object n)))
|
||||||
|
|
||||||
|
; top-N most active actors in the window
|
||||||
|
(define
|
||||||
|
feed/trending-actors
|
||||||
|
(fn
|
||||||
|
(stream now window n)
|
||||||
|
(feed/-top-counts
|
||||||
|
(feed/actor-counts (feed/-recent stream now window))
|
||||||
|
:actor n)))
|
||||||
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}))
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user