Compare commits
27 Commits
loops/rada
...
loops/mod
| Author | SHA1 | Date | |
|---|---|---|---|
| 2913cdc3a8 | |||
| 538b8a53e0 | |||
| 739e743918 | |||
| c19f658cf2 | |||
| 2f75ab11fc | |||
| 82fbf01bb3 | |||
| 329b3c4903 | |||
| b43901d297 | |||
| 68c8e39508 | |||
| 92addf5146 | |||
| 8292607e38 | |||
| bf65de7b24 | |||
| 3764b62206 | |||
| 062a76e64f | |||
| 50eb7079e5 | |||
| c3668e4461 | |||
| 01be84b5d8 | |||
| e53a292f1a | |||
| 3d2c1d94f2 | |||
| 102c806451 | |||
| 779a592614 | |||
| 2ea87796a1 | |||
| ee9851c063 | |||
| f4f34c1d33 | |||
| 6e825e1283 | |||
| 8dfc987095 | |||
| 72174941aa |
@@ -1,63 +0,0 @@
|
|||||||
# 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,5 +1,116 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# lib/apl/conformance.sh — APL conformance via the shared guest driver.
|
# lib/apl/conformance.sh — run APL test suites, emit scoreboard.json + scoreboard.md.
|
||||||
# Config lives in lib/apl/conformance.conf (MODE=counters). Override the binary
|
|
||||||
# with SX_SERVER=path/to/sx_server.exe bash lib/apl/conformance.sh
|
set -uo pipefail
|
||||||
exec bash "$(dirname "$0")/../guest/conformance.sh" "$(dirname "$0")/conformance.conf" "$@"
|
cd "$(git rev-parse --show-toplevel)"
|
||||||
|
|
||||||
|
SX_SERVER="${SX_SERVER:-/root/rose-ash/hosts/ocaml/_build/default/bin/sx_server.exe}"
|
||||||
|
if [ ! -x "$SX_SERVER" ]; then
|
||||||
|
SX_SERVER="hosts/ocaml/_build/default/bin/sx_server.exe"
|
||||||
|
fi
|
||||||
|
if [ ! -x "$SX_SERVER" ]; then
|
||||||
|
echo "ERROR: sx_server.exe not found." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
SUITES=(structural operators dfn tradfn valence programs system idioms eval-ops pipeline)
|
||||||
|
|
||||||
|
OUT_JSON="lib/apl/scoreboard.json"
|
||||||
|
OUT_MD="lib/apl/scoreboard.md"
|
||||||
|
|
||||||
|
run_suite() {
|
||||||
|
local suite=$1
|
||||||
|
local file="lib/apl/tests/${suite}.sx"
|
||||||
|
local TMP
|
||||||
|
TMP=$(mktemp)
|
||||||
|
cat > "$TMP" << EPOCHS
|
||||||
|
(epoch 1)
|
||||||
|
(load "spec/stdlib.sx")
|
||||||
|
(load "lib/r7rs.sx")
|
||||||
|
(load "lib/apl/runtime.sx")
|
||||||
|
(load "lib/apl/tokenizer.sx")
|
||||||
|
(load "lib/apl/parser.sx")
|
||||||
|
(load "lib/apl/transpile.sx")
|
||||||
|
(epoch 2)
|
||||||
|
(eval "(define apl-test-pass 0)")
|
||||||
|
(eval "(define apl-test-fail 0)")
|
||||||
|
(eval "(define apl-test (fn (name got expected) (if (= got expected) (set! apl-test-pass (+ apl-test-pass 1)) (set! apl-test-fail (+ apl-test-fail 1)))))")
|
||||||
|
(epoch 3)
|
||||||
|
(load "${file}")
|
||||||
|
(epoch 4)
|
||||||
|
(eval "(list apl-test-pass apl-test-fail)")
|
||||||
|
EPOCHS
|
||||||
|
|
||||||
|
local OUTPUT
|
||||||
|
OUTPUT=$(timeout 300 "$SX_SERVER" < "$TMP" 2>/dev/null)
|
||||||
|
rm -f "$TMP"
|
||||||
|
|
||||||
|
local LINE
|
||||||
|
LINE=$(echo "$OUTPUT" | awk '/^\(ok-len 4 / {getline; print; exit}')
|
||||||
|
if [ -z "$LINE" ]; then
|
||||||
|
LINE=$(echo "$OUTPUT" | grep -E '^\(ok 4 \([0-9]+ [0-9]+\)\)' | tail -1 \
|
||||||
|
| sed -E 's/^\(ok 4 //; s/\)$//')
|
||||||
|
fi
|
||||||
|
|
||||||
|
local P F
|
||||||
|
P=$(echo "$LINE" | sed -E 's/^\(([0-9]+) ([0-9]+)\).*/\1/')
|
||||||
|
F=$(echo "$LINE" | sed -E 's/^\(([0-9]+) ([0-9]+)\).*/\2/')
|
||||||
|
P=${P:-0}
|
||||||
|
F=${F:-0}
|
||||||
|
echo "${P} ${F}"
|
||||||
|
}
|
||||||
|
|
||||||
|
declare -A SUITE_PASS
|
||||||
|
declare -A SUITE_FAIL
|
||||||
|
TOTAL_PASS=0
|
||||||
|
TOTAL_FAIL=0
|
||||||
|
|
||||||
|
echo "Running APL conformance suite..." >&2
|
||||||
|
for s in "${SUITES[@]}"; do
|
||||||
|
read -r p f < <(run_suite "$s")
|
||||||
|
SUITE_PASS[$s]=$p
|
||||||
|
SUITE_FAIL[$s]=$f
|
||||||
|
TOTAL_PASS=$((TOTAL_PASS + p))
|
||||||
|
TOTAL_FAIL=$((TOTAL_FAIL + f))
|
||||||
|
printf " %-12s %d/%d\n" "$s" "$p" "$((p+f))" >&2
|
||||||
|
done
|
||||||
|
|
||||||
|
# scoreboard.json
|
||||||
|
{
|
||||||
|
printf '{\n'
|
||||||
|
printf ' "suites": {\n'
|
||||||
|
first=1
|
||||||
|
for s in "${SUITES[@]}"; do
|
||||||
|
if [ $first -eq 0 ]; then printf ',\n'; fi
|
||||||
|
printf ' "%s": {"pass": %d, "fail": %d}' "$s" "${SUITE_PASS[$s]}" "${SUITE_FAIL[$s]}"
|
||||||
|
first=0
|
||||||
|
done
|
||||||
|
printf '\n },\n'
|
||||||
|
printf ' "total_pass": %d,\n' "$TOTAL_PASS"
|
||||||
|
printf ' "total_fail": %d,\n' "$TOTAL_FAIL"
|
||||||
|
printf ' "total": %d\n' "$((TOTAL_PASS + TOTAL_FAIL))"
|
||||||
|
printf '}\n'
|
||||||
|
} > "$OUT_JSON"
|
||||||
|
|
||||||
|
# scoreboard.md
|
||||||
|
{
|
||||||
|
printf '# APL Conformance Scoreboard\n\n'
|
||||||
|
printf '_Generated by `lib/apl/conformance.sh`_\n\n'
|
||||||
|
printf '| Suite | Pass | Fail | Total |\n'
|
||||||
|
printf '|-------|-----:|-----:|------:|\n'
|
||||||
|
for s in "${SUITES[@]}"; do
|
||||||
|
p=${SUITE_PASS[$s]}
|
||||||
|
f=${SUITE_FAIL[$s]}
|
||||||
|
printf '| %s | %d | %d | %d |\n' "$s" "$p" "$f" "$((p+f))"
|
||||||
|
done
|
||||||
|
printf '| **Total** | **%d** | **%d** | **%d** |\n' "$TOTAL_PASS" "$TOTAL_FAIL" "$((TOTAL_PASS + TOTAL_FAIL))"
|
||||||
|
printf '\n'
|
||||||
|
printf '## Notes\n\n'
|
||||||
|
printf '%s\n' '- Suites use the standard `apl-test name got expected` framework loaded against `lib/apl/runtime.sx` + `lib/apl/transpile.sx`.'
|
||||||
|
printf '%s\n' '- `lib/apl/tests/parse.sx` and `lib/apl/tests/scalar.sx` use their own self-contained frameworks and are excluded from this scoreboard.'
|
||||||
|
} > "$OUT_MD"
|
||||||
|
|
||||||
|
echo "Wrote $OUT_JSON and $OUT_MD" >&2
|
||||||
|
echo "Total: $TOTAL_PASS pass, $TOTAL_FAIL fail" >&2
|
||||||
|
|
||||||
|
[ "$TOTAL_FAIL" -eq 0 ]
|
||||||
|
|||||||
@@ -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": 152, "fail": 0}
|
"pipeline": {"pass": 40, "fail": 0}
|
||||||
},
|
},
|
||||||
"total_pass": 562,
|
"total_pass": 450,
|
||||||
"total_fail": 0,
|
"total_fail": 0,
|
||||||
"total": 562
|
"total": 450
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 | 152 | 0 | 152 |
|
| pipeline | 40 | 0 | 40 |
|
||||||
| **Total** | **562** | **0** | **562** |
|
| **Total** | **450** | **0** | **450** |
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
; 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)))))
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
; 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)))
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
; 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)))
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
; 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)))
|
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
#!/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 ]
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
; 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))))
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
; 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)))
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
; 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)))))
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
; 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)))))
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
; 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)))
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
; 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)))))
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
; 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))))
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
; 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)))))))
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
; 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)))))
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
; 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)))
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
{
|
|
||||||
"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
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
# 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** |
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
; 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)))))
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
; 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)
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
; 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)
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
; 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"))
|
|
||||||
@@ -1,187 +0,0 @@
|
|||||||
; 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)
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
; 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"))
|
|
||||||
@@ -1,155 +0,0 @@
|
|||||||
; 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"))
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
; 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"))
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
; 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))
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
; 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)
|
|
||||||
@@ -1,160 +0,0 @@
|
|||||||
; 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)
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
; 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)
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
; 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"))
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
; 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))))
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
; 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)))
|
|
||||||
40
lib/mod/activity.sx
Normal file
40
lib/mod/activity.sx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
;; lib/mod/activity.sx — export decisions as ActivityPub-shaped events.
|
||||||
|
;;
|
||||||
|
;; The rose-ash platform propagates cross-domain effects as ActivityPub-shaped
|
||||||
|
;; activities. A moderation decision maps to a moderation verb so the rest of the
|
||||||
|
;; platform (and federated peers) can act on it: remove→Delete, ban→Block,
|
||||||
|
;; hide/escalate→Flag, keep→no activity. The precise mod action is preserved in
|
||||||
|
;; :action so a consumer can disambiguate (e.g. hide vs escalate, both Flag).
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/action->verb
|
||||||
|
(fn
|
||||||
|
(action)
|
||||||
|
(cond
|
||||||
|
((= action "remove") "Delete")
|
||||||
|
((= action "ban") "Block")
|
||||||
|
((= action "hide") "Flag")
|
||||||
|
((= action "escalate") "Flag")
|
||||||
|
(true nil))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/decision->activity
|
||||||
|
(fn
|
||||||
|
(d actor)
|
||||||
|
(let
|
||||||
|
((verb (mod/action->verb (get d :action))))
|
||||||
|
(if (nil? verb) nil {:type verb :action (get d :action) :actor actor :summary (str "moderation/" (get d :action) " via " (get d :rule)) :object (get d :report-id) :rule (get d :rule)}))))
|
||||||
|
|
||||||
|
;; map a batch of decisions to activities, dropping the no-op keeps
|
||||||
|
(define
|
||||||
|
mod/decisions->activities
|
||||||
|
(fn
|
||||||
|
(decisions actor)
|
||||||
|
(reduce
|
||||||
|
(fn
|
||||||
|
(acc d)
|
||||||
|
(let
|
||||||
|
((a (mod/decision->activity d actor)))
|
||||||
|
(if (nil? a) acc (append acc (list a)))))
|
||||||
|
(list)
|
||||||
|
decisions)))
|
||||||
163
lib/mod/api.sx
Normal file
163
lib/mod/api.sx
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
;; lib/mod/api.sx — report registry + lifecycle façade + public entry points.
|
||||||
|
;;
|
||||||
|
;; mod/report files a report (assigning a sequential id) and opens a lifecycle
|
||||||
|
;; case for it; mod/add-evidence accumulates evidence; mod/decide runs the engine
|
||||||
|
;; and commits to the audit log. The lifecycle façade (mod/triage, mod/resolve,
|
||||||
|
;; mod/review, mod/appeal, mod/finalize) drives the per-report case through its
|
||||||
|
;; states, logging each committed decision to the audit trail.
|
||||||
|
|
||||||
|
(define mod/*reports* (list))
|
||||||
|
(define mod/*cases* (list))
|
||||||
|
(define mod/*counter* 0)
|
||||||
|
(define mod/*rules* mod/default-rules)
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/reset!
|
||||||
|
(fn
|
||||||
|
()
|
||||||
|
(begin
|
||||||
|
(set! mod/*reports* (list))
|
||||||
|
(set! mod/*cases* (list))
|
||||||
|
(set! mod/*counter* 0)
|
||||||
|
(mod/audit-reset!))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/report
|
||||||
|
(fn
|
||||||
|
(by about reason)
|
||||||
|
(begin
|
||||||
|
(set! mod/*counter* (+ mod/*counter* 1))
|
||||||
|
(let
|
||||||
|
((id (str "r" mod/*counter*)))
|
||||||
|
(let
|
||||||
|
((r (mod/mk-report id by about reason)))
|
||||||
|
(begin
|
||||||
|
(append! mod/*reports* r)
|
||||||
|
(append! mod/*cases* {:id id :case (mod/mk-case r)})
|
||||||
|
r))))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/get-report
|
||||||
|
(fn
|
||||||
|
(id)
|
||||||
|
(reduce
|
||||||
|
(fn (acc r) (if (= (mod/report-id r) id) r acc))
|
||||||
|
nil
|
||||||
|
mod/*reports*)))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/add-evidence
|
||||||
|
(fn
|
||||||
|
(id kind val)
|
||||||
|
(let
|
||||||
|
((r (mod/get-report id)))
|
||||||
|
(if
|
||||||
|
(nil? r)
|
||||||
|
nil
|
||||||
|
(let
|
||||||
|
((updated (mod/attach-evidence r (mod/mk-evidence kind val))))
|
||||||
|
(begin
|
||||||
|
(set!
|
||||||
|
mod/*reports*
|
||||||
|
(map
|
||||||
|
(fn (x) (if (= (mod/report-id x) id) updated x))
|
||||||
|
mod/*reports*))
|
||||||
|
updated))))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/decide
|
||||||
|
(fn
|
||||||
|
(id)
|
||||||
|
(let
|
||||||
|
((r (mod/get-report id)))
|
||||||
|
(if
|
||||||
|
(nil? r)
|
||||||
|
nil
|
||||||
|
(let
|
||||||
|
((d (mod/decide-report r mod/*reports* mod/*rules*)))
|
||||||
|
(begin (mod/log-decision! d (mod/report-evidence r)) d))))))
|
||||||
|
|
||||||
|
;; ── lifecycle façade over the case registry ──
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/case-of
|
||||||
|
(fn
|
||||||
|
(id)
|
||||||
|
(reduce
|
||||||
|
(fn (acc rec) (if (= (get rec :id) id) (get rec :case) acc))
|
||||||
|
nil
|
||||||
|
mod/*cases*)))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/case-store!
|
||||||
|
(fn
|
||||||
|
(id c)
|
||||||
|
(set!
|
||||||
|
mod/*cases*
|
||||||
|
(map
|
||||||
|
(fn (rec) (if (= (get rec :id) id) {:id id :case c} rec))
|
||||||
|
mod/*cases*))))
|
||||||
|
|
||||||
|
;; apply a lifecycle op to the stored case, persist it, and (when a decision was
|
||||||
|
;; committed cleanly) append it to the audit log; returns the updated case
|
||||||
|
(define
|
||||||
|
mod/case-apply!
|
||||||
|
(fn
|
||||||
|
(id op log?)
|
||||||
|
(let
|
||||||
|
((c (mod/case-of id)))
|
||||||
|
(if
|
||||||
|
(nil? c)
|
||||||
|
nil
|
||||||
|
(let
|
||||||
|
((c2 (op c)))
|
||||||
|
(begin
|
||||||
|
(mod/case-store! id c2)
|
||||||
|
(when
|
||||||
|
log?
|
||||||
|
(when
|
||||||
|
(nil? (mod/case-error c2))
|
||||||
|
(let
|
||||||
|
((d (mod/case-decision c2)))
|
||||||
|
(if
|
||||||
|
(nil? d)
|
||||||
|
nil
|
||||||
|
(mod/log-decision!
|
||||||
|
d
|
||||||
|
(mod/report-evidence (mod/case-report c2)))))))
|
||||||
|
c2))))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/triage
|
||||||
|
(fn
|
||||||
|
(id)
|
||||||
|
(mod/case-apply!
|
||||||
|
id
|
||||||
|
(fn (c) (mod/case-triage c mod/*reports* mod/*rules*))
|
||||||
|
false)))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/resolve
|
||||||
|
(fn (id) (mod/case-apply! id (fn (c) (mod/case-resolve c)) true)))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/review
|
||||||
|
(fn
|
||||||
|
(id kind val)
|
||||||
|
(mod/case-apply!
|
||||||
|
id
|
||||||
|
(fn (c) (mod/case-review c kind val mod/*reports* mod/*rules*))
|
||||||
|
true)))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/appeal
|
||||||
|
(fn
|
||||||
|
(id kind val)
|
||||||
|
(mod/case-apply!
|
||||||
|
id
|
||||||
|
(fn (c) (mod/case-appeal c kind val mod/*reports* mod/*rules*))
|
||||||
|
true)))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/finalize
|
||||||
|
(fn (id) (mod/case-apply! id (fn (c) (mod/case-finalize c)) false)))
|
||||||
54
lib/mod/audit.sx
Normal file
54
lib/mod/audit.sx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
;; lib/mod/audit.sx — append-only decision log.
|
||||||
|
;;
|
||||||
|
;; Every decision the api commits is recorded as an immutable audit entry holding
|
||||||
|
;; the decision (action + matching rule), the proof tree (the derivation that
|
||||||
|
;; justified it), and a snapshot of the evidence in force at decision time. The
|
||||||
|
;; log is append-only: entries are never mutated or removed, only appended, each
|
||||||
|
;; with a monotonic sequence number. Retrieval is by report id (full history) or
|
||||||
|
;; by sequence.
|
||||||
|
|
||||||
|
(define mod/*audit-log* (list))
|
||||||
|
(define mod/*audit-seq* 0)
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/audit-reset!
|
||||||
|
(fn
|
||||||
|
()
|
||||||
|
(begin (set! mod/*audit-log* (list)) (set! mod/*audit-seq* 0))))
|
||||||
|
|
||||||
|
(define mod/mk-audit-entry (fn (seq decision evidence-snapshot) {:action (get decision :action) :evidence evidence-snapshot :proof (get decision :proof) :rule (get decision :rule) :report-id (get decision :report-id) :seq seq}))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/log-decision!
|
||||||
|
(fn
|
||||||
|
(decision evidence-snapshot)
|
||||||
|
(begin
|
||||||
|
(set! mod/*audit-seq* (+ mod/*audit-seq* 1))
|
||||||
|
(let
|
||||||
|
((entry (mod/mk-audit-entry mod/*audit-seq* decision evidence-snapshot)))
|
||||||
|
(begin (append! mod/*audit-log* entry) entry)))))
|
||||||
|
|
||||||
|
;; entries for one report, in chronological (sequence) order
|
||||||
|
(define
|
||||||
|
mod/audit
|
||||||
|
(fn
|
||||||
|
(id)
|
||||||
|
(reduce
|
||||||
|
(fn
|
||||||
|
(acc e)
|
||||||
|
(if (= (get e :report-id) id) (append acc (list e)) acc))
|
||||||
|
(list)
|
||||||
|
mod/*audit-log*)))
|
||||||
|
|
||||||
|
(define mod/audit-all (fn () mod/*audit-log*))
|
||||||
|
(define mod/audit-count (fn () (len mod/*audit-log*)))
|
||||||
|
|
||||||
|
;; most recent decision logged for a report (nil if none)
|
||||||
|
(define
|
||||||
|
mod/audit-latest
|
||||||
|
(fn
|
||||||
|
(id)
|
||||||
|
(reduce
|
||||||
|
(fn (acc e) (if (= (get e :report-id) id) e acc))
|
||||||
|
nil
|
||||||
|
mod/*audit-log*)))
|
||||||
55
lib/mod/batch.sx
Normal file
55
lib/mod/batch.sx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
;; lib/mod/batch.sx — batch triage + corpus analytics.
|
||||||
|
;;
|
||||||
|
;; Operational layer: decide a whole queue of reports at once, summarize the
|
||||||
|
;; outcomes by action, and measure which rules actually fire across a corpus.
|
||||||
|
;; mod/never-fired is the empirical complement to lint's static unreachable check
|
||||||
|
;; (Ext 5): lint finds rules that CAN'T fire by structure; never-fired finds rules
|
||||||
|
;; that DIDN'T fire on real data.
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/decide-batch
|
||||||
|
(fn
|
||||||
|
(reports rules)
|
||||||
|
(map (fn (r) (mod/decide-report r reports rules)) reports)))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/count-action
|
||||||
|
(fn
|
||||||
|
(decisions action)
|
||||||
|
(reduce
|
||||||
|
(fn (acc d) (if (= (get d :action) action) (+ acc 1) acc))
|
||||||
|
0
|
||||||
|
decisions)))
|
||||||
|
|
||||||
|
(define mod/action-histogram (fn (decisions) {:keep (mod/count-action decisions "keep") :remove (mod/count-action decisions "remove") :escalate (mod/count-action decisions "escalate") :hide (mod/count-action decisions "hide") :ban (mod/count-action decisions "ban")}))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/rule-fire-count
|
||||||
|
(fn
|
||||||
|
(decisions rule-name)
|
||||||
|
(reduce
|
||||||
|
(fn (acc d) (if (= (get d :rule) rule-name) (+ acc 1) acc))
|
||||||
|
0
|
||||||
|
decisions)))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/rule-coverage
|
||||||
|
(fn
|
||||||
|
(reports rules)
|
||||||
|
(let
|
||||||
|
((decisions (mod/decide-batch reports rules)))
|
||||||
|
(map (fn (rule) {:rule (mod/rule-name rule) :fired (mod/rule-fire-count decisions (mod/rule-name rule))}) rules))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/never-fired
|
||||||
|
(fn
|
||||||
|
(reports rules)
|
||||||
|
(reduce
|
||||||
|
(fn
|
||||||
|
(acc c)
|
||||||
|
(if
|
||||||
|
(= (get c :fired) 0)
|
||||||
|
(append acc (list (get c :rule)))
|
||||||
|
acc))
|
||||||
|
(list)
|
||||||
|
(mod/rule-coverage reports rules))))
|
||||||
60
lib/mod/conformance.conf
Normal file
60
lib/mod/conformance.conf
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
# Mod conformance config — sourced by lib/guest/conformance.sh.
|
||||||
|
|
||||||
|
LANG_NAME=mod
|
||||||
|
MODE=dict
|
||||||
|
|
||||||
|
PRELOADS=(
|
||||||
|
lib/guest/pratt.sx
|
||||||
|
lib/prolog/tokenizer.sx
|
||||||
|
lib/prolog/parser.sx
|
||||||
|
lib/prolog/runtime.sx
|
||||||
|
lib/prolog/query.sx
|
||||||
|
lib/prolog/compiler.sx
|
||||||
|
lib/mod/schema.sx
|
||||||
|
lib/mod/policy.sx
|
||||||
|
lib/mod/defrule.sx
|
||||||
|
lib/mod/engine.sx
|
||||||
|
lib/mod/explain.sx
|
||||||
|
lib/mod/severity.sx
|
||||||
|
lib/mod/offenders.sx
|
||||||
|
lib/mod/quorum.sx
|
||||||
|
lib/mod/trace.sx
|
||||||
|
lib/mod/whatif.sx
|
||||||
|
lib/mod/batch.sx
|
||||||
|
lib/mod/temporal.sx
|
||||||
|
lib/mod/sla.sx
|
||||||
|
lib/mod/wire.sx
|
||||||
|
lib/mod/activity.sx
|
||||||
|
lib/mod/policies.sx
|
||||||
|
lib/mod/pipeline.sx
|
||||||
|
lib/mod/lifecycle.sx
|
||||||
|
lib/mod/audit.sx
|
||||||
|
lib/mod/api.sx
|
||||||
|
lib/mod/fed.sx
|
||||||
|
lib/mod/link.sx
|
||||||
|
lib/mod/lint.sx
|
||||||
|
)
|
||||||
|
|
||||||
|
SUITES=(
|
||||||
|
"decide:lib/mod/tests/decide.sx:(mod-decide-tests-run!)"
|
||||||
|
"audit:lib/mod/tests/audit.sx:(mod-audit-tests-run!)"
|
||||||
|
"escalation:lib/mod/tests/escalation.sx:(mod-escalation-tests-run!)"
|
||||||
|
"fed:lib/mod/tests/fed.sx:(mod-fed-tests-run!)"
|
||||||
|
"extensions:lib/mod/tests/extensions.sx:(mod-extensions-tests-run!)"
|
||||||
|
"link:lib/mod/tests/link.sx:(mod-link-tests-run!)"
|
||||||
|
"lint:lib/mod/tests/lint.sx:(mod-lint-tests-run!)"
|
||||||
|
"severity:lib/mod/tests/severity.sx:(mod-severity-tests-run!)"
|
||||||
|
"offenders:lib/mod/tests/offenders.sx:(mod-offenders-tests-run!)"
|
||||||
|
"quorum:lib/mod/tests/quorum.sx:(mod-quorum-tests-run!)"
|
||||||
|
"trace:lib/mod/tests/trace.sx:(mod-trace-tests-run!)"
|
||||||
|
"whatif:lib/mod/tests/whatif.sx:(mod-whatif-tests-run!)"
|
||||||
|
"batch:lib/mod/tests/batch.sx:(mod-batch-tests-run!)"
|
||||||
|
"temporal:lib/mod/tests/temporal.sx:(mod-temporal-tests-run!)"
|
||||||
|
"sla:lib/mod/tests/sla.sx:(mod-sla-tests-run!)"
|
||||||
|
"wire:lib/mod/tests/wire.sx:(mod-wire-tests-run!)"
|
||||||
|
"disjunction:lib/mod/tests/disjunction.sx:(mod-disjunction-tests-run!)"
|
||||||
|
"activity:lib/mod/tests/activity.sx:(mod-activity-tests-run!)"
|
||||||
|
"policies:lib/mod/tests/policies.sx:(mod-policies-tests-run!)"
|
||||||
|
"defrule:lib/mod/tests/defrule.sx:(mod-defrule-tests-run!)"
|
||||||
|
"pipeline:lib/mod/tests/pipeline.sx:(mod-pipeline-tests-run!)"
|
||||||
|
)
|
||||||
3
lib/mod/conformance.sh
Executable file
3
lib/mod/conformance.sh
Executable file
@@ -0,0 +1,3 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Thin wrapper — see lib/guest/conformance.sh and lib/mod/conformance.conf.
|
||||||
|
exec bash "$(dirname "$0")/../guest/conformance.sh" "$(dirname "$0")/conformance.conf" "$@"
|
||||||
16
lib/mod/defrule.sx
Normal file
16
lib/mod/defrule.sx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
;; lib/mod/defrule.sx — ergonomic rule / ruleset construction.
|
||||||
|
;;
|
||||||
|
;; The roadmap sketched a (defrule action :when conditions) surface. Conditions
|
||||||
|
;; already evaluate to plain data, so this needs no macro — variadic functions
|
||||||
|
;; suffice: mod/defrule collects its trailing condition forms via &rest (dropping
|
||||||
|
;; the explicit outer (list ...)), and mod/ruleset assembles rules the same way.
|
||||||
|
;;
|
||||||
|
;; (mod/ruleset
|
||||||
|
;; (mod/defrule "spam-hide" :hide (list :classification "spam"))
|
||||||
|
;; (mod/defrule "default-keep" :keep))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/defrule
|
||||||
|
(fn (name action &rest conds) (mod/mk-rule name action conds)))
|
||||||
|
|
||||||
|
(define mod/ruleset (fn (&rest rules) rules))
|
||||||
64
lib/mod/engine.sx
Normal file
64
lib/mod/engine.sx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
;; lib/mod/engine.sx — decide a report by querying the policy program.
|
||||||
|
;;
|
||||||
|
;; build-program assembles the report's facts plus the compiled policy clauses;
|
||||||
|
;; decide-report runs the Prolog query and returns a decision. A decision is a
|
||||||
|
;; proof, not a bare keyword: it carries the matching rule, the conditions it
|
||||||
|
;; required, the evidence that satisfied them, and a derivation — the proof tree.
|
||||||
|
;;
|
||||||
|
;; The proof tree is built constructively: for the matching rule, each body goal
|
||||||
|
;; is re-queried against the same DB with the report id bound, recording the goal
|
||||||
|
;; text, whether it was solved, and the bindings that satisfied it. That is a
|
||||||
|
;; genuine derivation drawn from the Prolog database, ready for the audit trail.
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/find-rule
|
||||||
|
(fn
|
||||||
|
(rules name)
|
||||||
|
(reduce
|
||||||
|
(fn
|
||||||
|
(acc r)
|
||||||
|
(if (nil? acc) (if (= (mod/rule-name r) name) r acc) acc))
|
||||||
|
nil
|
||||||
|
rules)))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/build-program
|
||||||
|
(fn
|
||||||
|
(r count rules)
|
||||||
|
(str (mod/report-facts r count) "\n" (mod/rules->program rules))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/proof-goals
|
||||||
|
(fn
|
||||||
|
(db id conds)
|
||||||
|
(if
|
||||||
|
(empty? conds)
|
||||||
|
(list {:solved true :goal "true" :bindings {}})
|
||||||
|
(map
|
||||||
|
(fn
|
||||||
|
(c)
|
||||||
|
(let
|
||||||
|
((g (mod/cond->goal c id)))
|
||||||
|
(let ((sols (pl-query-all db g))) {:solved (if (empty? sols) false true) :goal g :bindings (if (empty? sols) {} (first sols))})))
|
||||||
|
conds))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/decide-report
|
||||||
|
(fn
|
||||||
|
(r reports rules)
|
||||||
|
(let
|
||||||
|
((count (mod/report-count (mod/report-about r) reports))
|
||||||
|
(kinds (mod/classify-keywords r))
|
||||||
|
(id (mod/report-id r)))
|
||||||
|
(let
|
||||||
|
((program (mod/build-program r count rules)))
|
||||||
|
(let
|
||||||
|
((db (pl-load program)))
|
||||||
|
(let
|
||||||
|
((sol (pl-query-one db (str "policy_action(" id ", Action, Rule)"))))
|
||||||
|
(if
|
||||||
|
(nil? sol)
|
||||||
|
{:action "keep" :proof {:goals (list) :evidence kinds :conditions (list) :rule "none" :count count} :report-id id :rule "none"}
|
||||||
|
(let
|
||||||
|
((rname (dict-get sol "Rule")))
|
||||||
|
(let ((rule (mod/find-rule rules rname))) {:action (mod/rule-action rule) :proof {:goals (mod/proof-goals db id (mod/rule-when rule)) :evidence kinds :conditions (mod/rule-when rule) :rule rname :count count} :report-id id :rule rname})))))))))
|
||||||
55
lib/mod/explain.sx
Normal file
55
lib/mod/explain.sx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
;; lib/mod/explain.sx — human-readable proof explanation.
|
||||||
|
;;
|
||||||
|
;; Turns a decision (from mod/decide-report, or any audit entry) into a readable
|
||||||
|
;; multi-line "why": the action, the rule that fired, the evidence in play, and
|
||||||
|
;; the derivation goal-by-goal with [proved]/[unproved] marks and the unification
|
||||||
|
;; bindings that satisfied each goal. Pure SX over the Phase-2 proof tree.
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/explain-binds
|
||||||
|
(fn
|
||||||
|
(binds)
|
||||||
|
(mod/join-with
|
||||||
|
", "
|
||||||
|
(map (fn (k) (str k "=" (dict-get binds k))) (keys binds)))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/explain-goal
|
||||||
|
(fn
|
||||||
|
(g)
|
||||||
|
(let
|
||||||
|
((mark (if (get g :solved) " [proved] " " [unproved] "))
|
||||||
|
(binds (get g :bindings)))
|
||||||
|
(if
|
||||||
|
(empty? (keys binds))
|
||||||
|
(str mark (get g :goal))
|
||||||
|
(str mark (get g :goal) " {" (mod/explain-binds binds) "}")))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/explain-evidence
|
||||||
|
(fn
|
||||||
|
(evidence)
|
||||||
|
(if
|
||||||
|
(empty? evidence)
|
||||||
|
"Evidence: (none)"
|
||||||
|
(str "Evidence: " (mod/join-with ", " evidence)))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/explain
|
||||||
|
(fn
|
||||||
|
(decision)
|
||||||
|
(let
|
||||||
|
((id (get decision :report-id))
|
||||||
|
(action (get decision :action))
|
||||||
|
(rule (get decision :rule))
|
||||||
|
(proof (get decision :proof)))
|
||||||
|
(let
|
||||||
|
((goals (get proof :goals)) (evidence (get proof :evidence)))
|
||||||
|
(mod/join-with
|
||||||
|
"\n"
|
||||||
|
(append
|
||||||
|
(list
|
||||||
|
(str "Report " id ": " action " (rule: " rule ")")
|
||||||
|
(mod/explain-evidence evidence)
|
||||||
|
"Because:")
|
||||||
|
(map mod/explain-goal goals)))))))
|
||||||
145
lib/mod/fed.sx
Normal file
145
lib/mod/fed.sx
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
;; lib/mod/fed.sx — federation: cross-instance reports, decision sharing, trust,
|
||||||
|
;; revocation. fed-sx itself is mocked here (an in-memory outbox); the real wire
|
||||||
|
;; transport would replace mod/fed-send!.
|
||||||
|
;;
|
||||||
|
;; Trust is advisory by default (the hard rule): a peer's decision only binds
|
||||||
|
;; locally when (mod/trusted? peer :mod) holds. An untrusted peer's decision is
|
||||||
|
;; recorded as a suggestion in the advisory log and is NOT applied. Local
|
||||||
|
;; decisions propagate outward via the outbox. Revocation undoes a locally
|
||||||
|
;; applied action when its proof is invalidated, notifying the origin peer.
|
||||||
|
|
||||||
|
(define mod/*fed-trust* (list)) ;; {:peer :scope}
|
||||||
|
(define mod/*fed-outbox* (list)) ;; {:to :type :payload}
|
||||||
|
(define mod/*fed-advisory* (list)) ;; {:peer :decision} — received, not applied
|
||||||
|
(define mod/*fed-applied* (list)) ;; {:report-id :action :origin :revoked}
|
||||||
|
(define mod/*fed-origins* (list)) ;; {:id :origin}
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/fed-reset!
|
||||||
|
(fn
|
||||||
|
()
|
||||||
|
(begin
|
||||||
|
(set! mod/*fed-trust* (list))
|
||||||
|
(set! mod/*fed-outbox* (list))
|
||||||
|
(set! mod/*fed-advisory* (list))
|
||||||
|
(set! mod/*fed-applied* (list))
|
||||||
|
(set! mod/*fed-origins* (list)))))
|
||||||
|
|
||||||
|
;; ── trust model ──
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/trust-match?
|
||||||
|
(fn
|
||||||
|
(t peer scope)
|
||||||
|
(if (= (get t :peer) peer) (= (get t :scope) scope) false)))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/grant-trust
|
||||||
|
(fn (peer scope) (begin (append! mod/*fed-trust* {:scope scope :peer peer}) true)))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/revoke-trust
|
||||||
|
(fn
|
||||||
|
(peer scope)
|
||||||
|
(set!
|
||||||
|
mod/*fed-trust*
|
||||||
|
(reduce
|
||||||
|
(fn
|
||||||
|
(acc t)
|
||||||
|
(if (mod/trust-match? t peer scope) acc (append acc (list t))))
|
||||||
|
(list)
|
||||||
|
mod/*fed-trust*))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/trusted?
|
||||||
|
(fn
|
||||||
|
(peer scope)
|
||||||
|
(mod/any? (fn (t) (mod/trust-match? t peer scope)) mod/*fed-trust*)))
|
||||||
|
|
||||||
|
;; ── cross-instance reports ──
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/fed-receive-report
|
||||||
|
(fn
|
||||||
|
(peer by about reason)
|
||||||
|
(let
|
||||||
|
((r (mod/report by about reason)))
|
||||||
|
(begin (append! mod/*fed-origins* {:id (mod/report-id r) :origin peer}) r))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/report-origin
|
||||||
|
(fn
|
||||||
|
(id)
|
||||||
|
(reduce
|
||||||
|
(fn (acc o) (if (= (get o :id) id) (get o :origin) acc))
|
||||||
|
"local"
|
||||||
|
mod/*fed-origins*)))
|
||||||
|
|
||||||
|
;; ── decision sharing (mock fed-sx send) ──
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/fed-send!
|
||||||
|
(fn (to type payload) (begin (append! mod/*fed-outbox* {:type type :to to :payload payload}) true)))
|
||||||
|
|
||||||
|
(define mod/fed-outbox (fn () mod/*fed-outbox*))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/fed-share-decision
|
||||||
|
(fn
|
||||||
|
(decision peers)
|
||||||
|
(reduce
|
||||||
|
(fn
|
||||||
|
(acc p)
|
||||||
|
(begin (mod/fed-send! p "decision" decision) (append acc (list p))))
|
||||||
|
(list)
|
||||||
|
peers)))
|
||||||
|
|
||||||
|
;; ── receiving a peer's decision (advisory unless trusted) ──
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/fed-applied-action
|
||||||
|
(fn
|
||||||
|
(report-id)
|
||||||
|
(reduce
|
||||||
|
(fn (acc a) (if (= (get a :report-id) report-id) a acc))
|
||||||
|
nil
|
||||||
|
mod/*fed-applied*)))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/fed-receive-decision
|
||||||
|
(fn
|
||||||
|
(peer decision)
|
||||||
|
(if
|
||||||
|
(mod/trusted? peer :mod)
|
||||||
|
(begin (append! mod/*fed-applied* {:revoked false :action (get decision :action) :report-id (get decision :report-id) :origin peer}) {:advisory false :peer peer :applied true :decision decision})
|
||||||
|
(begin (append! mod/*fed-advisory* {:peer peer :decision decision}) {:advisory true :peer peer :applied false :decision decision}))))
|
||||||
|
|
||||||
|
;; ── revocation ──
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/fed-revoke!
|
||||||
|
(fn
|
||||||
|
(report-id reason)
|
||||||
|
(begin
|
||||||
|
(set!
|
||||||
|
mod/*fed-applied*
|
||||||
|
(map
|
||||||
|
(fn (a) (if (= (get a :report-id) report-id) {:revoked true :action (get a :action) :report-id (get a :report-id) :origin (get a :origin)} a))
|
||||||
|
mod/*fed-applied*))
|
||||||
|
(mod/fed-send! (mod/report-origin report-id) "revocation" {:report-id report-id :reason reason})
|
||||||
|
report-id)))
|
||||||
|
|
||||||
|
;; re-run the engine; if the action no longer holds, the prior decision's proof
|
||||||
|
;; is invalidated — revoke the applied moderation.
|
||||||
|
(define
|
||||||
|
mod/fed-revoke-if-invalidated
|
||||||
|
(fn
|
||||||
|
(report decision reports rules)
|
||||||
|
(let
|
||||||
|
((d2 (mod/decide-report report reports rules)))
|
||||||
|
(if
|
||||||
|
(= (get d2 :action) (get decision :action))
|
||||||
|
{:revoked false :decision d2}
|
||||||
|
(begin
|
||||||
|
(mod/fed-revoke! (get decision :report-id) "proof invalidated")
|
||||||
|
{:revoked true :decision d2})))))
|
||||||
160
lib/mod/lifecycle.sx
Normal file
160
lib/mod/lifecycle.sx
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
;; lib/mod/lifecycle.sx — report lifecycle state machine (pure SX over the engine).
|
||||||
|
;;
|
||||||
|
;; Lifecycle state is deliberately separate from policy: the Prolog rules answer
|
||||||
|
;; "what action?", this module answers "where in the process is this report?".
|
||||||
|
;;
|
||||||
|
;; :open ──triage──▶ :triaged ──resolve/review──▶ :decided ──appeal──▶ :appealed
|
||||||
|
;; │ │
|
||||||
|
;; └────finalize───▶ :final ◀┘
|
||||||
|
;;
|
||||||
|
;; A case is an immutable value {:report :state :decision :tier :error :history}.
|
||||||
|
;; Every transition returns a NEW case; illegal transitions return the case
|
||||||
|
;; unchanged with :error set. Tiers: triage runs the engine (auto-tier); a
|
||||||
|
;; terminal action (hide/remove/keep) resolves immediately, an :escalate action
|
||||||
|
;; flags the case for human review (human-tier) before it can be resolved.
|
||||||
|
|
||||||
|
(define mod/case* (fn (report state decision tier err history) {:history history :state state :report report :error err :tier tier :decision decision}))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/mk-case
|
||||||
|
(fn (report) (mod/case* report "open" nil nil nil (list))))
|
||||||
|
|
||||||
|
(define mod/case-report (fn (c) (get c :report)))
|
||||||
|
(define mod/case-state (fn (c) (get c :state)))
|
||||||
|
(define mod/case-decision (fn (c) (get c :decision)))
|
||||||
|
(define mod/case-tier (fn (c) (get c :tier)))
|
||||||
|
(define mod/case-error (fn (c) (get c :error)))
|
||||||
|
(define mod/case-history (fn (c) (get c :history)))
|
||||||
|
|
||||||
|
;; ── transition table ──
|
||||||
|
|
||||||
|
(define mod/lc-transitions {:final (list) :appealed (list "final") :decided (list "appealed" "final") :open (list "triaged") :triaged (list "decided")})
|
||||||
|
|
||||||
|
(define mod/member? (fn (x lst) (mod/any? (fn (y) (= y x)) lst)))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/lc-can-transition?
|
||||||
|
(fn
|
||||||
|
(from to)
|
||||||
|
(let
|
||||||
|
((outs (get mod/lc-transitions from)))
|
||||||
|
(if (nil? outs) false (mod/member? to outs)))))
|
||||||
|
|
||||||
|
;; ── core transition: validate, record history, or flag :error ──
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/case-goto
|
||||||
|
(fn
|
||||||
|
(c to note report decision tier)
|
||||||
|
(let
|
||||||
|
((from (mod/case-state c)))
|
||||||
|
(if
|
||||||
|
(mod/lc-can-transition? from to)
|
||||||
|
(mod/case*
|
||||||
|
report
|
||||||
|
to
|
||||||
|
decision
|
||||||
|
tier
|
||||||
|
nil
|
||||||
|
(append (mod/case-history c) (list {:note note :to to :from from})))
|
||||||
|
(mod/case*
|
||||||
|
(mod/case-report c)
|
||||||
|
from
|
||||||
|
(mod/case-decision c)
|
||||||
|
(mod/case-tier c)
|
||||||
|
(str "illegal transition: " from " -> " to)
|
||||||
|
(mod/case-history c))))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/case-error-set
|
||||||
|
(fn
|
||||||
|
(c msg)
|
||||||
|
(mod/case*
|
||||||
|
(mod/case-report c)
|
||||||
|
(mod/case-state c)
|
||||||
|
(mod/case-decision c)
|
||||||
|
(mod/case-tier c)
|
||||||
|
msg
|
||||||
|
(mod/case-history c))))
|
||||||
|
|
||||||
|
;; ── lifecycle operations ──
|
||||||
|
|
||||||
|
;; :open → :triaged — run the auto-tier first pass.
|
||||||
|
(define
|
||||||
|
mod/case-triage
|
||||||
|
(fn
|
||||||
|
(c reports rules)
|
||||||
|
(let
|
||||||
|
((d (mod/decide-report (mod/case-report c) reports rules)))
|
||||||
|
(let
|
||||||
|
((tier (if (= (get d :action) "escalate") "human" "auto")))
|
||||||
|
(mod/case-goto
|
||||||
|
c
|
||||||
|
"triaged"
|
||||||
|
"auto-tier first pass"
|
||||||
|
(mod/case-report c)
|
||||||
|
d
|
||||||
|
tier)))))
|
||||||
|
|
||||||
|
;; :triaged → :decided — auto-tier resolves; human-tier is blocked until review.
|
||||||
|
(define
|
||||||
|
mod/case-resolve
|
||||||
|
(fn
|
||||||
|
(c)
|
||||||
|
(if
|
||||||
|
(= (mod/case-tier c) "human")
|
||||||
|
(mod/case-error-set c "awaiting human review (escalated)")
|
||||||
|
(mod/case-goto
|
||||||
|
c
|
||||||
|
"decided"
|
||||||
|
"auto-tier resolved"
|
||||||
|
(mod/case-report c)
|
||||||
|
(mod/case-decision c)
|
||||||
|
(mod/case-tier c)))))
|
||||||
|
|
||||||
|
;; :triaged → :decided — human review: attach evidence, re-decide, resolve.
|
||||||
|
(define
|
||||||
|
mod/case-review
|
||||||
|
(fn
|
||||||
|
(c kind val reports rules)
|
||||||
|
(let
|
||||||
|
((nr (mod/attach-evidence (mod/case-report c) (mod/mk-evidence kind val))))
|
||||||
|
(let
|
||||||
|
((d (mod/decide-report nr reports rules)))
|
||||||
|
(mod/case-goto c "decided" (str "human review: " kind) nr d "human")))))
|
||||||
|
|
||||||
|
;; :decided → :appealed — appeal: attach evidence, re-decide (may override).
|
||||||
|
(define
|
||||||
|
mod/case-appeal
|
||||||
|
(fn
|
||||||
|
(c kind val reports rules)
|
||||||
|
(let
|
||||||
|
((nr (mod/attach-evidence (mod/case-report c) (mod/mk-evidence kind val))))
|
||||||
|
(let
|
||||||
|
((d (mod/decide-report nr reports rules)))
|
||||||
|
(mod/case-goto
|
||||||
|
c
|
||||||
|
"appealed"
|
||||||
|
(str "appeal: " kind)
|
||||||
|
nr
|
||||||
|
d
|
||||||
|
(mod/case-tier c))))))
|
||||||
|
|
||||||
|
;; :decided | :appealed → :final
|
||||||
|
(define
|
||||||
|
mod/case-finalize
|
||||||
|
(fn
|
||||||
|
(c)
|
||||||
|
(mod/case-goto
|
||||||
|
c
|
||||||
|
"final"
|
||||||
|
"finalized"
|
||||||
|
(mod/case-report c)
|
||||||
|
(mod/case-decision c)
|
||||||
|
(mod/case-tier c))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/case-action
|
||||||
|
(fn
|
||||||
|
(c)
|
||||||
|
(let ((d (mod/case-decision c))) (if (nil? d) nil (get d :action)))))
|
||||||
92
lib/mod/link.sx
Normal file
92
lib/mod/link.sx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
;; lib/mod/link.sx — report linking + deduplication.
|
||||||
|
;;
|
||||||
|
;; Reports about the same subject form a cluster; identical reports (same
|
||||||
|
;; reporter + subject + reason) are duplicates. Linking is Prolog-backed: all
|
||||||
|
;; report facts are loaded and related ids are found by unification — the same
|
||||||
|
;; relational substrate the policy engine uses, here for retrieval rather than
|
||||||
|
;; decision. Dedup is pure SX over a normalized link key.
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/link-key
|
||||||
|
(fn
|
||||||
|
(r)
|
||||||
|
(str
|
||||||
|
(mod/report-by r)
|
||||||
|
"|"
|
||||||
|
(mod/report-about r)
|
||||||
|
"|"
|
||||||
|
(downcase (mod/report-reason r)))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/dedup-reports
|
||||||
|
(fn
|
||||||
|
(reports)
|
||||||
|
(reduce
|
||||||
|
(fn
|
||||||
|
(acc r)
|
||||||
|
(if
|
||||||
|
(mod/any? (fn (x) (= (mod/link-key x) (mod/link-key r))) acc)
|
||||||
|
acc
|
||||||
|
(append acc (list r))))
|
||||||
|
(list)
|
||||||
|
reports)))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/duplicate-count
|
||||||
|
(fn (reports) (- (len reports) (len (mod/dedup-reports reports)))))
|
||||||
|
|
||||||
|
;; ── Prolog-backed relational retrieval ──
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/report-rel-facts
|
||||||
|
(fn
|
||||||
|
(reports)
|
||||||
|
(mod/join-with
|
||||||
|
"\n"
|
||||||
|
(map
|
||||||
|
(fn
|
||||||
|
(r)
|
||||||
|
(str
|
||||||
|
"report("
|
||||||
|
(mod/report-id r)
|
||||||
|
", "
|
||||||
|
(mod/pl-quote (mod/report-by r))
|
||||||
|
", "
|
||||||
|
(mod/pl-quote (mod/report-about r))
|
||||||
|
")."))
|
||||||
|
reports))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/related-ids
|
||||||
|
(fn
|
||||||
|
(subject reports)
|
||||||
|
(let
|
||||||
|
((db (pl-load (mod/report-rel-facts reports))))
|
||||||
|
(map
|
||||||
|
(fn (sol) (dict-get sol "Id"))
|
||||||
|
(pl-query-all db (str "report(Id, _, " (mod/pl-quote subject) ")"))))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/reporters-of
|
||||||
|
(fn
|
||||||
|
(subject reports)
|
||||||
|
(let
|
||||||
|
((db (pl-load (mod/report-rel-facts reports))))
|
||||||
|
(map
|
||||||
|
(fn (sol) (dict-get sol "By"))
|
||||||
|
(pl-query-all db (str "report(_, By, " (mod/pl-quote subject) ")"))))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/distinct
|
||||||
|
(fn
|
||||||
|
(items)
|
||||||
|
(reduce
|
||||||
|
(fn
|
||||||
|
(acc x)
|
||||||
|
(if (mod/any? (fn (y) (= y x)) acc) acc (append acc (list x))))
|
||||||
|
(list)
|
||||||
|
items)))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/distinct-reporters-of
|
||||||
|
(fn (subject reports) (mod/distinct (mod/reporters-of subject reports))))
|
||||||
69
lib/mod/lint.sx
Normal file
69
lib/mod/lint.sx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
;; lib/mod/lint.sx — static analysis of a policy rule set.
|
||||||
|
;;
|
||||||
|
;; Because precedence is "first matching clause wins" (pl-query-one), the rule
|
||||||
|
;; order has correctness consequences a moderator can get wrong: a rule placed
|
||||||
|
;; after an unconditional (empty :when) rule can never fire, and a rule set with
|
||||||
|
;; no unconditional rule may leave some reports undecided. lint-rules surfaces
|
||||||
|
;; these without running the engine.
|
||||||
|
|
||||||
|
(define mod/rule-unconditional? (fn (r) (empty? (mod/rule-when r))))
|
||||||
|
|
||||||
|
;; names of rules that follow the first unconditional rule — structurally dead,
|
||||||
|
;; since the unconditional rule always matches first
|
||||||
|
(define
|
||||||
|
mod/unreachable-rules
|
||||||
|
(fn
|
||||||
|
(rules)
|
||||||
|
(get
|
||||||
|
(reduce
|
||||||
|
(fn
|
||||||
|
(acc r)
|
||||||
|
(if
|
||||||
|
(get acc :hit)
|
||||||
|
{:dead (append (get acc :dead) (list (mod/rule-name r))) :hit true}
|
||||||
|
(if (mod/rule-unconditional? r) {:dead (get acc :dead) :hit true} acc)))
|
||||||
|
{:dead (list) :hit false}
|
||||||
|
rules)
|
||||||
|
:dead)))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/has-catchall?
|
||||||
|
(fn (rules) (mod/any? mod/rule-unconditional? rules)))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/count-eq
|
||||||
|
(fn
|
||||||
|
(x lst)
|
||||||
|
(reduce (fn (a y) (if (= y x) (+ a 1) a)) 0 lst)))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/duplicate-rule-names
|
||||||
|
(fn
|
||||||
|
(rules)
|
||||||
|
(let
|
||||||
|
((names (map mod/rule-name rules)))
|
||||||
|
(mod/distinct
|
||||||
|
(reduce
|
||||||
|
(fn
|
||||||
|
(acc n)
|
||||||
|
(if
|
||||||
|
(< 1 (mod/count-eq n names))
|
||||||
|
(append acc (list n))
|
||||||
|
acc))
|
||||||
|
(list)
|
||||||
|
names)))))
|
||||||
|
|
||||||
|
(define mod/lint-rules (fn (rules) {:duplicate-names (mod/duplicate-rule-names rules) :has-catchall (mod/has-catchall? rules) :unreachable (mod/unreachable-rules rules)}))
|
||||||
|
|
||||||
|
;; a rule set is well-formed when nothing is dead, it has a catch-all, and rule
|
||||||
|
;; names are unique
|
||||||
|
(define
|
||||||
|
mod/rules-ok?
|
||||||
|
(fn
|
||||||
|
(rules)
|
||||||
|
(let
|
||||||
|
((l (mod/lint-rules rules)))
|
||||||
|
(if
|
||||||
|
(empty? (get l :unreachable))
|
||||||
|
(if (get l :has-catchall) (empty? (get l :duplicate-names)) false)
|
||||||
|
false))))
|
||||||
59
lib/mod/offenders.sx
Normal file
59
lib/mod/offenders.sx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
;; lib/mod/offenders.sx — repeat-offender escalation (audit log as evidence).
|
||||||
|
;;
|
||||||
|
;; The append-only audit trail is itself a source of evidence: a subject already
|
||||||
|
;; sanctioned several times is a repeat offender. mod/decide-escalating decides a
|
||||||
|
;; report normally, then — if the action is a sanction and the subject has at
|
||||||
|
;; least k PRIOR sanctions in the audit log — upgrades it to :ban. This is the one
|
||||||
|
;; place a decision depends on history beyond the single report, and it reads that
|
||||||
|
;; history from the audit log rather than re-deriving it.
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/sanction?
|
||||||
|
(fn
|
||||||
|
(action)
|
||||||
|
(mod/any? (fn (a) (= a action)) (list "hide" "remove" "ban"))))
|
||||||
|
|
||||||
|
;; count of prior sanctioning decisions in the audit log about a subject
|
||||||
|
(define
|
||||||
|
mod/subject-sanctions
|
||||||
|
(fn
|
||||||
|
(subject)
|
||||||
|
(reduce
|
||||||
|
(fn
|
||||||
|
(acc e)
|
||||||
|
(let
|
||||||
|
((r (mod/get-report (get e :report-id))))
|
||||||
|
(if
|
||||||
|
(nil? r)
|
||||||
|
acc
|
||||||
|
(if
|
||||||
|
(if
|
||||||
|
(= (mod/report-about r) subject)
|
||||||
|
(mod/sanction? (get e :action))
|
||||||
|
false)
|
||||||
|
(+ acc 1)
|
||||||
|
acc))))
|
||||||
|
0
|
||||||
|
(mod/audit-all))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/repeat-offender?
|
||||||
|
(fn (subject k) (<= k (mod/subject-sanctions subject))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/decide-escalating
|
||||||
|
(fn
|
||||||
|
(id k)
|
||||||
|
(let
|
||||||
|
((r (mod/get-report id)))
|
||||||
|
(if
|
||||||
|
(nil? r)
|
||||||
|
nil
|
||||||
|
(let
|
||||||
|
((priors (mod/subject-sanctions (mod/report-about r))))
|
||||||
|
(let
|
||||||
|
((d (mod/decide id)))
|
||||||
|
(if
|
||||||
|
(if (mod/sanction? (get d :action)) (<= k priors) false)
|
||||||
|
{:action "ban" :proof {:goals (get (get d :proof) :goals) :prior-sanctions priors :evidence (get (get d :proof) :evidence) :conditions (list) :rule "repeat-offender-ban" :count (get (get d :proof) :count)} :report-id id :rule "repeat-offender-ban" :strategy "escalating"}
|
||||||
|
d)))))))
|
||||||
18
lib/mod/pipeline.sx
Normal file
18
lib/mod/pipeline.sx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
;; lib/mod/pipeline.sx — end-to-end triage orchestration.
|
||||||
|
;;
|
||||||
|
;; A single entry point that runs a report through the subsystem and returns the
|
||||||
|
;; full artifact bundle: the decision (under the report's domain policy), a
|
||||||
|
;; human-readable explanation, an ActivityPub-shaped event for the bus, and the
|
||||||
|
;; wire line for federated peers. Composes policies (Ext 17), explain (Ext 3),
|
||||||
|
;; activity (Ext 16) and wire (Ext 14) — the modules are independent, this is just
|
||||||
|
;; the convenience that wires them together for the common "process a report" path.
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/triage-pipeline
|
||||||
|
(fn
|
||||||
|
(domain r reports actor)
|
||||||
|
(let ((d (mod/decide-in domain r reports))) {:activity (mod/decision->activity d actor) :action (get d :action) :wire (mod/decision->wire d) :rule (get d :rule) :decision d :explanation (mod/explain d)})))
|
||||||
|
|
||||||
|
(define mod/pipeline-action (fn (p) (get p :action)))
|
||||||
|
(define mod/pipeline-activity (fn (p) (get p :activity)))
|
||||||
|
(define mod/pipeline-wire (fn (p) (get p :wire)))
|
||||||
40
lib/mod/policies.sx
Normal file
40
lib/mod/policies.sx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
;; lib/mod/policies.sx — per-domain policy registry.
|
||||||
|
;;
|
||||||
|
;; rose-ash spans domains (blog, market, events, federation, …) that want
|
||||||
|
;; different moderation — a marketplace listing and a blog comment are not held to
|
||||||
|
;; the same bar. This registry maps a domain to a rule set; mod/decide-in resolves
|
||||||
|
;; the right policy and decides. Unregistered domains fall back to the default
|
||||||
|
;; rules, so adding a domain never leaves it unmoderated.
|
||||||
|
|
||||||
|
(define mod/*policies* (list))
|
||||||
|
|
||||||
|
(define mod/policies-reset! (fn () (set! mod/*policies* (list))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/register-policy!
|
||||||
|
(fn (domain rules) (begin (append! mod/*policies* {:domain domain :rules rules}) true)))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/policy-registered?
|
||||||
|
(fn
|
||||||
|
(domain)
|
||||||
|
(mod/any? (fn (p) (= (get p :domain) domain)) mod/*policies*)))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/policy-for
|
||||||
|
(fn
|
||||||
|
(domain)
|
||||||
|
(reduce
|
||||||
|
(fn (acc p) (if (= (get p :domain) domain) (get p :rules) acc))
|
||||||
|
mod/default-rules
|
||||||
|
mod/*policies*)))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/decide-in
|
||||||
|
(fn
|
||||||
|
(domain r reports)
|
||||||
|
(mod/decide-report r reports (mod/policy-for domain))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/registered-domains
|
||||||
|
(fn () (map (fn (p) (get p :domain)) mod/*policies*)))
|
||||||
137
lib/mod/policy.sx
Normal file
137
lib/mod/policy.sx
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
;; lib/mod/policy.sx — moderation rules → Prolog clauses.
|
||||||
|
;;
|
||||||
|
;; A rule is {:name :action :when}. :when is a list of condition forms; each
|
||||||
|
;; compiles to a Prolog goal. The conditions in a :when list are ANDed (joined by
|
||||||
|
;; ", "); :not negates and :any (a list of sub-conditions) disjoins — so the
|
||||||
|
;; condition language is a small boolean algebra over the leaf predicates.
|
||||||
|
;; Rule order is precedence: the engine queries with pl-query-one, so the first
|
||||||
|
;; clause that proves wins. The final default rule has an empty body (true) so
|
||||||
|
;; every report yields at least :keep — "no rule matched" is a real result, not a
|
||||||
|
;; query failure.
|
||||||
|
;;
|
||||||
|
;; cond->goal takes an id-term so the same condition can be compiled with the
|
||||||
|
;; head variable "Id" (for clause bodies) or a concrete report id (for proof-tree
|
||||||
|
;; goal-by-goal re-querying in the engine).
|
||||||
|
;;
|
||||||
|
;; Precedence (top wins): exoneration evidence (appeal override) > confirmed-abuse
|
||||||
|
;; evidence (human review) > spam/abuse classification > repeated-report count >
|
||||||
|
;; default keep.
|
||||||
|
|
||||||
|
(define mod/mk-rule (fn (name action conds) {:when conds :name name :action action}))
|
||||||
|
|
||||||
|
(define mod/rule-name (fn (r) (get r :name)))
|
||||||
|
(define mod/rule-action (fn (r) (get r :action)))
|
||||||
|
(define mod/rule-when (fn (r) (get r :when)))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/default-rules
|
||||||
|
(list
|
||||||
|
(mod/mk-rule
|
||||||
|
"exonerated-keep"
|
||||||
|
:keep (list (list :evidence "exonerated")))
|
||||||
|
(mod/mk-rule
|
||||||
|
"reviewer-remove"
|
||||||
|
:remove (list (list :evidence "confirmed-abuse")))
|
||||||
|
(mod/mk-rule "spam-hide" :hide (list (list :classification "spam")))
|
||||||
|
(mod/mk-rule
|
||||||
|
"abuse-remove"
|
||||||
|
:remove (list (list :classification "abuse")))
|
||||||
|
(mod/mk-rule
|
||||||
|
"repeated-escalate"
|
||||||
|
:escalate (list (list :count-at-least 3)))
|
||||||
|
(mod/mk-rule "default-keep" :keep (list))))
|
||||||
|
|
||||||
|
;; ── condition → Prolog goal ──
|
||||||
|
;;
|
||||||
|
;; (:classification "spam") → classification(Id, spam)
|
||||||
|
;; (:evidence "kind") → evidence(Id, 'kind', _)
|
||||||
|
;; (:attr "verified") → attr(Id, verified)
|
||||||
|
;; (:not <cond>) → not(<cond>) (negation)
|
||||||
|
;; (:any (list c1 c2 ...)) → (g1 ; g2 ; ...) (disjunction)
|
||||||
|
;; (:count-at-least 3) → report(Id, B, S), report_count(S, N), N >= 3
|
||||||
|
;; (:score-at-least 5) → aggregate_all(sum(W), signal(Id, _, W), T), T >= 5
|
||||||
|
;; (:reporters-at-least 2) → report(Id, _, Sr), setof(Br, report(_, Br, Sr), Bsr),
|
||||||
|
;; length(Bsr, Nr), Nr >= 2 (quorum engine)
|
||||||
|
;; (:burst-at-least 3) → report(Id, _, Sb), burst_count(Sb, Nb), Nb >= 3
|
||||||
|
;; (temporal engine)
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/cond->goal
|
||||||
|
(fn
|
||||||
|
(c idterm)
|
||||||
|
(let
|
||||||
|
((tag (first c)))
|
||||||
|
(cond
|
||||||
|
((= tag :classification)
|
||||||
|
(str "classification(" idterm ", " (nth c 1) ")"))
|
||||||
|
((= tag :evidence)
|
||||||
|
(str
|
||||||
|
"evidence("
|
||||||
|
idterm
|
||||||
|
", "
|
||||||
|
(mod/pl-quote (nth c 1))
|
||||||
|
", _)"))
|
||||||
|
((= tag :attr) (str "attr(" idterm ", " (nth c 1) ")"))
|
||||||
|
((= tag :not)
|
||||||
|
(str "not(" (mod/cond->goal (nth c 1) idterm) ")"))
|
||||||
|
((= tag :any)
|
||||||
|
(str
|
||||||
|
"("
|
||||||
|
(mod/join-with
|
||||||
|
" ; "
|
||||||
|
(map
|
||||||
|
(fn (sub) (mod/cond->goal sub idterm))
|
||||||
|
(nth c 1)))
|
||||||
|
")"))
|
||||||
|
((= tag :count-at-least)
|
||||||
|
(str
|
||||||
|
"report("
|
||||||
|
idterm
|
||||||
|
", B, S), report_count(S, N), N >= "
|
||||||
|
(nth c 1)))
|
||||||
|
((= tag :score-at-least)
|
||||||
|
(str
|
||||||
|
"aggregate_all(sum(W), signal("
|
||||||
|
idterm
|
||||||
|
", _, W), T), T >= "
|
||||||
|
(nth c 1)))
|
||||||
|
((= tag :reporters-at-least)
|
||||||
|
(str
|
||||||
|
"report("
|
||||||
|
idterm
|
||||||
|
", _, Sr), setof(Br, report(_, Br, Sr), Bsr), "
|
||||||
|
"length(Bsr, Nr), Nr >= "
|
||||||
|
(nth c 1)))
|
||||||
|
((= tag :burst-at-least)
|
||||||
|
(str
|
||||||
|
"report("
|
||||||
|
idterm
|
||||||
|
", _, Sb), burst_count(Sb, Nb), Nb >= "
|
||||||
|
(nth c 1)))
|
||||||
|
(true "true")))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/conds->body
|
||||||
|
(fn
|
||||||
|
(conds idterm)
|
||||||
|
(if
|
||||||
|
(empty? conds)
|
||||||
|
"true"
|
||||||
|
(mod/join-with ", " (map (fn (c) (mod/cond->goal c idterm)) conds)))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/rule->clause
|
||||||
|
(fn
|
||||||
|
(r)
|
||||||
|
(str
|
||||||
|
"policy_action(Id, "
|
||||||
|
(mod/rule-action r)
|
||||||
|
", '"
|
||||||
|
(mod/rule-name r)
|
||||||
|
"') :- "
|
||||||
|
(mod/conds->body (mod/rule-when r) "Id")
|
||||||
|
".")))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/rules->program
|
||||||
|
(fn (rules) (mod/join-with "\n" (map mod/rule->clause rules))))
|
||||||
40
lib/mod/quorum.sx
Normal file
40
lib/mod/quorum.sx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
;; lib/mod/quorum.sx — quorum decisions over distinct reporters (anti-brigade).
|
||||||
|
;;
|
||||||
|
;; The base engine asserts only the decided report's report/3 fact, so it can't
|
||||||
|
;; reason about WHO reported a subject. The quorum engine additionally asserts
|
||||||
|
;; every report's report/3 fact (via link's rel-facts), letting a rule require N
|
||||||
|
;; *distinct* reporters with `setof`/`length` — so one user filing many reports
|
||||||
|
;; does not manufacture consensus. Same decision shape as the base engine, plus
|
||||||
|
;; :strategy "quorum".
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/build-quorum-program
|
||||||
|
(fn
|
||||||
|
(r count reports rules)
|
||||||
|
(str
|
||||||
|
(mod/report-rel-facts reports)
|
||||||
|
"\n"
|
||||||
|
(mod/report-facts r count)
|
||||||
|
"\n"
|
||||||
|
(mod/rules->program rules))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/decide-quorum
|
||||||
|
(fn
|
||||||
|
(r reports rules)
|
||||||
|
(let
|
||||||
|
((count (mod/report-count (mod/report-about r) reports))
|
||||||
|
(kinds (mod/classify-keywords r))
|
||||||
|
(id (mod/report-id r)))
|
||||||
|
(let
|
||||||
|
((program (mod/build-quorum-program r count reports rules)))
|
||||||
|
(let
|
||||||
|
((db (pl-load program)))
|
||||||
|
(let
|
||||||
|
((sol (pl-query-one db (str "policy_action(" id ", Action, Rule)"))))
|
||||||
|
(if
|
||||||
|
(nil? sol)
|
||||||
|
{:action "keep" :proof {:goals (list) :evidence kinds :conditions (list) :rule "none" :count count} :report-id id :rule "none" :strategy "quorum"}
|
||||||
|
(let
|
||||||
|
((rule (mod/find-rule rules (dict-get sol "Rule"))))
|
||||||
|
{:action (mod/rule-action rule) :proof {:goals (mod/proof-goals db id (mod/rule-when rule)) :evidence kinds :conditions (mod/rule-when rule) :rule (mod/rule-name rule) :count count} :report-id id :rule (mod/rule-name rule) :strategy "quorum"}))))))))
|
||||||
259
lib/mod/schema.sx
Normal file
259
lib/mod/schema.sx
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
;; lib/mod/schema.sx — report representation + Prolog fact generation.
|
||||||
|
;;
|
||||||
|
;; A report is a dict {:id :by :about :reason :evidence :attrs :signals :at}.
|
||||||
|
;; :evidence — accumulated {:kind :val} entries (human review, scanners)
|
||||||
|
;; :attrs — attribute names ("verified") for negation-as-failure conditions
|
||||||
|
;; :signals — weighted {:kind :weight} entries for aggregate scoring rules
|
||||||
|
;; :at — integer timestamp/tick (deterministic; supplied, not clock-read)
|
||||||
|
;; The engine derives keyword classifications from the reason text and projects
|
||||||
|
;; the report, its classifications, evidence, attributes, and signals into Prolog
|
||||||
|
;; facts that policy clauses match against.
|
||||||
|
|
||||||
|
(define mod/mk-report (fn (id by about reason) {:attrs (list) :id id :signals (list) :by by :evidence (list) :about about :at 0 :reason reason}))
|
||||||
|
|
||||||
|
(define mod/report-id (fn (r) (get r :id)))
|
||||||
|
(define mod/report-by (fn (r) (get r :by)))
|
||||||
|
(define mod/report-about (fn (r) (get r :about)))
|
||||||
|
(define mod/report-reason (fn (r) (get r :reason)))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/report-evidence
|
||||||
|
(fn (r) (let ((e (get r :evidence))) (if (nil? e) (list) e))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/report-attrs
|
||||||
|
(fn (r) (let ((a (get r :attrs))) (if (nil? a) (list) a))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/report-signals
|
||||||
|
(fn (r) (let ((s (get r :signals))) (if (nil? s) (list) s))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/report-at
|
||||||
|
(fn (r) (let ((t (get r :at))) (if (nil? t) 0 t))))
|
||||||
|
|
||||||
|
(define mod/mk-evidence (fn (kind val) {:val val :kind kind}))
|
||||||
|
(define mod/evidence-kind (fn (e) (get e :kind)))
|
||||||
|
(define mod/evidence-val (fn (e) (get e :val)))
|
||||||
|
|
||||||
|
(define mod/mk-signal (fn (kind weight) {:kind kind :weight weight}))
|
||||||
|
(define mod/signal-kind (fn (s) (get s :kind)))
|
||||||
|
(define mod/signal-weight (fn (s) (get s :weight)))
|
||||||
|
|
||||||
|
(define mod/report* (fn (r evs attrs sigs at) {:attrs attrs :id (mod/report-id r) :signals sigs :by (mod/report-by r) :evidence evs :about (mod/report-about r) :at at :reason (mod/report-reason r)}))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/with-evidence
|
||||||
|
(fn
|
||||||
|
(r evs)
|
||||||
|
(mod/report*
|
||||||
|
r
|
||||||
|
evs
|
||||||
|
(mod/report-attrs r)
|
||||||
|
(mod/report-signals r)
|
||||||
|
(mod/report-at r))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/with-attrs
|
||||||
|
(fn
|
||||||
|
(r attrs)
|
||||||
|
(mod/report*
|
||||||
|
r
|
||||||
|
(mod/report-evidence r)
|
||||||
|
attrs
|
||||||
|
(mod/report-signals r)
|
||||||
|
(mod/report-at r))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/with-signals
|
||||||
|
(fn
|
||||||
|
(r sigs)
|
||||||
|
(mod/report*
|
||||||
|
r
|
||||||
|
(mod/report-evidence r)
|
||||||
|
(mod/report-attrs r)
|
||||||
|
sigs
|
||||||
|
(mod/report-at r))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/with-at
|
||||||
|
(fn
|
||||||
|
(r at)
|
||||||
|
(mod/report*
|
||||||
|
r
|
||||||
|
(mod/report-evidence r)
|
||||||
|
(mod/report-attrs r)
|
||||||
|
(mod/report-signals r)
|
||||||
|
at)))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/attach-evidence
|
||||||
|
(fn
|
||||||
|
(r e)
|
||||||
|
(mod/with-evidence r (append (mod/report-evidence r) (list e)))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/attach-attr
|
||||||
|
(fn (r a) (mod/with-attrs r (append (mod/report-attrs r) (list a)))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/attach-signal
|
||||||
|
(fn (r s) (mod/with-signals r (append (mod/report-signals r) (list s)))))
|
||||||
|
|
||||||
|
;; ── substring search (the prolog-loaded env lacks includes?; slice/len do work) ──
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/contains-at?
|
||||||
|
(fn
|
||||||
|
(hay needle hl nl pos)
|
||||||
|
(if
|
||||||
|
(< hl (+ pos nl))
|
||||||
|
false
|
||||||
|
(if
|
||||||
|
(= (slice hay pos (+ pos nl)) needle)
|
||||||
|
true
|
||||||
|
(mod/contains-at? hay needle hl nl (+ pos 1))))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/str-contains?
|
||||||
|
(fn
|
||||||
|
(hay needle)
|
||||||
|
(let
|
||||||
|
((hl (len hay)) (nl (len needle)))
|
||||||
|
(if
|
||||||
|
(= nl 0)
|
||||||
|
true
|
||||||
|
(mod/contains-at? hay needle hl nl 0)))))
|
||||||
|
|
||||||
|
;; ── evidence derivation (keyword classification) ──
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/spam-keywords
|
||||||
|
(list "spam" "buy now" "click here" "free money" "viagra" "limited offer"))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/abuse-keywords
|
||||||
|
(list "abuse" "harassment" "threat" "slur" "hate speech"))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/any?
|
||||||
|
(fn (pred coll) (reduce (fn (acc x) (if acc acc (pred x))) false coll)))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/reason-matches?
|
||||||
|
(fn
|
||||||
|
(reason kws)
|
||||||
|
(let
|
||||||
|
((low (downcase reason)))
|
||||||
|
(mod/any? (fn (k) (mod/str-contains? low k)) kws))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/classify-keywords
|
||||||
|
(fn
|
||||||
|
(r)
|
||||||
|
(let
|
||||||
|
((reason (mod/report-reason r)) (kinds (list)))
|
||||||
|
(begin
|
||||||
|
(when
|
||||||
|
(mod/reason-matches? reason mod/spam-keywords)
|
||||||
|
(append! kinds "spam"))
|
||||||
|
(when
|
||||||
|
(mod/reason-matches? reason mod/abuse-keywords)
|
||||||
|
(append! kinds "abuse"))
|
||||||
|
kinds))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/report-count
|
||||||
|
(fn
|
||||||
|
(about reports)
|
||||||
|
(reduce
|
||||||
|
(fn
|
||||||
|
(acc r)
|
||||||
|
(if (= (mod/report-about r) about) (+ acc 1) acc))
|
||||||
|
0
|
||||||
|
reports)))
|
||||||
|
|
||||||
|
;; ── Prolog fact projection ──
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/join-with
|
||||||
|
(fn
|
||||||
|
(sep items)
|
||||||
|
(reduce (fn (acc x) (if (= acc "") x (str acc sep x))) "" items)))
|
||||||
|
|
||||||
|
(define mod/pl-quote (fn (s) (str "'" s "'")))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/classification-facts
|
||||||
|
(fn
|
||||||
|
(id kinds)
|
||||||
|
(mod/join-with
|
||||||
|
"\n"
|
||||||
|
(map (fn (k) (str "classification(" id ", " k ").")) kinds))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/evidence-facts
|
||||||
|
(fn
|
||||||
|
(id evs)
|
||||||
|
(mod/join-with
|
||||||
|
"\n"
|
||||||
|
(map
|
||||||
|
(fn
|
||||||
|
(e)
|
||||||
|
(str
|
||||||
|
"evidence("
|
||||||
|
id
|
||||||
|
", "
|
||||||
|
(mod/pl-quote (mod/evidence-kind e))
|
||||||
|
", "
|
||||||
|
(mod/pl-quote (str (mod/evidence-val e)))
|
||||||
|
")."))
|
||||||
|
evs))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/attr-facts
|
||||||
|
(fn
|
||||||
|
(id attrs)
|
||||||
|
(mod/join-with "\n" (map (fn (a) (str "attr(" id ", " a ").")) attrs))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/signal-facts
|
||||||
|
(fn
|
||||||
|
(id sigs)
|
||||||
|
(mod/join-with
|
||||||
|
"\n"
|
||||||
|
(map
|
||||||
|
(fn
|
||||||
|
(s)
|
||||||
|
(str
|
||||||
|
"signal("
|
||||||
|
id
|
||||||
|
", "
|
||||||
|
(mod/pl-quote (mod/signal-kind s))
|
||||||
|
", "
|
||||||
|
(mod/signal-weight s)
|
||||||
|
")."))
|
||||||
|
sigs))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/report-facts
|
||||||
|
(fn
|
||||||
|
(r count)
|
||||||
|
(let
|
||||||
|
((id (mod/report-id r))
|
||||||
|
(by (mod/pl-quote (mod/report-by r)))
|
||||||
|
(about (mod/pl-quote (mod/report-about r))))
|
||||||
|
(let
|
||||||
|
((cls (mod/classification-facts id (mod/classify-keywords r)))
|
||||||
|
(evs (mod/evidence-facts id (mod/report-evidence r)))
|
||||||
|
(ats (mod/attr-facts id (mod/report-attrs r)))
|
||||||
|
(sgs (mod/signal-facts id (mod/report-signals r))))
|
||||||
|
(mod/join-with
|
||||||
|
"\n"
|
||||||
|
(list
|
||||||
|
(str "report(" id ", " by ", " about ").")
|
||||||
|
(str "report_count(" about ", " count ").")
|
||||||
|
cls
|
||||||
|
evs
|
||||||
|
ats
|
||||||
|
sgs))))))
|
||||||
30
lib/mod/scoreboard.json
Normal file
30
lib/mod/scoreboard.json
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"lang": "mod",
|
||||||
|
"total_passed": 390,
|
||||||
|
"total_failed": 0,
|
||||||
|
"total": 390,
|
||||||
|
"suites": [
|
||||||
|
{"name":"decide","passed":31,"failed":0,"total":31},
|
||||||
|
{"name":"audit","passed":29,"failed":0,"total":29},
|
||||||
|
{"name":"escalation","passed":46,"failed":0,"total":46},
|
||||||
|
{"name":"fed","passed":26,"failed":0,"total":26},
|
||||||
|
{"name":"extensions","passed":32,"failed":0,"total":32},
|
||||||
|
{"name":"link","passed":12,"failed":0,"total":12},
|
||||||
|
{"name":"lint","passed":14,"failed":0,"total":14},
|
||||||
|
{"name":"severity","passed":14,"failed":0,"total":14},
|
||||||
|
{"name":"offenders","passed":19,"failed":0,"total":19},
|
||||||
|
{"name":"quorum","passed":9,"failed":0,"total":9},
|
||||||
|
{"name":"trace","passed":15,"failed":0,"total":15},
|
||||||
|
{"name":"whatif","passed":13,"failed":0,"total":13},
|
||||||
|
{"name":"batch","passed":17,"failed":0,"total":17},
|
||||||
|
{"name":"temporal","passed":15,"failed":0,"total":15},
|
||||||
|
{"name":"sla","passed":15,"failed":0,"total":15},
|
||||||
|
{"name":"wire","passed":16,"failed":0,"total":16},
|
||||||
|
{"name":"disjunction","passed":10,"failed":0,"total":10},
|
||||||
|
{"name":"activity","passed":17,"failed":0,"total":17},
|
||||||
|
{"name":"policies","passed":14,"failed":0,"total":14},
|
||||||
|
{"name":"defrule","passed":11,"failed":0,"total":11},
|
||||||
|
{"name":"pipeline","passed":15,"failed":0,"total":15}
|
||||||
|
],
|
||||||
|
"generated": "2026-06-06T19:40:03+00:00"
|
||||||
|
}
|
||||||
27
lib/mod/scoreboard.md
Normal file
27
lib/mod/scoreboard.md
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# mod scoreboard
|
||||||
|
|
||||||
|
**390 / 390 passing** (0 failure(s)).
|
||||||
|
|
||||||
|
| Suite | Passed | Total | Status |
|
||||||
|
|-------|--------|-------|--------|
|
||||||
|
| decide | 31 | 31 | ok |
|
||||||
|
| audit | 29 | 29 | ok |
|
||||||
|
| escalation | 46 | 46 | ok |
|
||||||
|
| fed | 26 | 26 | ok |
|
||||||
|
| extensions | 32 | 32 | ok |
|
||||||
|
| link | 12 | 12 | ok |
|
||||||
|
| lint | 14 | 14 | ok |
|
||||||
|
| severity | 14 | 14 | ok |
|
||||||
|
| offenders | 19 | 19 | ok |
|
||||||
|
| quorum | 9 | 9 | ok |
|
||||||
|
| trace | 15 | 15 | ok |
|
||||||
|
| whatif | 13 | 13 | ok |
|
||||||
|
| batch | 17 | 17 | ok |
|
||||||
|
| temporal | 15 | 15 | ok |
|
||||||
|
| sla | 15 | 15 | ok |
|
||||||
|
| wire | 16 | 16 | ok |
|
||||||
|
| disjunction | 10 | 10 | ok |
|
||||||
|
| activity | 17 | 17 | ok |
|
||||||
|
| policies | 14 | 14 | ok |
|
||||||
|
| defrule | 11 | 11 | ok |
|
||||||
|
| pipeline | 15 | 15 | ok |
|
||||||
60
lib/mod/severity.sx
Normal file
60
lib/mod/severity.sx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
;; lib/mod/severity.sx — "strictest-wins" decision strategy.
|
||||||
|
;;
|
||||||
|
;; The default engine resolves precedence by rule ORDER (first proven clause wins,
|
||||||
|
;; via pl-query-one). Some policies instead want the HARSHEST applicable sanction
|
||||||
|
;; regardless of order. mod/decide-strictest collects every rule that proves
|
||||||
|
;; (pl-query-all) and picks the highest-severity action. Same decision shape as
|
||||||
|
;; the engine, plus :strategy. Built over the engine's helpers; engine untouched.
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/action-severity
|
||||||
|
(fn
|
||||||
|
(action)
|
||||||
|
(cond
|
||||||
|
((= action "ban") 4)
|
||||||
|
((= action "remove") 3)
|
||||||
|
((= action "hide") 2)
|
||||||
|
((= action "escalate") 1)
|
||||||
|
(true 0))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/strictest-sol
|
||||||
|
(fn
|
||||||
|
(sols)
|
||||||
|
(reduce
|
||||||
|
(fn
|
||||||
|
(acc s)
|
||||||
|
(if
|
||||||
|
(nil? acc)
|
||||||
|
s
|
||||||
|
(if
|
||||||
|
(<
|
||||||
|
(mod/action-severity (dict-get acc "Action"))
|
||||||
|
(mod/action-severity (dict-get s "Action")))
|
||||||
|
s
|
||||||
|
acc)))
|
||||||
|
nil
|
||||||
|
sols)))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/decide-strictest
|
||||||
|
(fn
|
||||||
|
(r reports rules)
|
||||||
|
(let
|
||||||
|
((count (mod/report-count (mod/report-about r) reports))
|
||||||
|
(kinds (mod/classify-keywords r))
|
||||||
|
(id (mod/report-id r)))
|
||||||
|
(let
|
||||||
|
((program (mod/build-program r count rules)))
|
||||||
|
(let
|
||||||
|
((db (pl-load program)))
|
||||||
|
(let
|
||||||
|
((sols (pl-query-all db (str "policy_action(" id ", Action, Rule)"))))
|
||||||
|
(let
|
||||||
|
((best (mod/strictest-sol sols)))
|
||||||
|
(if
|
||||||
|
(nil? best)
|
||||||
|
{:action "keep" :proof {:goals (list) :evidence kinds :conditions (list) :rule "none" :count count} :report-id id :rule "none" :strategy "strictest"}
|
||||||
|
(let
|
||||||
|
((rule (mod/find-rule rules (dict-get best "Rule"))))
|
||||||
|
{:action (mod/rule-action rule) :proof {:goals (mod/proof-goals db id (mod/rule-when rule)) :evidence kinds :conditions (mod/rule-when rule) :rule (mod/rule-name rule) :count count} :report-id id :rule (mod/rule-name rule) :strategy "strictest"})))))))))
|
||||||
47
lib/mod/sla.sx
Normal file
47
lib/mod/sla.sx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
;; lib/mod/sla.sx — service-level sweep over pending lifecycle cases.
|
||||||
|
;;
|
||||||
|
;; Composes the Phase-3 lifecycle with the Ext-12 time dimension: a case left in a
|
||||||
|
;; pending state (open / triaged / appealed) past a deadline has breached SLA and
|
||||||
|
;; should resurface. A timed-case pairs a case with the tick it entered its
|
||||||
|
;; current state (the caller stamps this — the lifecycle stays timeless and pure).
|
||||||
|
;; Terminal states (decided / final) never breach.
|
||||||
|
|
||||||
|
(define mod/pending-states (list "open" "triaged" "appealed"))
|
||||||
|
(define mod/pending-state? (fn (s) (mod/member? s mod/pending-states)))
|
||||||
|
|
||||||
|
(define mod/mk-timed-case (fn (c entered-at) {:entered-at entered-at :case c}))
|
||||||
|
(define mod/tc-case (fn (tc) (get tc :case)))
|
||||||
|
(define mod/tc-entered-at (fn (tc) (get tc :entered-at)))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/overdue?
|
||||||
|
(fn
|
||||||
|
(tc now deadline)
|
||||||
|
(if
|
||||||
|
(mod/pending-state? (mod/case-state (mod/tc-case tc)))
|
||||||
|
(< deadline (- now (mod/tc-entered-at tc)))
|
||||||
|
false)))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/sla-sweep
|
||||||
|
(fn
|
||||||
|
(timed-cases now deadline)
|
||||||
|
(reduce
|
||||||
|
(fn
|
||||||
|
(acc tc)
|
||||||
|
(if
|
||||||
|
(mod/overdue? tc now deadline)
|
||||||
|
(append
|
||||||
|
acc
|
||||||
|
(list (mod/report-id (mod/case-report (mod/tc-case tc)))))
|
||||||
|
acc))
|
||||||
|
(list)
|
||||||
|
timed-cases)))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/overdue-count
|
||||||
|
(fn
|
||||||
|
(timed-cases now deadline)
|
||||||
|
(len (mod/sla-sweep timed-cases now deadline))))
|
||||||
|
|
||||||
|
(define mod/age (fn (tc now) (- now (mod/tc-entered-at tc))))
|
||||||
62
lib/mod/temporal.sx
Normal file
62
lib/mod/temporal.sx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
;; lib/mod/temporal.sx — burst detection over a time window.
|
||||||
|
;;
|
||||||
|
;; A plain report count can't tell a burst (N reports in minutes) from slow
|
||||||
|
;; accumulation (N reports over months). mod/decide-temporal takes a `now` tick
|
||||||
|
;; and a `window`, counts reports about the subject with :at within [now-window,
|
||||||
|
;; now], asserts it as burst_count/2, and lets a `(:burst-at-least K)` rule fire
|
||||||
|
;; only on a genuine burst. Time is supplied (deterministic), never clock-read.
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/window-count
|
||||||
|
(fn
|
||||||
|
(subject reports now window)
|
||||||
|
(reduce
|
||||||
|
(fn
|
||||||
|
(acc r)
|
||||||
|
(if
|
||||||
|
(if
|
||||||
|
(= (mod/report-about r) subject)
|
||||||
|
(<= (- now window) (mod/report-at r))
|
||||||
|
false)
|
||||||
|
(+ acc 1)
|
||||||
|
acc))
|
||||||
|
0
|
||||||
|
reports)))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/build-temporal-program
|
||||||
|
(fn
|
||||||
|
(r count bcount rules)
|
||||||
|
(str
|
||||||
|
(mod/report-facts r count)
|
||||||
|
"\n"
|
||||||
|
"burst_count("
|
||||||
|
(mod/pl-quote (mod/report-about r))
|
||||||
|
", "
|
||||||
|
bcount
|
||||||
|
").\n"
|
||||||
|
(mod/rules->program rules))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/decide-temporal
|
||||||
|
(fn
|
||||||
|
(r reports rules now window)
|
||||||
|
(let
|
||||||
|
((about (mod/report-about r))
|
||||||
|
(id (mod/report-id r))
|
||||||
|
(kinds (mod/classify-keywords r)))
|
||||||
|
(let
|
||||||
|
((count (mod/report-count about reports))
|
||||||
|
(bcount (mod/window-count about reports now window)))
|
||||||
|
(let
|
||||||
|
((program (mod/build-temporal-program r count bcount rules)))
|
||||||
|
(let
|
||||||
|
((db (pl-load program)))
|
||||||
|
(let
|
||||||
|
((sol (pl-query-one db (str "policy_action(" id ", Action, Rule)"))))
|
||||||
|
(if
|
||||||
|
(nil? sol)
|
||||||
|
{:action "keep" :proof {:burst bcount :goals (list) :evidence kinds :conditions (list) :rule "none" :count count} :report-id id :rule "none" :strategy "temporal"}
|
||||||
|
(let
|
||||||
|
((rule (mod/find-rule rules (dict-get sol "Rule"))))
|
||||||
|
{:action (mod/rule-action rule) :proof {:burst bcount :goals (mod/proof-goals db id (mod/rule-when rule)) :evidence kinds :conditions (mod/rule-when rule) :rule (mod/rule-name rule) :count count} :report-id id :rule (mod/rule-name rule) :strategy "temporal"})))))))))
|
||||||
95
lib/mod/tests/activity.sx
Normal file
95
lib/mod/tests/activity.sx
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
;; lib/mod/tests/activity.sx — Ext 16: ActivityPub-shaped decision export.
|
||||||
|
|
||||||
|
(define mod-ap-count 0)
|
||||||
|
(define mod-ap-pass 0)
|
||||||
|
(define mod-ap-fail 0)
|
||||||
|
(define mod-ap-failures (list))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod-ap-test!
|
||||||
|
(fn
|
||||||
|
(name got expected)
|
||||||
|
(begin
|
||||||
|
(set! mod-ap-count (+ mod-ap-count 1))
|
||||||
|
(if
|
||||||
|
(= got expected)
|
||||||
|
(set! mod-ap-pass (+ mod-ap-pass 1))
|
||||||
|
(begin
|
||||||
|
(set! mod-ap-fail (+ mod-ap-fail 1))
|
||||||
|
(append!
|
||||||
|
mod-ap-failures
|
||||||
|
(str name "\n expected: " expected "\n got: " got)))))))
|
||||||
|
|
||||||
|
;; ── action → AP verb ──
|
||||||
|
|
||||||
|
(mod-ap-test! "remove → Delete" (mod/action->verb "remove") "Delete")
|
||||||
|
(mod-ap-test! "ban → Block" (mod/action->verb "ban") "Block")
|
||||||
|
(mod-ap-test! "hide → Flag" (mod/action->verb "hide") "Flag")
|
||||||
|
(mod-ap-test! "escalate → Flag" (mod/action->verb "escalate") "Flag")
|
||||||
|
(mod-ap-test! "keep → nil (no activity)" (mod/action->verb "keep") nil)
|
||||||
|
|
||||||
|
;; ── single decision → activity ──
|
||||||
|
|
||||||
|
(define mod-ap-spam (mod/mk-report "r1" "a" "bob" "this is spam"))
|
||||||
|
(define
|
||||||
|
mod-ap-dec
|
||||||
|
(mod/decide-report mod-ap-spam (list mod-ap-spam) mod/default-rules))
|
||||||
|
(define mod-ap-act (mod/decision->activity mod-ap-dec "instance.example"))
|
||||||
|
|
||||||
|
(mod-ap-test! "activity type is Flag (hide)" (get mod-ap-act :type) "Flag")
|
||||||
|
(mod-ap-test! "activity object is report id" (get mod-ap-act :object) "r1")
|
||||||
|
(mod-ap-test!
|
||||||
|
"activity actor preserved"
|
||||||
|
(get mod-ap-act :actor)
|
||||||
|
"instance.example")
|
||||||
|
(mod-ap-test!
|
||||||
|
"activity preserves precise action"
|
||||||
|
(get mod-ap-act :action)
|
||||||
|
"hide")
|
||||||
|
(mod-ap-test! "activity carries rule" (get mod-ap-act :rule) "spam-hide")
|
||||||
|
(mod-ap-test!
|
||||||
|
"activity summary"
|
||||||
|
(get mod-ap-act :summary)
|
||||||
|
"moderation/hide via spam-hide")
|
||||||
|
|
||||||
|
;; ── keep produces no activity ──
|
||||||
|
|
||||||
|
(define mod-ap-clean (mod/mk-report "r2" "a" "b" "a fine post"))
|
||||||
|
(define
|
||||||
|
mod-ap-keep
|
||||||
|
(mod/decide-report mod-ap-clean (list mod-ap-clean) mod/default-rules))
|
||||||
|
(mod-ap-test!
|
||||||
|
"keep decision → nil activity"
|
||||||
|
(mod/decision->activity mod-ap-keep "x")
|
||||||
|
nil)
|
||||||
|
|
||||||
|
;; ── abuse → Delete ──
|
||||||
|
|
||||||
|
(define mod-ap-abuse (mod/mk-report "r3" "a" "b" "harassment here"))
|
||||||
|
(define
|
||||||
|
mod-ap-abuse-dec
|
||||||
|
(mod/decide-report mod-ap-abuse (list mod-ap-abuse) mod/default-rules))
|
||||||
|
(mod-ap-test!
|
||||||
|
"abuse decision → Delete activity"
|
||||||
|
(get (mod/decision->activity mod-ap-abuse-dec "x") :type)
|
||||||
|
"Delete")
|
||||||
|
|
||||||
|
;; ── batch export drops keeps ──
|
||||||
|
|
||||||
|
(define mod-ap-decisions (list mod-ap-dec mod-ap-keep mod-ap-abuse-dec))
|
||||||
|
(define mod-ap-acts (mod/decisions->activities mod-ap-decisions "inst"))
|
||||||
|
(mod-ap-test! "batch export drops the keep" (len mod-ap-acts) 2)
|
||||||
|
(mod-ap-test!
|
||||||
|
"batch export first is the Flag"
|
||||||
|
(get (first mod-ap-acts) :type)
|
||||||
|
"Flag")
|
||||||
|
(mod-ap-test!
|
||||||
|
"batch export second is the Delete"
|
||||||
|
(get (nth mod-ap-acts 1) :type)
|
||||||
|
"Delete")
|
||||||
|
(mod-ap-test!
|
||||||
|
"empty decisions → no activities"
|
||||||
|
(mod/decisions->activities (list) "inst")
|
||||||
|
(list))
|
||||||
|
|
||||||
|
(define mod-activity-tests-run! (fn () {:failures mod-ap-failures :total mod-ap-count :passed mod-ap-pass :failed mod-ap-fail}))
|
||||||
187
lib/mod/tests/audit.sx
Normal file
187
lib/mod/tests/audit.sx
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
;; lib/mod/tests/audit.sx — Phase 2: evidence accumulation + proof tree + audit.
|
||||||
|
|
||||||
|
(define mod-aud-count 0)
|
||||||
|
(define mod-aud-pass 0)
|
||||||
|
(define mod-aud-fail 0)
|
||||||
|
(define mod-aud-failures (list))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod-aud-test!
|
||||||
|
(fn
|
||||||
|
(name got expected)
|
||||||
|
(begin
|
||||||
|
(set! mod-aud-count (+ mod-aud-count 1))
|
||||||
|
(if
|
||||||
|
(= got expected)
|
||||||
|
(set! mod-aud-pass (+ mod-aud-pass 1))
|
||||||
|
(begin
|
||||||
|
(set! mod-aud-fail (+ mod-aud-fail 1))
|
||||||
|
(append!
|
||||||
|
mod-aud-failures
|
||||||
|
(str name "\n expected: " expected "\n got: " got)))))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod-aud-decide1
|
||||||
|
(fn (r) (mod/decide-report r (list r) mod/default-rules)))
|
||||||
|
|
||||||
|
;; ── proof tree: keyword classification ──
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod-aud-spam
|
||||||
|
(mod-aud-decide1 (mod/mk-report "r1" "alice" "bob" "this is spam")))
|
||||||
|
(define mod-aud-spam-goals (get (get mod-aud-spam :proof) :goals))
|
||||||
|
|
||||||
|
(mod-aud-test! "spam proof has one goal" (len mod-aud-spam-goals) 1)
|
||||||
|
(mod-aud-test!
|
||||||
|
"spam proof goal text"
|
||||||
|
(get (first mod-aud-spam-goals) :goal)
|
||||||
|
"classification(r1, spam)")
|
||||||
|
(mod-aud-test!
|
||||||
|
"spam proof goal solved"
|
||||||
|
(get (first mod-aud-spam-goals) :solved)
|
||||||
|
true)
|
||||||
|
|
||||||
|
;; ── proof tree: count rule with real bindings ──
|
||||||
|
|
||||||
|
(define mod-aud-rep-r (mod/mk-report "r3" "ann" "dave" "x"))
|
||||||
|
(define
|
||||||
|
mod-aud-rep
|
||||||
|
(mod/decide-report
|
||||||
|
mod-aud-rep-r
|
||||||
|
(list mod-aud-rep-r mod-aud-rep-r mod-aud-rep-r)
|
||||||
|
mod/default-rules))
|
||||||
|
(define mod-aud-rep-goals (get (get mod-aud-rep :proof) :goals))
|
||||||
|
(define mod-aud-rep-binds (get (first mod-aud-rep-goals) :bindings))
|
||||||
|
|
||||||
|
(mod-aud-test!
|
||||||
|
"count proof goal solved"
|
||||||
|
(get (first mod-aud-rep-goals) :solved)
|
||||||
|
true)
|
||||||
|
(mod-aud-test! "count proof binding N" (dict-get mod-aud-rep-binds "N") "3")
|
||||||
|
(mod-aud-test!
|
||||||
|
"count proof binding S (subject)"
|
||||||
|
(dict-get mod-aud-rep-binds "S")
|
||||||
|
"dave")
|
||||||
|
|
||||||
|
;; ── proof tree: default keep has a 'true' goal ──
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod-aud-keep
|
||||||
|
(mod-aud-decide1 (mod/mk-report "rk" "a" "b" "a fine post")))
|
||||||
|
(define mod-aud-keep-goals (get (get mod-aud-keep :proof) :goals))
|
||||||
|
|
||||||
|
(mod-aud-test!
|
||||||
|
"keep proof goal text true"
|
||||||
|
(get (first mod-aud-keep-goals) :goal)
|
||||||
|
"true")
|
||||||
|
(mod-aud-test!
|
||||||
|
"keep proof goal solved"
|
||||||
|
(get (first mod-aud-keep-goals) :solved)
|
||||||
|
true)
|
||||||
|
|
||||||
|
;; ── evidence accumulation drives a rule ──
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod-aud-rev-r
|
||||||
|
(mod/attach-evidence
|
||||||
|
(mod/mk-report "re" "a" "carol" "neutral")
|
||||||
|
(mod/mk-evidence "confirmed-abuse" "human")))
|
||||||
|
(define mod-aud-rev (mod-aud-decide1 mod-aud-rev-r))
|
||||||
|
|
||||||
|
(mod-aud-test!
|
||||||
|
"evidence has length 1"
|
||||||
|
(len (mod/report-evidence mod-aud-rev-r))
|
||||||
|
1)
|
||||||
|
(mod-aud-test!
|
||||||
|
"evidence reviewer-remove → remove"
|
||||||
|
(get mod-aud-rev :action)
|
||||||
|
"remove")
|
||||||
|
(mod-aud-test!
|
||||||
|
"evidence reviewer-remove rule"
|
||||||
|
(get mod-aud-rev :rule)
|
||||||
|
"reviewer-remove")
|
||||||
|
(mod-aud-test!
|
||||||
|
"evidence proof goal solved"
|
||||||
|
(get (first (get (get mod-aud-rev :proof) :goals)) :solved)
|
||||||
|
true)
|
||||||
|
(mod-aud-test!
|
||||||
|
"no evidence → not reviewer-remove"
|
||||||
|
(get (mod-aud-decide1 (mod/mk-report "rn" "a" "b" "neutral")) :rule)
|
||||||
|
"default-keep")
|
||||||
|
|
||||||
|
;; ── append-only audit log via the api ──
|
||||||
|
|
||||||
|
(mod/reset!)
|
||||||
|
(mod/report "alice" "bob" "this is spam")
|
||||||
|
(mod/report "carol" "eve" "fine post")
|
||||||
|
(define mod-aud-d1 (mod/decide "r1"))
|
||||||
|
(define mod-aud-d2 (mod/decide "r2"))
|
||||||
|
|
||||||
|
(mod-aud-test! "two decisions logged" (mod/audit-count) 2)
|
||||||
|
(mod-aud-test!
|
||||||
|
"first entry seq 1"
|
||||||
|
(get (first (mod/audit-all)) :seq)
|
||||||
|
1)
|
||||||
|
(mod-aud-test!
|
||||||
|
"audit r1 returns one entry"
|
||||||
|
(len (mod/audit "r1"))
|
||||||
|
1)
|
||||||
|
(mod-aud-test!
|
||||||
|
"audit r1 action matches decision"
|
||||||
|
(get (first (mod/audit "r1")) :action)
|
||||||
|
(get mod-aud-d1 :action))
|
||||||
|
(mod-aud-test!
|
||||||
|
"audit r1 rule matches decision"
|
||||||
|
(get (first (mod/audit "r1")) :rule)
|
||||||
|
"spam-hide")
|
||||||
|
(mod-aud-test!
|
||||||
|
"audit r1 entry carries proof goals"
|
||||||
|
(len (get (get (first (mod/audit "r1")) :proof) :goals))
|
||||||
|
1)
|
||||||
|
(mod-aud-test!
|
||||||
|
"audit r2 keep"
|
||||||
|
(get (first (mod/audit "r2")) :action)
|
||||||
|
"keep")
|
||||||
|
(mod-aud-test! "audit unknown report → empty" (mod/audit "r99") (list))
|
||||||
|
|
||||||
|
;; ── append-only: re-deciding appends, never mutates ──
|
||||||
|
|
||||||
|
(define mod-aud-d1b (mod/decide "r1"))
|
||||||
|
|
||||||
|
(mod-aud-test! "re-decide appends (count 3)" (mod/audit-count) 3)
|
||||||
|
(mod-aud-test!
|
||||||
|
"audit r1 now has 2 entries"
|
||||||
|
(len (mod/audit "r1"))
|
||||||
|
2)
|
||||||
|
(mod-aud-test!
|
||||||
|
"audit r1 seqs monotonic"
|
||||||
|
(get (nth (mod/audit "r1") 1) :seq)
|
||||||
|
3)
|
||||||
|
(mod-aud-test!
|
||||||
|
"audit-latest r1 is seq 3"
|
||||||
|
(get (mod/audit-latest "r1") :seq)
|
||||||
|
3)
|
||||||
|
(mod-aud-test!
|
||||||
|
"first r1 entry unchanged (still seq 1)"
|
||||||
|
(get (first (mod/audit "r1")) :seq)
|
||||||
|
1)
|
||||||
|
|
||||||
|
;; ── evidence snapshot captured at decision time ──
|
||||||
|
|
||||||
|
(mod/add-evidence "r2" "confirmed-abuse" "human")
|
||||||
|
(define mod-aud-d2b (mod/decide "r2"))
|
||||||
|
|
||||||
|
(mod-aud-test!
|
||||||
|
"post-evidence decision flips to remove"
|
||||||
|
(get mod-aud-d2b :action)
|
||||||
|
"remove")
|
||||||
|
(mod-aud-test!
|
||||||
|
"audit snapshot records evidence kind"
|
||||||
|
(mod/evidence-kind (first (get (mod/audit-latest "r2") :evidence)))
|
||||||
|
"confirmed-abuse")
|
||||||
|
(mod-aud-test!
|
||||||
|
"earlier r2 entry had empty evidence snapshot"
|
||||||
|
(len (get (first (mod/audit "r2")) :evidence))
|
||||||
|
0)
|
||||||
|
|
||||||
|
(define mod-audit-tests-run! (fn () {:failures mod-aud-failures :total mod-aud-count :passed mod-aud-pass :failed mod-aud-fail}))
|
||||||
101
lib/mod/tests/batch.sx
Normal file
101
lib/mod/tests/batch.sx
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
;; lib/mod/tests/batch.sx — Ext 11: batch triage + corpus analytics.
|
||||||
|
|
||||||
|
(define mod-b-count 0)
|
||||||
|
(define mod-b-pass 0)
|
||||||
|
(define mod-b-fail 0)
|
||||||
|
(define mod-b-failures (list))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod-b-test!
|
||||||
|
(fn
|
||||||
|
(name got expected)
|
||||||
|
(begin
|
||||||
|
(set! mod-b-count (+ mod-b-count 1))
|
||||||
|
(if
|
||||||
|
(= got expected)
|
||||||
|
(set! mod-b-pass (+ mod-b-pass 1))
|
||||||
|
(begin
|
||||||
|
(set! mod-b-fail (+ mod-b-fail 1))
|
||||||
|
(append!
|
||||||
|
mod-b-failures
|
||||||
|
(str name "\n expected: " expected "\n got: " got)))))))
|
||||||
|
|
||||||
|
;; corpus: 2 spam, 1 abuse, 2 clean — distinct subjects so the count rule stays quiet
|
||||||
|
(define
|
||||||
|
mod-b-corpus
|
||||||
|
(list
|
||||||
|
(mod/mk-report "r1" "u" "s1" "this is spam")
|
||||||
|
(mod/mk-report "r2" "u" "s2" "buy now offer")
|
||||||
|
(mod/mk-report "r3" "u" "s3" "harassment here")
|
||||||
|
(mod/mk-report "r4" "u" "s4" "a fine post")
|
||||||
|
(mod/mk-report "r5" "u" "s5" "thanks for sharing")))
|
||||||
|
|
||||||
|
(define mod-b-decisions (mod/decide-batch mod-b-corpus mod/default-rules))
|
||||||
|
|
||||||
|
;; ── decide-batch ──
|
||||||
|
|
||||||
|
(mod-b-test! "one decision per report" (len mod-b-decisions) 5)
|
||||||
|
(mod-b-test!
|
||||||
|
"first decision is hide"
|
||||||
|
(get (first mod-b-decisions) :action)
|
||||||
|
"hide")
|
||||||
|
|
||||||
|
;; ── action histogram ──
|
||||||
|
|
||||||
|
(define mod-b-hist (mod/action-histogram mod-b-decisions))
|
||||||
|
(mod-b-test! "histogram hide count" (get mod-b-hist :hide) 2)
|
||||||
|
(mod-b-test! "histogram remove count" (get mod-b-hist :remove) 1)
|
||||||
|
(mod-b-test! "histogram keep count" (get mod-b-hist :keep) 2)
|
||||||
|
(mod-b-test! "histogram escalate count" (get mod-b-hist :escalate) 0)
|
||||||
|
(mod-b-test! "histogram ban count" (get mod-b-hist :ban) 0)
|
||||||
|
(mod-b-test!
|
||||||
|
"histogram totals match corpus"
|
||||||
|
(+
|
||||||
|
(+ (get mod-b-hist :hide) (get mod-b-hist :remove))
|
||||||
|
(+
|
||||||
|
(get mod-b-hist :keep)
|
||||||
|
(+ (get mod-b-hist :escalate) (get mod-b-hist :ban))))
|
||||||
|
5)
|
||||||
|
|
||||||
|
;; ── rule coverage (empirical) ──
|
||||||
|
|
||||||
|
(define mod-b-cov (mod/rule-coverage mod-b-corpus mod/default-rules))
|
||||||
|
(mod-b-test! "coverage has one row per rule" (len mod-b-cov) 6)
|
||||||
|
(mod-b-test!
|
||||||
|
"spam-hide fired twice"
|
||||||
|
(mod/rule-fire-count mod-b-decisions "spam-hide")
|
||||||
|
2)
|
||||||
|
(mod-b-test!
|
||||||
|
"abuse-remove fired once"
|
||||||
|
(mod/rule-fire-count mod-b-decisions "abuse-remove")
|
||||||
|
1)
|
||||||
|
(mod-b-test!
|
||||||
|
"default-keep fired twice"
|
||||||
|
(mod/rule-fire-count mod-b-decisions "default-keep")
|
||||||
|
2)
|
||||||
|
|
||||||
|
;; ── never-fired: rules not exercised by this corpus ──
|
||||||
|
|
||||||
|
(define mod-b-never (mod/never-fired mod-b-corpus mod/default-rules))
|
||||||
|
(mod-b-test!
|
||||||
|
"exonerated-keep never fired"
|
||||||
|
(mod/member? "exonerated-keep" mod-b-never)
|
||||||
|
true)
|
||||||
|
(mod-b-test!
|
||||||
|
"reviewer-remove never fired"
|
||||||
|
(mod/member? "reviewer-remove" mod-b-never)
|
||||||
|
true)
|
||||||
|
(mod-b-test!
|
||||||
|
"repeated-escalate never fired"
|
||||||
|
(mod/member? "repeated-escalate" mod-b-never)
|
||||||
|
true)
|
||||||
|
(mod-b-test!
|
||||||
|
"spam-hide DID fire (not in never-fired)"
|
||||||
|
(mod/member? "spam-hide" mod-b-never)
|
||||||
|
false)
|
||||||
|
(mod-b-test!
|
||||||
|
"three rules never fired on this corpus"
|
||||||
|
(len mod-b-never)
|
||||||
|
3)
|
||||||
|
|
||||||
|
(define mod-batch-tests-run! (fn () {:failures mod-b-failures :total mod-b-count :passed mod-b-pass :failed mod-b-fail}))
|
||||||
215
lib/mod/tests/decide.sx
Normal file
215
lib/mod/tests/decide.sx
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
;; lib/mod/tests/decide.sx — Phase 1: report representation + simple policy.
|
||||||
|
|
||||||
|
(define mod-dec-count 0)
|
||||||
|
(define mod-dec-pass 0)
|
||||||
|
(define mod-dec-fail 0)
|
||||||
|
(define mod-dec-failures (list))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod-dec-test!
|
||||||
|
(fn
|
||||||
|
(name got expected)
|
||||||
|
(begin
|
||||||
|
(set! mod-dec-count (+ mod-dec-count 1))
|
||||||
|
(if
|
||||||
|
(= got expected)
|
||||||
|
(set! mod-dec-pass (+ mod-dec-pass 1))
|
||||||
|
(begin
|
||||||
|
(set! mod-dec-fail (+ mod-dec-fail 1))
|
||||||
|
(append!
|
||||||
|
mod-dec-failures
|
||||||
|
(str name "\n expected: " expected "\n got: " got)))))))
|
||||||
|
|
||||||
|
;; decide a single report (count over a 1-element registry)
|
||||||
|
(define
|
||||||
|
mod-dec-one
|
||||||
|
(fn
|
||||||
|
(reason)
|
||||||
|
(let
|
||||||
|
((r (mod/mk-report "r1" "alice" "bob" reason)))
|
||||||
|
(mod/decide-report r (list r) mod/default-rules))))
|
||||||
|
|
||||||
|
(define mod-dec-action (fn (reason) (get (mod-dec-one reason) :action)))
|
||||||
|
|
||||||
|
;; ── spam keyword → :hide ──
|
||||||
|
|
||||||
|
(mod-dec-test!
|
||||||
|
"spam keyword 'spam' → hide"
|
||||||
|
(mod-dec-action "this is spam")
|
||||||
|
"hide")
|
||||||
|
(mod-dec-test!
|
||||||
|
"spam keyword 'buy now' → hide"
|
||||||
|
(mod-dec-action "buy now while stocks last")
|
||||||
|
"hide")
|
||||||
|
(mod-dec-test!
|
||||||
|
"spam keyword case-insensitive 'CLICK HERE' → hide"
|
||||||
|
(mod-dec-action "CLICK HERE now")
|
||||||
|
"hide")
|
||||||
|
(mod-dec-test!
|
||||||
|
"spam keyword 'free money' → hide"
|
||||||
|
(mod-dec-action "win free money fast")
|
||||||
|
"hide")
|
||||||
|
|
||||||
|
;; ── abuse keyword → :remove ──
|
||||||
|
|
||||||
|
(mod-dec-test!
|
||||||
|
"abuse keyword 'harassment' → remove"
|
||||||
|
(mod-dec-action "ongoing harassment of users")
|
||||||
|
"remove")
|
||||||
|
(mod-dec-test!
|
||||||
|
"abuse keyword 'threat' → remove"
|
||||||
|
(mod-dec-action "this is a threat")
|
||||||
|
"remove")
|
||||||
|
(mod-dec-test!
|
||||||
|
"abuse keyword 'slur' → remove"
|
||||||
|
(mod-dec-action "contains a slur")
|
||||||
|
"remove")
|
||||||
|
|
||||||
|
;; ── no rule → :keep ──
|
||||||
|
|
||||||
|
(mod-dec-test!
|
||||||
|
"neutral reason → keep"
|
||||||
|
(mod-dec-action "I disagree with this post")
|
||||||
|
"keep")
|
||||||
|
(mod-dec-test! "empty reason → keep" (mod-dec-action "") "keep")
|
||||||
|
|
||||||
|
;; ── decision carries the matching rule (proof, not bare keyword) ──
|
||||||
|
|
||||||
|
(mod-dec-test!
|
||||||
|
"spam decision rule name"
|
||||||
|
(get (mod-dec-one "this is spam") :rule)
|
||||||
|
"spam-hide")
|
||||||
|
(mod-dec-test!
|
||||||
|
"keep decision rule name"
|
||||||
|
(get (mod-dec-one "fine post") :rule)
|
||||||
|
"default-keep")
|
||||||
|
(mod-dec-test!
|
||||||
|
"abuse decision rule name"
|
||||||
|
(get (mod-dec-one "harassment here") :rule)
|
||||||
|
"abuse-remove")
|
||||||
|
(mod-dec-test!
|
||||||
|
"spam proof :rule"
|
||||||
|
(get (get (mod-dec-one "spam!") :proof) :rule)
|
||||||
|
"spam-hide")
|
||||||
|
(mod-dec-test!
|
||||||
|
"spam proof :evidence"
|
||||||
|
(get (get (mod-dec-one "spam!") :proof) :evidence)
|
||||||
|
(list "spam"))
|
||||||
|
(mod-dec-test!
|
||||||
|
"spam proof :count"
|
||||||
|
(get (get (mod-dec-one "spam!") :proof) :count)
|
||||||
|
1)
|
||||||
|
|
||||||
|
;; ── classification (evidence derivation) ──
|
||||||
|
|
||||||
|
(mod-dec-test!
|
||||||
|
"classify spam"
|
||||||
|
(mod/classify-keywords (mod/mk-report "r1" "a" "b" "spam!"))
|
||||||
|
(list "spam"))
|
||||||
|
(mod-dec-test!
|
||||||
|
"classify abuse"
|
||||||
|
(mod/classify-keywords (mod/mk-report "r1" "a" "b" "abuse"))
|
||||||
|
(list "abuse"))
|
||||||
|
(mod-dec-test!
|
||||||
|
"classify neutral → empty"
|
||||||
|
(mod/classify-keywords (mod/mk-report "r1" "a" "b" "hello"))
|
||||||
|
(list))
|
||||||
|
(mod-dec-test!
|
||||||
|
"classify both spam+abuse"
|
||||||
|
(mod/classify-keywords (mod/mk-report "r1" "a" "b" "spam and abuse"))
|
||||||
|
(list "spam" "abuse"))
|
||||||
|
|
||||||
|
;; ── report-count + repeated → :escalate ──
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod-dec-three
|
||||||
|
(list
|
||||||
|
(mod/mk-report "r1" "a" "bob" "x")
|
||||||
|
(mod/mk-report "r2" "c" "bob" "y")
|
||||||
|
(mod/mk-report "r3" "d" "bob" "z")))
|
||||||
|
|
||||||
|
(mod-dec-test!
|
||||||
|
"report-count counts subject"
|
||||||
|
(mod/report-count "bob" mod-dec-three)
|
||||||
|
3)
|
||||||
|
(mod-dec-test!
|
||||||
|
"3 reports about subject → escalate"
|
||||||
|
(get
|
||||||
|
(mod/decide-report (first mod-dec-three) mod-dec-three mod/default-rules)
|
||||||
|
:action)
|
||||||
|
"escalate")
|
||||||
|
(mod-dec-test!
|
||||||
|
"escalate rule name"
|
||||||
|
(get
|
||||||
|
(mod/decide-report (first mod-dec-three) mod-dec-three mod/default-rules)
|
||||||
|
:rule)
|
||||||
|
"repeated-escalate")
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod-dec-two
|
||||||
|
(list
|
||||||
|
(mod/mk-report "r1" "a" "carol" "x")
|
||||||
|
(mod/mk-report "r2" "c" "carol" "y")))
|
||||||
|
|
||||||
|
(mod-dec-test!
|
||||||
|
"2 reports about subject → keep (below threshold)"
|
||||||
|
(get
|
||||||
|
(mod/decide-report (first mod-dec-two) mod-dec-two mod/default-rules)
|
||||||
|
:action)
|
||||||
|
"keep")
|
||||||
|
|
||||||
|
;; ── precedence: spam beats repeated ──
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod-dec-spam-among-many
|
||||||
|
(list
|
||||||
|
(mod/mk-report "r1" "a" "dave" "buy now spam")
|
||||||
|
(mod/mk-report "r2" "c" "dave" "y")
|
||||||
|
(mod/mk-report "r3" "d" "dave" "z")))
|
||||||
|
|
||||||
|
(mod-dec-test!
|
||||||
|
"spam wins over repeated (precedence)"
|
||||||
|
(get
|
||||||
|
(mod/decide-report
|
||||||
|
(first mod-dec-spam-among-many)
|
||||||
|
mod-dec-spam-among-many
|
||||||
|
mod/default-rules)
|
||||||
|
:action)
|
||||||
|
"hide")
|
||||||
|
|
||||||
|
;; ── accessors ──
|
||||||
|
|
||||||
|
(mod-dec-test!
|
||||||
|
"report-about accessor"
|
||||||
|
(mod/report-about (mod/mk-report "r1" "a" "bob" "x"))
|
||||||
|
"bob")
|
||||||
|
(mod-dec-test!
|
||||||
|
"report-by accessor"
|
||||||
|
(mod/report-by (mod/mk-report "r1" "alice" "bob" "x"))
|
||||||
|
"alice")
|
||||||
|
|
||||||
|
;; ── api registry ──
|
||||||
|
|
||||||
|
(mod/reset!)
|
||||||
|
(define mod-dec-r1 (mod/report "alice" "bob" "this is spam"))
|
||||||
|
(define mod-dec-r2 (mod/report "carol" "eve" "fine post"))
|
||||||
|
|
||||||
|
(mod-dec-test!
|
||||||
|
"mod/report assigns sequential id r1"
|
||||||
|
(mod/report-id mod-dec-r1)
|
||||||
|
"r1")
|
||||||
|
(mod-dec-test!
|
||||||
|
"mod/report assigns sequential id r2"
|
||||||
|
(mod/report-id mod-dec-r2)
|
||||||
|
"r2")
|
||||||
|
(mod-dec-test!
|
||||||
|
"mod/decide via registry → hide"
|
||||||
|
(get (mod/decide "r1") :action)
|
||||||
|
"hide")
|
||||||
|
(mod-dec-test!
|
||||||
|
"mod/decide via registry → keep"
|
||||||
|
(get (mod/decide "r2") :action)
|
||||||
|
"keep")
|
||||||
|
(mod-dec-test! "mod/decide unknown id → nil" (mod/decide "r99") nil)
|
||||||
|
|
||||||
|
(define mod-decide-tests-run! (fn () {:failures mod-dec-failures :total mod-dec-count :passed mod-dec-pass :failed mod-dec-fail}))
|
||||||
95
lib/mod/tests/defrule.sx
Normal file
95
lib/mod/tests/defrule.sx
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
;; lib/mod/tests/defrule.sx — Ext 18: ergonomic defrule / ruleset.
|
||||||
|
|
||||||
|
(define mod-dr-count 0)
|
||||||
|
(define mod-dr-pass 0)
|
||||||
|
(define mod-dr-fail 0)
|
||||||
|
(define mod-dr-failures (list))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod-dr-test!
|
||||||
|
(fn
|
||||||
|
(name got expected)
|
||||||
|
(begin
|
||||||
|
(set! mod-dr-count (+ mod-dr-count 1))
|
||||||
|
(if
|
||||||
|
(= got expected)
|
||||||
|
(set! mod-dr-pass (+ mod-dr-pass 1))
|
||||||
|
(begin
|
||||||
|
(set! mod-dr-fail (+ mod-dr-fail 1))
|
||||||
|
(append!
|
||||||
|
mod-dr-failures
|
||||||
|
(str name "\n expected: " expected "\n got: " got)))))))
|
||||||
|
|
||||||
|
;; ── defrule produces the same structure as mk-rule ──
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod-dr-r
|
||||||
|
(mod/defrule "spam-hide" :hide (list :classification "spam")))
|
||||||
|
(mod-dr-test! "defrule name" (mod/rule-name mod-dr-r) "spam-hide")
|
||||||
|
(mod-dr-test! "defrule action" (mod/rule-action mod-dr-r) "hide")
|
||||||
|
(mod-dr-test!
|
||||||
|
"defrule when wraps the conditions"
|
||||||
|
(mod/rule-when mod-dr-r)
|
||||||
|
(list (list :classification "spam")))
|
||||||
|
(mod-dr-test!
|
||||||
|
"defrule equals mk-rule equivalent"
|
||||||
|
(mod/rule-when mod-dr-r)
|
||||||
|
(mod/rule-when
|
||||||
|
(mod/mk-rule "spam-hide" :hide (list (list :classification "spam")))))
|
||||||
|
|
||||||
|
;; ── multi-condition + no-condition ──
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod-dr-multi
|
||||||
|
(mod/defrule
|
||||||
|
"strict"
|
||||||
|
:hide (list :classification "spam")
|
||||||
|
(list :not (list :attr "verified"))))
|
||||||
|
(mod-dr-test!
|
||||||
|
"defrule collects multiple conditions"
|
||||||
|
(len (mod/rule-when mod-dr-multi))
|
||||||
|
2)
|
||||||
|
|
||||||
|
(define mod-dr-catch (mod/defrule "default-keep" :keep))
|
||||||
|
(mod-dr-test!
|
||||||
|
"defrule with no conditions is unconditional"
|
||||||
|
(mod/rule-when mod-dr-catch)
|
||||||
|
(list))
|
||||||
|
|
||||||
|
;; ── ruleset assembles a list ──
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod-dr-rules
|
||||||
|
(mod/ruleset
|
||||||
|
(mod/defrule "spam-hide" :hide (list :classification "spam"))
|
||||||
|
(mod/defrule "default-keep" :keep)))
|
||||||
|
|
||||||
|
(mod-dr-test! "ruleset length" (len mod-dr-rules) 2)
|
||||||
|
(mod-dr-test!
|
||||||
|
"ruleset first rule name"
|
||||||
|
(mod/rule-name (first mod-dr-rules))
|
||||||
|
"spam-hide")
|
||||||
|
|
||||||
|
;; ── engine works with defrule/ruleset-built policy ──
|
||||||
|
|
||||||
|
(define mod-dr-spam (mod/mk-report "r1" "a" "b" "this is spam"))
|
||||||
|
(define mod-dr-clean (mod/mk-report "r2" "a" "b" "a fine post"))
|
||||||
|
|
||||||
|
(mod-dr-test!
|
||||||
|
"defrule policy: spam → hide"
|
||||||
|
(get
|
||||||
|
(mod/decide-report mod-dr-spam (list mod-dr-spam) mod-dr-rules)
|
||||||
|
:action)
|
||||||
|
"hide")
|
||||||
|
(mod-dr-test!
|
||||||
|
"defrule policy: clean → keep"
|
||||||
|
(get
|
||||||
|
(mod/decide-report mod-dr-clean (list mod-dr-clean) mod-dr-rules)
|
||||||
|
:action)
|
||||||
|
"keep")
|
||||||
|
(mod-dr-test!
|
||||||
|
"defrule policy: spam names the rule"
|
||||||
|
(get (mod/decide-report mod-dr-spam (list mod-dr-spam) mod-dr-rules) :rule)
|
||||||
|
"spam-hide")
|
||||||
|
|
||||||
|
(define mod-defrule-tests-run! (fn () {:failures mod-dr-failures :total mod-dr-count :passed mod-dr-pass :failed mod-dr-fail}))
|
||||||
145
lib/mod/tests/disjunction.sx
Normal file
145
lib/mod/tests/disjunction.sx
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
;; lib/mod/tests/disjunction.sx — Ext 15: disjunctive (:any) conditions.
|
||||||
|
|
||||||
|
(define mod-or-count 0)
|
||||||
|
(define mod-or-pass 0)
|
||||||
|
(define mod-or-fail 0)
|
||||||
|
(define mod-or-failures (list))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod-or-test!
|
||||||
|
(fn
|
||||||
|
(name got expected)
|
||||||
|
(begin
|
||||||
|
(set! mod-or-count (+ mod-or-count 1))
|
||||||
|
(if
|
||||||
|
(= got expected)
|
||||||
|
(set! mod-or-pass (+ mod-or-pass 1))
|
||||||
|
(begin
|
||||||
|
(set! mod-or-fail (+ mod-or-fail 1))
|
||||||
|
(append!
|
||||||
|
mod-or-failures
|
||||||
|
(str name "\n expected: " expected "\n got: " got)))))))
|
||||||
|
|
||||||
|
;; one rule, OR of two classifications → one action covers both
|
||||||
|
(define
|
||||||
|
mod-or-rules
|
||||||
|
(list
|
||||||
|
(mod/mk-rule
|
||||||
|
"spam-or-abuse-hide"
|
||||||
|
:hide (list
|
||||||
|
(list
|
||||||
|
:any (list (list :classification "spam") (list :classification "abuse")))))
|
||||||
|
(mod/mk-rule "default-keep" :keep (list))))
|
||||||
|
|
||||||
|
(define mod-or-spam (mod/mk-report "r1" "a" "b" "this is spam"))
|
||||||
|
(define mod-or-abuse (mod/mk-report "r2" "a" "b" "harassment here"))
|
||||||
|
(define mod-or-clean (mod/mk-report "r3" "a" "b" "a fine post"))
|
||||||
|
|
||||||
|
(mod-or-test!
|
||||||
|
"OR: spam branch → hide"
|
||||||
|
(get
|
||||||
|
(mod/decide-report mod-or-spam (list mod-or-spam) mod-or-rules)
|
||||||
|
:action)
|
||||||
|
"hide")
|
||||||
|
(mod-or-test!
|
||||||
|
"OR: abuse branch → hide"
|
||||||
|
(get
|
||||||
|
(mod/decide-report mod-or-abuse (list mod-or-abuse) mod-or-rules)
|
||||||
|
:action)
|
||||||
|
"hide")
|
||||||
|
(mod-or-test!
|
||||||
|
"OR: neither branch → keep"
|
||||||
|
(get
|
||||||
|
(mod/decide-report mod-or-clean (list mod-or-clean) mod-or-rules)
|
||||||
|
:action)
|
||||||
|
"keep")
|
||||||
|
|
||||||
|
;; ── goal text + proof ──
|
||||||
|
|
||||||
|
(mod-or-test!
|
||||||
|
"cond->goal :any joins with ;"
|
||||||
|
(mod/cond->goal
|
||||||
|
(list
|
||||||
|
:any (list (list :classification "spam") (list :classification "abuse")))
|
||||||
|
"Id")
|
||||||
|
"(classification(Id, spam) ; classification(Id, abuse))")
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod-or-dec
|
||||||
|
(mod/decide-report mod-or-spam (list mod-or-spam) mod-or-rules))
|
||||||
|
(mod-or-test!
|
||||||
|
"OR proof goal solved"
|
||||||
|
(get (first (get (get mod-or-dec :proof) :goals)) :solved)
|
||||||
|
true)
|
||||||
|
(mod-or-test!
|
||||||
|
"OR proof goal text"
|
||||||
|
(get (first (get (get mod-or-dec :proof) :goals)) :goal)
|
||||||
|
"(classification(r1, spam) ; classification(r1, abuse))")
|
||||||
|
|
||||||
|
;; ── :any composes with :not (NOR-ish) and :attr ──
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod-or-mixed-rules
|
||||||
|
(list
|
||||||
|
(mod/mk-rule
|
||||||
|
"spam-or-flagged-hide"
|
||||||
|
:hide (list
|
||||||
|
(list
|
||||||
|
:any (list (list :classification "spam") (list :attr "flagged")))))
|
||||||
|
(mod/mk-rule "default-keep" :keep (list))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod-or-flagged
|
||||||
|
(mod/attach-attr (mod/mk-report "r4" "a" "b" "a fine post") "flagged"))
|
||||||
|
(mod-or-test!
|
||||||
|
"OR over classification|attr: flagged clean post → hide"
|
||||||
|
(get
|
||||||
|
(mod/decide-report
|
||||||
|
mod-or-flagged
|
||||||
|
(list mod-or-flagged)
|
||||||
|
mod-or-mixed-rules)
|
||||||
|
:action)
|
||||||
|
"hide")
|
||||||
|
|
||||||
|
(mod-or-test!
|
||||||
|
"cond->goal :any with :not branch"
|
||||||
|
(mod/cond->goal
|
||||||
|
(list
|
||||||
|
:any (list
|
||||||
|
(list :classification "spam")
|
||||||
|
(list :not (list :attr "verified"))))
|
||||||
|
"Id")
|
||||||
|
"(classification(Id, spam) ; not(attr(Id, verified)))")
|
||||||
|
|
||||||
|
;; AND still works alongside OR in the same :when list
|
||||||
|
(define
|
||||||
|
mod-or-and-rules
|
||||||
|
(list
|
||||||
|
(mod/mk-rule
|
||||||
|
"spam-and-not-verified"
|
||||||
|
:hide (list
|
||||||
|
(list
|
||||||
|
:any (list (list :classification "spam") (list :classification "abuse")))
|
||||||
|
(list :not (list :attr "verified"))))
|
||||||
|
(mod/mk-rule "default-keep" :keep (list))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod-or-spam-verified
|
||||||
|
(mod/attach-attr (mod/mk-report "r5" "a" "b" "this is spam") "verified"))
|
||||||
|
(mod-or-test!
|
||||||
|
"AND of OR + NOT: verified spam → keep"
|
||||||
|
(get
|
||||||
|
(mod/decide-report
|
||||||
|
mod-or-spam-verified
|
||||||
|
(list mod-or-spam-verified)
|
||||||
|
mod-or-and-rules)
|
||||||
|
:action)
|
||||||
|
"keep")
|
||||||
|
(mod-or-test!
|
||||||
|
"AND of OR + NOT: unverified abuse → hide"
|
||||||
|
(get
|
||||||
|
(mod/decide-report mod-or-abuse (list mod-or-abuse) mod-or-and-rules)
|
||||||
|
:action)
|
||||||
|
"hide")
|
||||||
|
|
||||||
|
(define mod-disjunction-tests-run! (fn () {:failures mod-or-failures :total mod-or-count :passed mod-or-pass :failed mod-or-fail}))
|
||||||
279
lib/mod/tests/escalation.sx
Normal file
279
lib/mod/tests/escalation.sx
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
;; lib/mod/tests/escalation.sx — Phase 3: lifecycle state machine + escalation.
|
||||||
|
|
||||||
|
(define mod-esc-count 0)
|
||||||
|
(define mod-esc-pass 0)
|
||||||
|
(define mod-esc-fail 0)
|
||||||
|
(define mod-esc-failures (list))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod-esc-test!
|
||||||
|
(fn
|
||||||
|
(name got expected)
|
||||||
|
(begin
|
||||||
|
(set! mod-esc-count (+ mod-esc-count 1))
|
||||||
|
(if
|
||||||
|
(= got expected)
|
||||||
|
(set! mod-esc-pass (+ mod-esc-pass 1))
|
||||||
|
(begin
|
||||||
|
(set! mod-esc-fail (+ mod-esc-fail 1))
|
||||||
|
(append!
|
||||||
|
mod-esc-failures
|
||||||
|
(str name "\n expected: " expected "\n got: " got)))))))
|
||||||
|
|
||||||
|
;; ── transition table guard ──
|
||||||
|
|
||||||
|
(mod-esc-test!
|
||||||
|
"open → triaged allowed"
|
||||||
|
(mod/lc-can-transition? "open" "triaged")
|
||||||
|
true)
|
||||||
|
(mod-esc-test!
|
||||||
|
"triaged → decided allowed"
|
||||||
|
(mod/lc-can-transition? "triaged" "decided")
|
||||||
|
true)
|
||||||
|
(mod-esc-test!
|
||||||
|
"decided → appealed allowed"
|
||||||
|
(mod/lc-can-transition? "decided" "appealed")
|
||||||
|
true)
|
||||||
|
(mod-esc-test!
|
||||||
|
"appealed → final allowed"
|
||||||
|
(mod/lc-can-transition? "appealed" "final")
|
||||||
|
true)
|
||||||
|
(mod-esc-test!
|
||||||
|
"open → decided rejected"
|
||||||
|
(mod/lc-can-transition? "open" "decided")
|
||||||
|
false)
|
||||||
|
(mod-esc-test!
|
||||||
|
"triaged → final rejected"
|
||||||
|
(mod/lc-can-transition? "triaged" "final")
|
||||||
|
false)
|
||||||
|
(mod-esc-test!
|
||||||
|
"final is terminal"
|
||||||
|
(mod/lc-can-transition? "final" "open")
|
||||||
|
false)
|
||||||
|
|
||||||
|
;; ── initial state ──
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod-esc-c0
|
||||||
|
(mod/mk-case (mod/mk-report "r1" "alice" "bob" "this is spam")))
|
||||||
|
(mod-esc-test! "new case is open" (mod/case-state mod-esc-c0) "open")
|
||||||
|
(mod-esc-test! "new case has no decision" (mod/case-decision mod-esc-c0) nil)
|
||||||
|
|
||||||
|
;; ── auto-tier: spam triages + resolves to decided/hide ──
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod-esc-spam-rep
|
||||||
|
(list (mod/mk-report "r1" "alice" "bob" "this is spam")))
|
||||||
|
(define
|
||||||
|
mod-esc-t1
|
||||||
|
(mod/case-triage mod-esc-c0 mod-esc-spam-rep mod/default-rules))
|
||||||
|
(mod-esc-test! "spam triaged" (mod/case-state mod-esc-t1) "triaged")
|
||||||
|
(mod-esc-test! "spam triage tier auto" (mod/case-tier mod-esc-t1) "auto")
|
||||||
|
(mod-esc-test! "spam triage action hide" (mod/case-action mod-esc-t1) "hide")
|
||||||
|
|
||||||
|
(define mod-esc-r1 (mod/case-resolve mod-esc-t1))
|
||||||
|
(mod-esc-test!
|
||||||
|
"auto resolve → decided"
|
||||||
|
(mod/case-state mod-esc-r1)
|
||||||
|
"decided")
|
||||||
|
(mod-esc-test!
|
||||||
|
"decision preserved through resolve"
|
||||||
|
(mod/case-action mod-esc-r1)
|
||||||
|
"hide")
|
||||||
|
|
||||||
|
;; ── illegal transition flags :error, leaves state ──
|
||||||
|
|
||||||
|
(define mod-esc-bad (mod/case-finalize mod-esc-c0))
|
||||||
|
(mod-esc-test!
|
||||||
|
"finalize from open is illegal"
|
||||||
|
(mod/case-state mod-esc-bad)
|
||||||
|
"open")
|
||||||
|
(mod-esc-test!
|
||||||
|
"illegal transition sets error"
|
||||||
|
(nil? (mod/case-error mod-esc-bad))
|
||||||
|
false)
|
||||||
|
|
||||||
|
;; ── human-tier: repeated report escalates, resolve blocked, review decides ──
|
||||||
|
|
||||||
|
(define mod-esc-rep-r (mod/mk-report "r3" "ann" "dave" "off-topic"))
|
||||||
|
(define mod-esc-rep-reports (list mod-esc-rep-r mod-esc-rep-r mod-esc-rep-r))
|
||||||
|
(define mod-esc-rep-c0 (mod/mk-case mod-esc-rep-r))
|
||||||
|
(define
|
||||||
|
mod-esc-rep-t
|
||||||
|
(mod/case-triage mod-esc-rep-c0 mod-esc-rep-reports mod/default-rules))
|
||||||
|
|
||||||
|
(mod-esc-test!
|
||||||
|
"repeated triage action escalate"
|
||||||
|
(mod/case-action mod-esc-rep-t)
|
||||||
|
"escalate")
|
||||||
|
(mod-esc-test!
|
||||||
|
"repeated triage tier human"
|
||||||
|
(mod/case-tier mod-esc-rep-t)
|
||||||
|
"human")
|
||||||
|
(mod-esc-test!
|
||||||
|
"repeated still triaged after triage"
|
||||||
|
(mod/case-state mod-esc-rep-t)
|
||||||
|
"triaged")
|
||||||
|
|
||||||
|
(define mod-esc-rep-block (mod/case-resolve mod-esc-rep-t))
|
||||||
|
(mod-esc-test!
|
||||||
|
"auto-resolve blocked on human tier (state unchanged)"
|
||||||
|
(mod/case-state mod-esc-rep-block)
|
||||||
|
"triaged")
|
||||||
|
(mod-esc-test!
|
||||||
|
"blocked resolve sets error"
|
||||||
|
(nil? (mod/case-error mod-esc-rep-block))
|
||||||
|
false)
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod-esc-rep-rev
|
||||||
|
(mod/case-review
|
||||||
|
mod-esc-rep-t
|
||||||
|
"confirmed-abuse"
|
||||||
|
"human"
|
||||||
|
mod-esc-rep-reports
|
||||||
|
mod/default-rules))
|
||||||
|
(mod-esc-test!
|
||||||
|
"human review → decided"
|
||||||
|
(mod/case-state mod-esc-rep-rev)
|
||||||
|
"decided")
|
||||||
|
(mod-esc-test!
|
||||||
|
"human review action remove"
|
||||||
|
(mod/case-action mod-esc-rep-rev)
|
||||||
|
"remove")
|
||||||
|
(mod-esc-test!
|
||||||
|
"review attached evidence to report"
|
||||||
|
(len (mod/report-evidence (mod/case-report mod-esc-rep-rev)))
|
||||||
|
1)
|
||||||
|
|
||||||
|
(define mod-esc-rep-final (mod/case-finalize mod-esc-rep-rev))
|
||||||
|
(mod-esc-test!
|
||||||
|
"review case finalizes"
|
||||||
|
(mod/case-state mod-esc-rep-final)
|
||||||
|
"final")
|
||||||
|
|
||||||
|
;; ── appeal overrides a prior decision ──
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod-esc-ap-c0
|
||||||
|
(mod/mk-case (mod/mk-report "r5" "u" "v" "buy now spam")))
|
||||||
|
(define mod-esc-ap-rep (list (mod/mk-report "r5" "u" "v" "buy now spam")))
|
||||||
|
(define
|
||||||
|
mod-esc-ap-t
|
||||||
|
(mod/case-triage mod-esc-ap-c0 mod-esc-ap-rep mod/default-rules))
|
||||||
|
(define mod-esc-ap-d (mod/case-resolve mod-esc-ap-t))
|
||||||
|
|
||||||
|
(mod-esc-test!
|
||||||
|
"appeal precondition decided/hide"
|
||||||
|
(mod/case-action mod-esc-ap-d)
|
||||||
|
"hide")
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod-esc-ap-appealed
|
||||||
|
(mod/case-appeal
|
||||||
|
mod-esc-ap-d
|
||||||
|
"exonerated"
|
||||||
|
"moderator"
|
||||||
|
mod-esc-ap-rep
|
||||||
|
mod/default-rules))
|
||||||
|
(mod-esc-test!
|
||||||
|
"appeal → appealed state"
|
||||||
|
(mod/case-state mod-esc-ap-appealed)
|
||||||
|
"appealed")
|
||||||
|
(mod-esc-test!
|
||||||
|
"appeal overrides hide → keep"
|
||||||
|
(mod/case-action mod-esc-ap-appealed)
|
||||||
|
"keep")
|
||||||
|
(mod-esc-test!
|
||||||
|
"appeal recorded via exonerated-keep rule"
|
||||||
|
(get (mod/case-decision mod-esc-ap-appealed) :rule)
|
||||||
|
"exonerated-keep")
|
||||||
|
|
||||||
|
(define mod-esc-ap-final (mod/case-finalize mod-esc-ap-appealed))
|
||||||
|
(mod-esc-test! "appealed → final" (mod/case-state mod-esc-ap-final) "final")
|
||||||
|
|
||||||
|
;; ── history records the full traversal ──
|
||||||
|
|
||||||
|
(mod-esc-test!
|
||||||
|
"full lifecycle history length 4 (triage,resolve,appeal,finalize)"
|
||||||
|
(len (mod/case-history mod-esc-ap-final))
|
||||||
|
4)
|
||||||
|
(mod-esc-test!
|
||||||
|
"first history step open→triaged"
|
||||||
|
(get (first (mod/case-history mod-esc-ap-final)) :to)
|
||||||
|
"triaged")
|
||||||
|
(mod-esc-test!
|
||||||
|
"last history step → final"
|
||||||
|
(get (nth (mod/case-history mod-esc-ap-final) 3) :to)
|
||||||
|
"final")
|
||||||
|
|
||||||
|
;; ── api-level lifecycle façade ──
|
||||||
|
|
||||||
|
(mod/reset!)
|
||||||
|
(mod/report "alice" "bob" "this is spam")
|
||||||
|
(mod/report "carol" "dave" "off-topic")
|
||||||
|
(mod/report "carol" "dave" "off-topic")
|
||||||
|
(mod/report "carol" "dave" "off-topic")
|
||||||
|
|
||||||
|
(mod-esc-test!
|
||||||
|
"api: case opens at open"
|
||||||
|
(mod/case-state (mod/case-of "r1"))
|
||||||
|
"open")
|
||||||
|
|
||||||
|
(define mod-esc-api-t1 (mod/triage "r1"))
|
||||||
|
(mod-esc-test!
|
||||||
|
"api: triage spam → triaged"
|
||||||
|
(mod/case-state mod-esc-api-t1)
|
||||||
|
"triaged")
|
||||||
|
(mod-esc-test!
|
||||||
|
"api: triage spam action hide"
|
||||||
|
(mod/case-action mod-esc-api-t1)
|
||||||
|
"hide")
|
||||||
|
|
||||||
|
(define mod-esc-api-r1 (mod/resolve "r1"))
|
||||||
|
(mod-esc-test!
|
||||||
|
"api: resolve → decided"
|
||||||
|
(mod/case-state mod-esc-api-r1)
|
||||||
|
"decided")
|
||||||
|
(mod-esc-test!
|
||||||
|
"api: resolve logged decision"
|
||||||
|
(len (mod/audit "r1"))
|
||||||
|
1)
|
||||||
|
|
||||||
|
(define mod-esc-api-app (mod/appeal "r1" "exonerated" "mod"))
|
||||||
|
(mod-esc-test!
|
||||||
|
"api: appeal → appealed"
|
||||||
|
(mod/case-state mod-esc-api-app)
|
||||||
|
"appealed")
|
||||||
|
(mod-esc-test!
|
||||||
|
"api: appeal overrides → keep"
|
||||||
|
(mod/case-action mod-esc-api-app)
|
||||||
|
"keep")
|
||||||
|
(mod-esc-test!
|
||||||
|
"api: appeal logged second decision"
|
||||||
|
(len (mod/audit "r1"))
|
||||||
|
2)
|
||||||
|
(mod-esc-test!
|
||||||
|
"api: finalize → final"
|
||||||
|
(mod/case-state (mod/finalize "r1"))
|
||||||
|
"final")
|
||||||
|
|
||||||
|
;; r4 is the 3rd report about dave → escalates via the human tier
|
||||||
|
(define mod-esc-api-t4 (mod/triage "r4"))
|
||||||
|
(mod-esc-test!
|
||||||
|
"api: repeated triage escalates (human tier)"
|
||||||
|
(mod/case-tier mod-esc-api-t4)
|
||||||
|
"human")
|
||||||
|
(define mod-esc-api-blk (mod/resolve "r4"))
|
||||||
|
(mod-esc-test!
|
||||||
|
"api: escalated resolve blocked"
|
||||||
|
(mod/case-state mod-esc-api-blk)
|
||||||
|
"triaged")
|
||||||
|
(define mod-esc-api-rev (mod/review "r4" "confirmed-abuse" "human"))
|
||||||
|
(mod-esc-test!
|
||||||
|
"api: review → decided/remove"
|
||||||
|
(mod/case-action mod-esc-api-rev)
|
||||||
|
"remove")
|
||||||
|
(mod-esc-test! "api: unknown id → nil" (mod/triage "r99") nil)
|
||||||
|
|
||||||
|
(define mod-escalation-tests-run! (fn () {:failures mod-esc-failures :total mod-esc-count :passed mod-esc-pass :failed mod-esc-fail}))
|
||||||
313
lib/mod/tests/extensions.sx
Normal file
313
lib/mod/tests/extensions.sx
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
;; lib/mod/tests/extensions.sx — beyond-roadmap extensions.
|
||||||
|
;;
|
||||||
|
;; Ext 1: negation-as-failure conditions (:not / :attr) + report attributes.
|
||||||
|
;; "hide spam UNLESS the author is verified" (closed-world reasoning).
|
||||||
|
;; Ext 2: weighted/aggregate evidence scoring (:score-at-least) + report signals.
|
||||||
|
;; Many low-confidence signals accumulate past a threshold via Prolog
|
||||||
|
;; aggregate_all(sum(W), ...).
|
||||||
|
;; Ext 3: human-readable proof explanation (mod/explain) over the proof tree.
|
||||||
|
;; Demonstrated with custom rule sets so the default policy (and its conformance
|
||||||
|
;; tests) stays untouched.
|
||||||
|
|
||||||
|
(define mod-ext-count 0)
|
||||||
|
(define mod-ext-pass 0)
|
||||||
|
(define mod-ext-fail 0)
|
||||||
|
(define mod-ext-failures (list))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod-ext-test!
|
||||||
|
(fn
|
||||||
|
(name got expected)
|
||||||
|
(begin
|
||||||
|
(set! mod-ext-count (+ mod-ext-count 1))
|
||||||
|
(if
|
||||||
|
(= got expected)
|
||||||
|
(set! mod-ext-pass (+ mod-ext-pass 1))
|
||||||
|
(begin
|
||||||
|
(set! mod-ext-fail (+ mod-ext-fail 1))
|
||||||
|
(append!
|
||||||
|
mod-ext-failures
|
||||||
|
(str name "\n expected: " expected "\n got: " got)))))))
|
||||||
|
|
||||||
|
;; ── Ext 1: report attributes ──
|
||||||
|
|
||||||
|
(define mod-ext-r0 (mod/mk-report "r1" "a" "b" "this is spam"))
|
||||||
|
(mod-ext-test!
|
||||||
|
"fresh report has no attrs"
|
||||||
|
(len (mod/report-attrs mod-ext-r0))
|
||||||
|
0)
|
||||||
|
(define mod-ext-rv (mod/attach-attr mod-ext-r0 "verified"))
|
||||||
|
(mod-ext-test!
|
||||||
|
"attach-attr adds one attr"
|
||||||
|
(len (mod/report-attrs mod-ext-rv))
|
||||||
|
1)
|
||||||
|
(mod-ext-test!
|
||||||
|
"attach-attr preserves evidence field"
|
||||||
|
(len
|
||||||
|
(mod/report-evidence
|
||||||
|
(mod/attach-evidence mod-ext-rv (mod/mk-evidence "x" "y"))))
|
||||||
|
1)
|
||||||
|
(mod-ext-test!
|
||||||
|
"attach-evidence preserves attrs"
|
||||||
|
(len
|
||||||
|
(mod/report-attrs
|
||||||
|
(mod/attach-evidence mod-ext-rv (mod/mk-evidence "x" "y"))))
|
||||||
|
1)
|
||||||
|
|
||||||
|
;; ── Ext 1: negation-as-failure: spam hidden unless author verified ──
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod-ext-rules
|
||||||
|
(list
|
||||||
|
(mod/mk-rule
|
||||||
|
"spam-unverified-hide"
|
||||||
|
:hide (list
|
||||||
|
(list :classification "spam")
|
||||||
|
(list :not (list :attr "verified"))))
|
||||||
|
(mod/mk-rule "default-keep" :keep (list))))
|
||||||
|
|
||||||
|
(define mod-ext-spam-plain (mod/mk-report "p1" "a" "b" "this is spam"))
|
||||||
|
(define
|
||||||
|
mod-ext-spam-verified
|
||||||
|
(mod/attach-attr (mod/mk-report "p2" "a" "b" "this is spam") "verified"))
|
||||||
|
(define mod-ext-clean (mod/mk-report "p3" "a" "b" "a fine post"))
|
||||||
|
|
||||||
|
(mod-ext-test!
|
||||||
|
"unverified spam → hide"
|
||||||
|
(get
|
||||||
|
(mod/decide-report
|
||||||
|
mod-ext-spam-plain
|
||||||
|
(list mod-ext-spam-plain)
|
||||||
|
mod-ext-rules)
|
||||||
|
:action)
|
||||||
|
"hide")
|
||||||
|
(mod-ext-test!
|
||||||
|
"verified author spam → keep (negation blocks)"
|
||||||
|
(get
|
||||||
|
(mod/decide-report
|
||||||
|
mod-ext-spam-verified
|
||||||
|
(list mod-ext-spam-verified)
|
||||||
|
mod-ext-rules)
|
||||||
|
:action)
|
||||||
|
"keep")
|
||||||
|
(mod-ext-test!
|
||||||
|
"clean post → keep"
|
||||||
|
(get
|
||||||
|
(mod/decide-report mod-ext-clean (list mod-ext-clean) mod-ext-rules)
|
||||||
|
:action)
|
||||||
|
"keep")
|
||||||
|
|
||||||
|
;; ── Ext 1: negation appears in the goal text + proof ──
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod-ext-dec
|
||||||
|
(mod/decide-report
|
||||||
|
mod-ext-spam-plain
|
||||||
|
(list mod-ext-spam-plain)
|
||||||
|
mod-ext-rules))
|
||||||
|
(define mod-ext-goals (get (get mod-ext-dec :proof) :goals))
|
||||||
|
|
||||||
|
(mod-ext-test!
|
||||||
|
"rule that matched is spam-unverified-hide"
|
||||||
|
(get mod-ext-dec :rule)
|
||||||
|
"spam-unverified-hide")
|
||||||
|
(mod-ext-test! "proof has two goals" (len mod-ext-goals) 2)
|
||||||
|
(mod-ext-test!
|
||||||
|
"negation goal text"
|
||||||
|
(get (nth mod-ext-goals 1) :goal)
|
||||||
|
"not(attr(p1, verified))")
|
||||||
|
(mod-ext-test!
|
||||||
|
"negation goal solved for unverified"
|
||||||
|
(get (nth mod-ext-goals 1) :solved)
|
||||||
|
true)
|
||||||
|
|
||||||
|
;; ── Ext 1: cond->goal compiles :attr and :not directly ──
|
||||||
|
|
||||||
|
(mod-ext-test!
|
||||||
|
"cond->goal :attr"
|
||||||
|
(mod/cond->goal (list :attr "verified") "Id")
|
||||||
|
"attr(Id, verified)")
|
||||||
|
(mod-ext-test!
|
||||||
|
"cond->goal :not wraps inner"
|
||||||
|
(mod/cond->goal (list :not (list :classification "spam")) "Id")
|
||||||
|
"not(classification(Id, spam))")
|
||||||
|
|
||||||
|
;; ── Ext 1: positive :attr condition (allowlist-style) ──
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod-ext-allow-rules
|
||||||
|
(list
|
||||||
|
(mod/mk-rule "trusted-keep" :keep (list (list :attr "trusted")))
|
||||||
|
(mod/mk-rule "spam-hide" :hide (list (list :classification "spam")))
|
||||||
|
(mod/mk-rule "default-keep" :keep (list))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod-ext-trusted-spam
|
||||||
|
(mod/attach-attr (mod/mk-report "t1" "a" "b" "this is spam") "trusted"))
|
||||||
|
(mod-ext-test!
|
||||||
|
"trusted attr exempts spam → keep"
|
||||||
|
(get
|
||||||
|
(mod/decide-report
|
||||||
|
mod-ext-trusted-spam
|
||||||
|
(list mod-ext-trusted-spam)
|
||||||
|
mod-ext-allow-rules)
|
||||||
|
:action)
|
||||||
|
"keep")
|
||||||
|
|
||||||
|
;; ── Ext 2: weighted signals + aggregate scoring ──
|
||||||
|
|
||||||
|
(define mod-ext-s0 (mod/mk-report "s1" "a" "b" "neutral"))
|
||||||
|
(mod-ext-test!
|
||||||
|
"fresh report has no signals"
|
||||||
|
(len (mod/report-signals mod-ext-s0))
|
||||||
|
0)
|
||||||
|
(define
|
||||||
|
mod-ext-s1
|
||||||
|
(mod/attach-signal mod-ext-s0 (mod/mk-signal "link" 2)))
|
||||||
|
(mod-ext-test!
|
||||||
|
"attach-signal adds one"
|
||||||
|
(len (mod/report-signals mod-ext-s1))
|
||||||
|
1)
|
||||||
|
(mod-ext-test!
|
||||||
|
"attach-signal preserves attrs"
|
||||||
|
(len
|
||||||
|
(mod/report-attrs
|
||||||
|
(mod/attach-signal mod-ext-rv (mod/mk-signal "x" 1))))
|
||||||
|
1)
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod-ext-score-rules
|
||||||
|
(list
|
||||||
|
(mod/mk-rule
|
||||||
|
"high-score-hide"
|
||||||
|
:hide (list (list :score-at-least 5)))
|
||||||
|
(mod/mk-rule "default-keep" :keep (list))))
|
||||||
|
|
||||||
|
;; one weak signal (2) — below threshold
|
||||||
|
(define
|
||||||
|
mod-ext-weak
|
||||||
|
(mod/attach-signal
|
||||||
|
(mod/mk-report "w1" "a" "b" "neutral")
|
||||||
|
(mod/mk-signal "link" 2)))
|
||||||
|
(mod-ext-test!
|
||||||
|
"single weak signal → keep (below threshold)"
|
||||||
|
(get
|
||||||
|
(mod/decide-report mod-ext-weak (list mod-ext-weak) mod-ext-score-rules)
|
||||||
|
:action)
|
||||||
|
"keep")
|
||||||
|
|
||||||
|
;; three signals summing to 6 — over threshold
|
||||||
|
(define
|
||||||
|
mod-ext-strong0
|
||||||
|
(mod/attach-signal
|
||||||
|
(mod/mk-report "w2" "a" "b" "neutral")
|
||||||
|
(mod/mk-signal "link" 2)))
|
||||||
|
(define
|
||||||
|
mod-ext-strong1
|
||||||
|
(mod/attach-signal mod-ext-strong0 (mod/mk-signal "newaccount" 2)))
|
||||||
|
(define
|
||||||
|
mod-ext-strong
|
||||||
|
(mod/attach-signal mod-ext-strong1 (mod/mk-signal "burst" 2)))
|
||||||
|
(mod-ext-test!
|
||||||
|
"accumulated signals (2+2+2=6) → hide"
|
||||||
|
(get
|
||||||
|
(mod/decide-report
|
||||||
|
mod-ext-strong
|
||||||
|
(list mod-ext-strong)
|
||||||
|
mod-ext-score-rules)
|
||||||
|
:action)
|
||||||
|
"hide")
|
||||||
|
(mod-ext-test!
|
||||||
|
"scoring rule named in decision"
|
||||||
|
(get
|
||||||
|
(mod/decide-report
|
||||||
|
mod-ext-strong
|
||||||
|
(list mod-ext-strong)
|
||||||
|
mod-ext-score-rules)
|
||||||
|
:rule)
|
||||||
|
"high-score-hide")
|
||||||
|
|
||||||
|
;; exactly at threshold (5) fires
|
||||||
|
(define
|
||||||
|
mod-ext-exact0
|
||||||
|
(mod/attach-signal
|
||||||
|
(mod/mk-report "w3" "a" "b" "neutral")
|
||||||
|
(mod/mk-signal "link" 3)))
|
||||||
|
(define
|
||||||
|
mod-ext-exact
|
||||||
|
(mod/attach-signal mod-ext-exact0 (mod/mk-signal "burst" 2)))
|
||||||
|
(mod-ext-test!
|
||||||
|
"exactly at threshold (5) → hide"
|
||||||
|
(get
|
||||||
|
(mod/decide-report mod-ext-exact (list mod-ext-exact) mod-ext-score-rules)
|
||||||
|
:action)
|
||||||
|
"hide")
|
||||||
|
|
||||||
|
(mod-ext-test!
|
||||||
|
"cond->goal :score-at-least"
|
||||||
|
(mod/cond->goal (list :score-at-least 5) "Id")
|
||||||
|
"aggregate_all(sum(W), signal(Id, _, W), T), T >= 5")
|
||||||
|
|
||||||
|
;; ── Ext 3: human-readable proof explanation ──
|
||||||
|
|
||||||
|
(define mod-ext-spam-explain (mod/explain mod-ext-dec))
|
||||||
|
|
||||||
|
(mod-ext-test!
|
||||||
|
"explain mentions the report id"
|
||||||
|
(mod/str-contains? mod-ext-spam-explain "Report p1")
|
||||||
|
true)
|
||||||
|
(mod-ext-test!
|
||||||
|
"explain mentions the action"
|
||||||
|
(mod/str-contains? mod-ext-spam-explain "hide")
|
||||||
|
true)
|
||||||
|
(mod-ext-test!
|
||||||
|
"explain mentions the rule"
|
||||||
|
(mod/str-contains? mod-ext-spam-explain "spam-unverified-hide")
|
||||||
|
true)
|
||||||
|
(mod-ext-test!
|
||||||
|
"explain marks proved goals"
|
||||||
|
(mod/str-contains? mod-ext-spam-explain "[proved]")
|
||||||
|
true)
|
||||||
|
(mod-ext-test!
|
||||||
|
"explain renders the evidence line"
|
||||||
|
(mod/str-contains? mod-ext-spam-explain "Evidence: spam")
|
||||||
|
true)
|
||||||
|
|
||||||
|
;; count-rule explanation shows the unification bindings
|
||||||
|
(define mod-ext-rep-r (mod/mk-report "rc" "ann" "dave" "off-topic"))
|
||||||
|
(define
|
||||||
|
mod-ext-rep-d
|
||||||
|
(mod/decide-report
|
||||||
|
mod-ext-rep-r
|
||||||
|
(list mod-ext-rep-r mod-ext-rep-r mod-ext-rep-r)
|
||||||
|
mod/default-rules))
|
||||||
|
(define mod-ext-rep-explain (mod/explain mod-ext-rep-d))
|
||||||
|
(mod-ext-test!
|
||||||
|
"explain shows binding N=3"
|
||||||
|
(mod/str-contains? mod-ext-rep-explain "N=3")
|
||||||
|
true)
|
||||||
|
(mod-ext-test!
|
||||||
|
"explain shows subject binding"
|
||||||
|
(mod/str-contains? mod-ext-rep-explain "dave")
|
||||||
|
true)
|
||||||
|
|
||||||
|
;; explain-goal direct: unproved goal gets [unproved]
|
||||||
|
(mod-ext-test!
|
||||||
|
"explain-goal marks unproved"
|
||||||
|
(mod/str-contains? (mod/explain-goal {:solved false :goal "attr(x, foo)" :bindings {}}) "[unproved]")
|
||||||
|
true)
|
||||||
|
;; explain-binds renders key=value pairs
|
||||||
|
(mod-ext-test!
|
||||||
|
"explain-binds renders pair"
|
||||||
|
(mod/explain-binds {:N "3"})
|
||||||
|
"N=3")
|
||||||
|
;; no-evidence decision says (none)
|
||||||
|
(define
|
||||||
|
mod-ext-keep-d
|
||||||
|
(mod/decide-report mod-ext-clean (list mod-ext-clean) mod-ext-rules))
|
||||||
|
(mod-ext-test!
|
||||||
|
"explain (none) for empty evidence"
|
||||||
|
(mod/str-contains? (mod/explain mod-ext-keep-d) "Evidence: (none)")
|
||||||
|
true)
|
||||||
|
|
||||||
|
(define mod-extensions-tests-run! (fn () {:failures mod-ext-failures :total mod-ext-count :passed mod-ext-pass :failed mod-ext-fail}))
|
||||||
154
lib/mod/tests/fed.sx
Normal file
154
lib/mod/tests/fed.sx
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
;; lib/mod/tests/fed.sx — Phase 4: federation (mock fed-sx).
|
||||||
|
|
||||||
|
(define mod-fed-count 0)
|
||||||
|
(define mod-fed-pass 0)
|
||||||
|
(define mod-fed-fail 0)
|
||||||
|
(define mod-fed-failures (list))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod-fed-test!
|
||||||
|
(fn
|
||||||
|
(name got expected)
|
||||||
|
(begin
|
||||||
|
(set! mod-fed-count (+ mod-fed-count 1))
|
||||||
|
(if
|
||||||
|
(= got expected)
|
||||||
|
(set! mod-fed-pass (+ mod-fed-pass 1))
|
||||||
|
(begin
|
||||||
|
(set! mod-fed-fail (+ mod-fed-fail 1))
|
||||||
|
(append!
|
||||||
|
mod-fed-failures
|
||||||
|
(str name "\n expected: " expected "\n got: " got)))))))
|
||||||
|
|
||||||
|
(mod/reset!)
|
||||||
|
(mod/fed-reset!)
|
||||||
|
|
||||||
|
;; ── trust model (advisory by default) ──
|
||||||
|
|
||||||
|
(mod-fed-test! "trust initially false" (mod/trusted? "peerA" :mod) false)
|
||||||
|
(mod/grant-trust "peerA" :mod)
|
||||||
|
(mod-fed-test! "trust after grant" (mod/trusted? "peerA" :mod) true)
|
||||||
|
(mod-fed-test! "trust wrong scope" (mod/trusted? "peerA" :other) false)
|
||||||
|
(mod-fed-test! "trust other peer" (mod/trusted? "peerB" :mod) false)
|
||||||
|
(mod/revoke-trust "peerA" :mod)
|
||||||
|
(mod-fed-test! "trust after revoke" (mod/trusted? "peerA" :mod) false)
|
||||||
|
|
||||||
|
;; ── cross-instance reports ──
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod-fed-fr
|
||||||
|
(mod/fed-receive-report "peerB" "alice" "bob" "this is spam"))
|
||||||
|
(mod-fed-test! "fed report assigned id r1" (mod/report-id mod-fed-fr) "r1")
|
||||||
|
(mod-fed-test! "fed report origin is peer" (mod/report-origin "r1") "peerB")
|
||||||
|
(define mod-fed-local (mod/report "carol" "dave" "fine post"))
|
||||||
|
(mod-fed-test!
|
||||||
|
"local report origin is local"
|
||||||
|
(mod/report-origin (mod/report-id mod-fed-local))
|
||||||
|
"local")
|
||||||
|
(mod-fed-test!
|
||||||
|
"engine decides fed report (spam → hide)"
|
||||||
|
(get
|
||||||
|
(mod/decide-report mod-fed-fr (list mod-fed-fr) mod/default-rules)
|
||||||
|
:action)
|
||||||
|
"hide")
|
||||||
|
|
||||||
|
;; ── decision sharing (outbox) ──
|
||||||
|
|
||||||
|
(define mod-fed-dec {:action "hide" :rule "spam-hide" :report-id "r1"})
|
||||||
|
(define
|
||||||
|
mod-fed-shared
|
||||||
|
(mod/fed-share-decision mod-fed-dec (list "peerB" "peerC")))
|
||||||
|
(mod-fed-test! "share returns notified peers" (len mod-fed-shared) 2)
|
||||||
|
(mod-fed-test! "outbox has two messages" (len (mod/fed-outbox)) 2)
|
||||||
|
(mod-fed-test!
|
||||||
|
"outbox message type decision"
|
||||||
|
(get (first (mod/fed-outbox)) :type)
|
||||||
|
"decision")
|
||||||
|
(mod-fed-test!
|
||||||
|
"outbox message addressed to peer"
|
||||||
|
(get (first (mod/fed-outbox)) :to)
|
||||||
|
"peerB")
|
||||||
|
|
||||||
|
;; ── receiving a peer decision: advisory unless trusted ──
|
||||||
|
|
||||||
|
(define mod-fed-untrusted (mod/fed-receive-decision "peerZ" {:action "remove" :rule "reviewer-remove" :report-id "rx"}))
|
||||||
|
(mod-fed-test!
|
||||||
|
"untrusted decision not applied"
|
||||||
|
(get mod-fed-untrusted :applied)
|
||||||
|
false)
|
||||||
|
(mod-fed-test!
|
||||||
|
"untrusted decision advisory"
|
||||||
|
(get mod-fed-untrusted :advisory)
|
||||||
|
true)
|
||||||
|
(mod-fed-test!
|
||||||
|
"untrusted decision absent from applied log"
|
||||||
|
(mod/fed-applied-action "rx")
|
||||||
|
nil)
|
||||||
|
(mod-fed-test!
|
||||||
|
"advisory log records suggestion"
|
||||||
|
(len mod/*fed-advisory*)
|
||||||
|
1)
|
||||||
|
|
||||||
|
(mod/grant-trust "peerT" :mod)
|
||||||
|
(define mod-fed-trusted (mod/fed-receive-decision "peerT" {:action "hide" :rule "spam-hide" :report-id "ry"}))
|
||||||
|
(mod-fed-test! "trusted decision applied" (get mod-fed-trusted :applied) true)
|
||||||
|
(mod-fed-test!
|
||||||
|
"trusted decision binds locally"
|
||||||
|
(get (mod/fed-applied-action "ry") :action)
|
||||||
|
"hide")
|
||||||
|
|
||||||
|
;; ── revocation ──
|
||||||
|
|
||||||
|
(mod-fed-test!
|
||||||
|
"applied action not yet revoked"
|
||||||
|
(get (mod/fed-applied-action "ry") :revoked)
|
||||||
|
false)
|
||||||
|
(mod/fed-revoke! "ry" "manual")
|
||||||
|
(mod-fed-test!
|
||||||
|
"revoke marks applied action revoked"
|
||||||
|
(get (mod/fed-applied-action "ry") :revoked)
|
||||||
|
true)
|
||||||
|
(mod-fed-test!
|
||||||
|
"revoke emits a revocation message"
|
||||||
|
(mod/any? (fn (m) (= (get m :type) "revocation")) (mod/fed-outbox))
|
||||||
|
true)
|
||||||
|
|
||||||
|
;; revoke-if-invalidated: proof still holds → no revocation
|
||||||
|
(define mod-fed-spam-r (mod/mk-report "rs" "a" "b" "this is spam"))
|
||||||
|
(define
|
||||||
|
mod-fed-spam-d
|
||||||
|
(mod/decide-report mod-fed-spam-r (list mod-fed-spam-r) mod/default-rules))
|
||||||
|
(mod-fed-test! "spam decision is hide" (get mod-fed-spam-d :action) "hide")
|
||||||
|
(define
|
||||||
|
mod-fed-rev-same
|
||||||
|
(mod/fed-revoke-if-invalidated
|
||||||
|
mod-fed-spam-r
|
||||||
|
mod-fed-spam-d
|
||||||
|
(list mod-fed-spam-r)
|
||||||
|
mod/default-rules))
|
||||||
|
(mod-fed-test!
|
||||||
|
"valid proof → not revoked"
|
||||||
|
(get mod-fed-rev-same :revoked)
|
||||||
|
false)
|
||||||
|
|
||||||
|
;; exoneration invalidates the proof → revocation
|
||||||
|
(define
|
||||||
|
mod-fed-exon-r
|
||||||
|
(mod/attach-evidence mod-fed-spam-r (mod/mk-evidence "exonerated" "mod")))
|
||||||
|
(define
|
||||||
|
mod-fed-rev-inv
|
||||||
|
(mod/fed-revoke-if-invalidated
|
||||||
|
mod-fed-exon-r
|
||||||
|
mod-fed-spam-d
|
||||||
|
(list mod-fed-exon-r)
|
||||||
|
mod/default-rules))
|
||||||
|
(mod-fed-test!
|
||||||
|
"invalidated proof → revoked"
|
||||||
|
(get mod-fed-rev-inv :revoked)
|
||||||
|
true)
|
||||||
|
(mod-fed-test!
|
||||||
|
"re-decision after exoneration is keep"
|
||||||
|
(get (get mod-fed-rev-inv :decision) :action)
|
||||||
|
"keep")
|
||||||
|
|
||||||
|
(define mod-fed-tests-run! (fn () {:failures mod-fed-failures :total mod-fed-count :passed mod-fed-pass :failed mod-fed-fail}))
|
||||||
86
lib/mod/tests/link.sx
Normal file
86
lib/mod/tests/link.sx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
;; lib/mod/tests/link.sx — Ext 4: report linking + dedup.
|
||||||
|
|
||||||
|
(define mod-lnk-count 0)
|
||||||
|
(define mod-lnk-pass 0)
|
||||||
|
(define mod-lnk-fail 0)
|
||||||
|
(define mod-lnk-failures (list))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod-lnk-test!
|
||||||
|
(fn
|
||||||
|
(name got expected)
|
||||||
|
(begin
|
||||||
|
(set! mod-lnk-count (+ mod-lnk-count 1))
|
||||||
|
(if
|
||||||
|
(= got expected)
|
||||||
|
(set! mod-lnk-pass (+ mod-lnk-pass 1))
|
||||||
|
(begin
|
||||||
|
(set! mod-lnk-fail (+ mod-lnk-fail 1))
|
||||||
|
(append!
|
||||||
|
mod-lnk-failures
|
||||||
|
(str name "\n expected: " expected "\n got: " got)))))))
|
||||||
|
|
||||||
|
;; ── link-key + dedup ──
|
||||||
|
|
||||||
|
(define mod-lnk-a (mod/mk-report "r1" "alice" "bob" "this is spam"))
|
||||||
|
(define mod-lnk-a2 (mod/mk-report "r2" "alice" "bob" "THIS IS SPAM"))
|
||||||
|
(define mod-lnk-b (mod/mk-report "r3" "carol" "bob" "abuse"))
|
||||||
|
(define mod-lnk-c (mod/mk-report "r4" "alice" "eve" "this is spam"))
|
||||||
|
|
||||||
|
(mod-lnk-test!
|
||||||
|
"identical reports share a link key (case-insensitive reason)"
|
||||||
|
(= (mod/link-key mod-lnk-a) (mod/link-key mod-lnk-a2))
|
||||||
|
true)
|
||||||
|
(mod-lnk-test!
|
||||||
|
"different reporter → different key"
|
||||||
|
(= (mod/link-key mod-lnk-a) (mod/link-key mod-lnk-b))
|
||||||
|
false)
|
||||||
|
(mod-lnk-test!
|
||||||
|
"different subject → different key"
|
||||||
|
(= (mod/link-key mod-lnk-a) (mod/link-key mod-lnk-c))
|
||||||
|
false)
|
||||||
|
|
||||||
|
(define mod-lnk-set (list mod-lnk-a mod-lnk-a2 mod-lnk-b mod-lnk-c))
|
||||||
|
(mod-lnk-test!
|
||||||
|
"dedup collapses identical reports"
|
||||||
|
(len (mod/dedup-reports mod-lnk-set))
|
||||||
|
3)
|
||||||
|
(mod-lnk-test!
|
||||||
|
"duplicate-count counts collapsed"
|
||||||
|
(mod/duplicate-count mod-lnk-set)
|
||||||
|
1)
|
||||||
|
(mod-lnk-test!
|
||||||
|
"dedup of all-distinct keeps all"
|
||||||
|
(len (mod/dedup-reports (list mod-lnk-a mod-lnk-b mod-lnk-c)))
|
||||||
|
3)
|
||||||
|
|
||||||
|
;; ── Prolog-backed relational linking ──
|
||||||
|
|
||||||
|
(mod-lnk-test!
|
||||||
|
"related-ids finds all reports about subject"
|
||||||
|
(len (mod/related-ids "bob" mod-lnk-set))
|
||||||
|
3)
|
||||||
|
(mod-lnk-test!
|
||||||
|
"related-ids returns the ids"
|
||||||
|
(mod/related-ids "eve" mod-lnk-set)
|
||||||
|
(list "r4"))
|
||||||
|
(mod-lnk-test!
|
||||||
|
"related-ids empty for unknown subject"
|
||||||
|
(mod/related-ids "nobody" mod-lnk-set)
|
||||||
|
(list))
|
||||||
|
|
||||||
|
;; reporters: bob reported by alice (x2) + carol → 3 raw, 2 distinct
|
||||||
|
(mod-lnk-test!
|
||||||
|
"reporters-of counts all reports"
|
||||||
|
(len (mod/reporters-of "bob" mod-lnk-set))
|
||||||
|
3)
|
||||||
|
(mod-lnk-test!
|
||||||
|
"distinct reporters-of dedups reporters"
|
||||||
|
(len (mod/distinct-reporters-of "bob" mod-lnk-set))
|
||||||
|
2)
|
||||||
|
(mod-lnk-test!
|
||||||
|
"distinct utility removes dups"
|
||||||
|
(mod/distinct (list "a" "b" "a" "c" "b"))
|
||||||
|
(list "a" "b" "c"))
|
||||||
|
|
||||||
|
(define mod-link-tests-run! (fn () {:failures mod-lnk-failures :total mod-lnk-count :passed mod-lnk-pass :failed mod-lnk-fail}))
|
||||||
122
lib/mod/tests/lint.sx
Normal file
122
lib/mod/tests/lint.sx
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
;; lib/mod/tests/lint.sx — Ext 5: policy rule-set static analysis.
|
||||||
|
|
||||||
|
(define mod-lint-count 0)
|
||||||
|
(define mod-lint-pass 0)
|
||||||
|
(define mod-lint-fail 0)
|
||||||
|
(define mod-lint-failures (list))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod-lint-test!
|
||||||
|
(fn
|
||||||
|
(name got expected)
|
||||||
|
(begin
|
||||||
|
(set! mod-lint-count (+ mod-lint-count 1))
|
||||||
|
(if
|
||||||
|
(= got expected)
|
||||||
|
(set! mod-lint-pass (+ mod-lint-pass 1))
|
||||||
|
(begin
|
||||||
|
(set! mod-lint-fail (+ mod-lint-fail 1))
|
||||||
|
(append!
|
||||||
|
mod-lint-failures
|
||||||
|
(str name "\n expected: " expected "\n got: " got)))))))
|
||||||
|
|
||||||
|
;; ── the default rule set is well-formed ──
|
||||||
|
|
||||||
|
(mod-lint-test!
|
||||||
|
"default rules: no unreachable"
|
||||||
|
(mod/unreachable-rules mod/default-rules)
|
||||||
|
(list))
|
||||||
|
(mod-lint-test!
|
||||||
|
"default rules: has catch-all"
|
||||||
|
(mod/has-catchall? mod/default-rules)
|
||||||
|
true)
|
||||||
|
(mod-lint-test!
|
||||||
|
"default rules: no duplicate names"
|
||||||
|
(mod/duplicate-rule-names mod/default-rules)
|
||||||
|
(list))
|
||||||
|
(mod-lint-test!
|
||||||
|
"default rules: well-formed"
|
||||||
|
(mod/rules-ok? mod/default-rules)
|
||||||
|
true)
|
||||||
|
|
||||||
|
;; ── unreachable detection ──
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod-lint-shadowed
|
||||||
|
(list
|
||||||
|
(mod/mk-rule "spam-hide" :hide (list (list :classification "spam")))
|
||||||
|
(mod/mk-rule "catch-all" :keep (list))
|
||||||
|
(mod/mk-rule
|
||||||
|
"abuse-remove"
|
||||||
|
:remove (list (list :classification "abuse")))
|
||||||
|
(mod/mk-rule
|
||||||
|
"repeated"
|
||||||
|
:escalate (list (list :count-at-least 3)))))
|
||||||
|
|
||||||
|
(mod-lint-test!
|
||||||
|
"rules after catch-all are unreachable"
|
||||||
|
(mod/unreachable-rules mod-lint-shadowed)
|
||||||
|
(list "abuse-remove" "repeated"))
|
||||||
|
(mod-lint-test!
|
||||||
|
"shadowed rule set is not ok"
|
||||||
|
(mod/rules-ok? mod-lint-shadowed)
|
||||||
|
false)
|
||||||
|
|
||||||
|
;; ── missing catch-all ──
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod-lint-nocatch
|
||||||
|
(list
|
||||||
|
(mod/mk-rule "spam-hide" :hide (list (list :classification "spam")))
|
||||||
|
(mod/mk-rule
|
||||||
|
"abuse-remove"
|
||||||
|
:remove (list (list :classification "abuse")))))
|
||||||
|
|
||||||
|
(mod-lint-test!
|
||||||
|
"no catch-all detected"
|
||||||
|
(mod/has-catchall? mod-lint-nocatch)
|
||||||
|
false)
|
||||||
|
(mod-lint-test!
|
||||||
|
"no unreachable when no catch-all"
|
||||||
|
(mod/unreachable-rules mod-lint-nocatch)
|
||||||
|
(list))
|
||||||
|
(mod-lint-test!
|
||||||
|
"no-catch-all rule set is not ok"
|
||||||
|
(mod/rules-ok? mod-lint-nocatch)
|
||||||
|
false)
|
||||||
|
|
||||||
|
;; ── duplicate names ──
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod-lint-dups
|
||||||
|
(list
|
||||||
|
(mod/mk-rule "x" :hide (list (list :classification "spam")))
|
||||||
|
(mod/mk-rule "x" :remove (list (list :classification "abuse")))
|
||||||
|
(mod/mk-rule "default" :keep (list))))
|
||||||
|
|
||||||
|
(mod-lint-test!
|
||||||
|
"duplicate names detected"
|
||||||
|
(mod/duplicate-rule-names mod-lint-dups)
|
||||||
|
(list "x"))
|
||||||
|
(mod-lint-test!
|
||||||
|
"duplicate-name rule set is not ok"
|
||||||
|
(mod/rules-ok? mod-lint-dups)
|
||||||
|
false)
|
||||||
|
|
||||||
|
;; ── helpers ──
|
||||||
|
|
||||||
|
(mod-lint-test!
|
||||||
|
"rule-unconditional? true for empty when"
|
||||||
|
(mod/rule-unconditional? (mod/mk-rule "d" :keep (list)))
|
||||||
|
true)
|
||||||
|
(mod-lint-test!
|
||||||
|
"rule-unconditional? false with conditions"
|
||||||
|
(mod/rule-unconditional?
|
||||||
|
(mod/mk-rule "s" :hide (list (list :classification "spam"))))
|
||||||
|
false)
|
||||||
|
(mod-lint-test!
|
||||||
|
"count-eq counts occurrences"
|
||||||
|
(mod/count-eq "a" (list "a" "b" "a"))
|
||||||
|
2)
|
||||||
|
|
||||||
|
(define mod-lint-tests-run! (fn () {:failures mod-lint-failures :total mod-lint-count :passed mod-lint-pass :failed mod-lint-fail}))
|
||||||
115
lib/mod/tests/offenders.sx
Normal file
115
lib/mod/tests/offenders.sx
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
;; lib/mod/tests/offenders.sx — Ext 7: repeat-offender escalation.
|
||||||
|
|
||||||
|
(define mod-off-count 0)
|
||||||
|
(define mod-off-pass 0)
|
||||||
|
(define mod-off-fail 0)
|
||||||
|
(define mod-off-failures (list))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod-off-test!
|
||||||
|
(fn
|
||||||
|
(name got expected)
|
||||||
|
(begin
|
||||||
|
(set! mod-off-count (+ mod-off-count 1))
|
||||||
|
(if
|
||||||
|
(= got expected)
|
||||||
|
(set! mod-off-pass (+ mod-off-pass 1))
|
||||||
|
(begin
|
||||||
|
(set! mod-off-fail (+ mod-off-fail 1))
|
||||||
|
(append!
|
||||||
|
mod-off-failures
|
||||||
|
(str name "\n expected: " expected "\n got: " got)))))))
|
||||||
|
|
||||||
|
;; ── sanction? predicate ──
|
||||||
|
|
||||||
|
(mod-off-test! "hide is a sanction" (mod/sanction? "hide") true)
|
||||||
|
(mod-off-test! "remove is a sanction" (mod/sanction? "remove") true)
|
||||||
|
(mod-off-test! "ban is a sanction" (mod/sanction? "ban") true)
|
||||||
|
(mod-off-test! "keep is not a sanction" (mod/sanction? "keep") false)
|
||||||
|
(mod-off-test! "escalate is not a sanction" (mod/sanction? "escalate") false)
|
||||||
|
|
||||||
|
;; ── repeat-offender escalation over the audit log ──
|
||||||
|
|
||||||
|
(mod/reset!)
|
||||||
|
(mod/report "u1" "spammer" "this is spam")
|
||||||
|
(mod/report "u2" "spammer" "buy now offer")
|
||||||
|
(mod/report "u3" "spammer" "click here free money")
|
||||||
|
(mod/report "u4" "innocent" "fine post")
|
||||||
|
|
||||||
|
(mod-off-test!
|
||||||
|
"no sanctions before any decision"
|
||||||
|
(mod/subject-sanctions "spammer")
|
||||||
|
0)
|
||||||
|
|
||||||
|
(define mod-off-d1 (mod/decide-escalating "r1" 2))
|
||||||
|
(mod-off-test!
|
||||||
|
"first spam → hide (0 priors)"
|
||||||
|
(get mod-off-d1 :action)
|
||||||
|
"hide")
|
||||||
|
(mod-off-test!
|
||||||
|
"one sanction recorded"
|
||||||
|
(mod/subject-sanctions "spammer")
|
||||||
|
1)
|
||||||
|
|
||||||
|
(define mod-off-d2 (mod/decide-escalating "r2" 2))
|
||||||
|
(mod-off-test!
|
||||||
|
"second spam → hide (1 prior, below k=2)"
|
||||||
|
(get mod-off-d2 :action)
|
||||||
|
"hide")
|
||||||
|
(mod-off-test!
|
||||||
|
"two sanctions recorded"
|
||||||
|
(mod/subject-sanctions "spammer")
|
||||||
|
2)
|
||||||
|
|
||||||
|
(define mod-off-d3 (mod/decide-escalating "r3" 2))
|
||||||
|
(mod-off-test!
|
||||||
|
"third spam → ban (2 priors ≥ k)"
|
||||||
|
(get mod-off-d3 :action)
|
||||||
|
"ban")
|
||||||
|
(mod-off-test!
|
||||||
|
"ban decision names repeat-offender rule"
|
||||||
|
(get mod-off-d3 :rule)
|
||||||
|
"repeat-offender-ban")
|
||||||
|
(mod-off-test!
|
||||||
|
"ban proof records prior sanction count"
|
||||||
|
(get (get mod-off-d3 :proof) :prior-sanctions)
|
||||||
|
2)
|
||||||
|
|
||||||
|
;; ── different subjects accumulate independently ──
|
||||||
|
|
||||||
|
(define mod-off-d4 (mod/decide-escalating "r4" 2))
|
||||||
|
(mod-off-test!
|
||||||
|
"innocent keep → not escalated"
|
||||||
|
(get mod-off-d4 :action)
|
||||||
|
"keep")
|
||||||
|
(mod-off-test!
|
||||||
|
"innocent has no sanctions"
|
||||||
|
(mod/subject-sanctions "innocent")
|
||||||
|
0)
|
||||||
|
(mod-off-test!
|
||||||
|
"repeat-offender? true for spammer at k=2"
|
||||||
|
(mod/repeat-offender? "spammer" 2)
|
||||||
|
true)
|
||||||
|
(mod-off-test!
|
||||||
|
"repeat-offender? false for innocent at k=1"
|
||||||
|
(mod/repeat-offender? "innocent" 1)
|
||||||
|
false)
|
||||||
|
|
||||||
|
;; ── non-sanction decisions are never upgraded to ban ──
|
||||||
|
;; r5 is a clean post, but it is the 4th report about "spammer", so the
|
||||||
|
;; repeated-report rule escalates it. escalate is not a sanction, so it passes
|
||||||
|
;; through decide-escalating unchanged (never becomes :ban).
|
||||||
|
|
||||||
|
(mod/report "u5" "spammer" "a perfectly fine post")
|
||||||
|
(define mod-off-d5 (mod/decide-escalating "r5" 1))
|
||||||
|
(mod-off-test!
|
||||||
|
"non-sanction (escalate) decision is not upgraded to ban"
|
||||||
|
(get mod-off-d5 :action)
|
||||||
|
"escalate")
|
||||||
|
|
||||||
|
(mod-off-test!
|
||||||
|
"decide-escalating unknown id → nil"
|
||||||
|
(mod/decide-escalating "r99" 2)
|
||||||
|
nil)
|
||||||
|
|
||||||
|
(define mod-offenders-tests-run! (fn () {:failures mod-off-failures :total mod-off-count :passed mod-off-pass :failed mod-off-fail}))
|
||||||
112
lib/mod/tests/pipeline.sx
Normal file
112
lib/mod/tests/pipeline.sx
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
;; lib/mod/tests/pipeline.sx — Ext 19: end-to-end triage orchestration.
|
||||||
|
|
||||||
|
(define mod-pp-count 0)
|
||||||
|
(define mod-pp-pass 0)
|
||||||
|
(define mod-pp-fail 0)
|
||||||
|
(define mod-pp-failures (list))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod-pp-test!
|
||||||
|
(fn
|
||||||
|
(name got expected)
|
||||||
|
(begin
|
||||||
|
(set! mod-pp-count (+ mod-pp-count 1))
|
||||||
|
(if
|
||||||
|
(= got expected)
|
||||||
|
(set! mod-pp-pass (+ mod-pp-pass 1))
|
||||||
|
(begin
|
||||||
|
(set! mod-pp-fail (+ mod-pp-fail 1))
|
||||||
|
(append!
|
||||||
|
mod-pp-failures
|
||||||
|
(str name "\n expected: " expected "\n got: " got)))))))
|
||||||
|
|
||||||
|
(mod/policies-reset!)
|
||||||
|
(mod/register-policy!
|
||||||
|
"market"
|
||||||
|
(mod/ruleset
|
||||||
|
(mod/defrule "market-spam-remove" :remove (list :classification "spam"))
|
||||||
|
(mod/defrule "default-keep" :keep)))
|
||||||
|
|
||||||
|
;; ── spam in the market domain: full bundle ──
|
||||||
|
|
||||||
|
(define mod-pp-spam (mod/mk-report "r1" "u" "bob" "this is spam"))
|
||||||
|
(define
|
||||||
|
mod-pp
|
||||||
|
(mod/triage-pipeline "market" mod-pp-spam (list mod-pp-spam) "inst.example"))
|
||||||
|
|
||||||
|
(mod-pp-test!
|
||||||
|
"pipeline action (market policy → remove)"
|
||||||
|
(mod/pipeline-action mod-pp)
|
||||||
|
"remove")
|
||||||
|
(mod-pp-test! "pipeline rule" (get mod-pp :rule) "market-spam-remove")
|
||||||
|
(mod-pp-test!
|
||||||
|
"pipeline explanation mentions the action"
|
||||||
|
(mod/str-contains? (get mod-pp :explanation) "remove")
|
||||||
|
true)
|
||||||
|
(mod-pp-test!
|
||||||
|
"pipeline activity is Delete (remove)"
|
||||||
|
(get (mod/pipeline-activity mod-pp) :type)
|
||||||
|
"Delete")
|
||||||
|
(mod-pp-test!
|
||||||
|
"pipeline activity object is the report"
|
||||||
|
(get (mod/pipeline-activity mod-pp) :object)
|
||||||
|
"r1")
|
||||||
|
(mod-pp-test!
|
||||||
|
"pipeline wire round-trips to the same action"
|
||||||
|
(get (mod/wire->decision (mod/pipeline-wire mod-pp)) :action)
|
||||||
|
"remove")
|
||||||
|
|
||||||
|
;; ── same report, blog domain (default) → hide, Flag ──
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod-pp-blog
|
||||||
|
(mod/triage-pipeline "blog" mod-pp-spam (list mod-pp-spam) "inst.example"))
|
||||||
|
(mod-pp-test!
|
||||||
|
"blog default policy → hide"
|
||||||
|
(mod/pipeline-action mod-pp-blog)
|
||||||
|
"hide")
|
||||||
|
(mod-pp-test!
|
||||||
|
"blog activity is Flag"
|
||||||
|
(get (mod/pipeline-activity mod-pp-blog) :type)
|
||||||
|
"Flag")
|
||||||
|
|
||||||
|
;; ── clean report: keep, no activity, explanation says (none) ──
|
||||||
|
|
||||||
|
(define mod-pp-clean (mod/mk-report "r2" "u" "eve" "a fine post"))
|
||||||
|
(define
|
||||||
|
mod-pp-k
|
||||||
|
(mod/triage-pipeline
|
||||||
|
"market"
|
||||||
|
mod-pp-clean
|
||||||
|
(list mod-pp-clean)
|
||||||
|
"inst.example"))
|
||||||
|
(mod-pp-test! "clean → keep" (mod/pipeline-action mod-pp-k) "keep")
|
||||||
|
(mod-pp-test! "keep → no activity" (mod/pipeline-activity mod-pp-k) nil)
|
||||||
|
(mod-pp-test!
|
||||||
|
"keep explanation says no evidence"
|
||||||
|
(mod/str-contains? (get mod-pp-k :explanation) "Evidence: (none)")
|
||||||
|
true)
|
||||||
|
(mod-pp-test!
|
||||||
|
"keep wire still round-trips"
|
||||||
|
(get (mod/wire->decision (mod/pipeline-wire mod-pp-k)) :rule)
|
||||||
|
"default-keep")
|
||||||
|
|
||||||
|
;; ── federated handoff: market decision crosses to a peer, trust-gated ──
|
||||||
|
|
||||||
|
(mod/fed-reset!)
|
||||||
|
(define mod-pp-peer-dec (mod/wire->decision (mod/pipeline-wire mod-pp)))
|
||||||
|
(mod-pp-test!
|
||||||
|
"untrusted peer: market decision is advisory"
|
||||||
|
(get (mod/fed-receive-decision "peerX" mod-pp-peer-dec) :applied)
|
||||||
|
false)
|
||||||
|
(mod/grant-trust "peerY" :mod)
|
||||||
|
(mod-pp-test!
|
||||||
|
"trusted peer: market decision applies"
|
||||||
|
(get (mod/fed-receive-decision "peerY" mod-pp-peer-dec) :applied)
|
||||||
|
true)
|
||||||
|
(mod-pp-test!
|
||||||
|
"applied action is remove"
|
||||||
|
(get (mod/fed-applied-action "r1") :action)
|
||||||
|
"remove")
|
||||||
|
|
||||||
|
(define mod-pipeline-tests-run! (fn () {:failures mod-pp-failures :total mod-pp-count :passed mod-pp-pass :failed mod-pp-fail}))
|
||||||
112
lib/mod/tests/policies.sx
Normal file
112
lib/mod/tests/policies.sx
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
;; lib/mod/tests/policies.sx — Ext 17: per-domain policy registry.
|
||||||
|
|
||||||
|
(define mod-pol-count 0)
|
||||||
|
(define mod-pol-pass 0)
|
||||||
|
(define mod-pol-fail 0)
|
||||||
|
(define mod-pol-failures (list))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod-pol-test!
|
||||||
|
(fn
|
||||||
|
(name got expected)
|
||||||
|
(begin
|
||||||
|
(set! mod-pol-count (+ mod-pol-count 1))
|
||||||
|
(if
|
||||||
|
(= got expected)
|
||||||
|
(set! mod-pol-pass (+ mod-pol-pass 1))
|
||||||
|
(begin
|
||||||
|
(set! mod-pol-fail (+ mod-pol-fail 1))
|
||||||
|
(append!
|
||||||
|
mod-pol-failures
|
||||||
|
(str name "\n expected: " expected "\n got: " got)))))))
|
||||||
|
|
||||||
|
(mod/policies-reset!)
|
||||||
|
|
||||||
|
;; market is strict: spam is removed outright, not just hidden
|
||||||
|
(define
|
||||||
|
mod-pol-market-rules
|
||||||
|
(list
|
||||||
|
(mod/mk-rule
|
||||||
|
"market-spam-remove"
|
||||||
|
:remove (list (list :classification "spam")))
|
||||||
|
(mod/mk-rule "default-keep" :keep (list))))
|
||||||
|
|
||||||
|
(mod-pol-test!
|
||||||
|
"unregistered domain falls back to default"
|
||||||
|
(mod/policy-registered? "market")
|
||||||
|
false)
|
||||||
|
(mod/register-policy! "market" mod-pol-market-rules)
|
||||||
|
(mod-pol-test!
|
||||||
|
"domain registered after register!"
|
||||||
|
(mod/policy-registered? "market")
|
||||||
|
true)
|
||||||
|
|
||||||
|
(define mod-pol-spam (mod/mk-report "r1" "a" "b" "this is spam"))
|
||||||
|
|
||||||
|
;; ── same report, different domain → different action ──
|
||||||
|
|
||||||
|
(mod-pol-test!
|
||||||
|
"market policy removes spam"
|
||||||
|
(get (mod/decide-in "market" mod-pol-spam (list mod-pol-spam)) :action)
|
||||||
|
"remove")
|
||||||
|
(mod-pol-test!
|
||||||
|
"market decision uses market rule"
|
||||||
|
(get (mod/decide-in "market" mod-pol-spam (list mod-pol-spam)) :rule)
|
||||||
|
"market-spam-remove")
|
||||||
|
(mod-pol-test!
|
||||||
|
"blog (unregistered) uses default → hide"
|
||||||
|
(get (mod/decide-in "blog" mod-pol-spam (list mod-pol-spam)) :action)
|
||||||
|
"hide")
|
||||||
|
(mod-pol-test!
|
||||||
|
"blog decision uses default rule"
|
||||||
|
(get (mod/decide-in "blog" mod-pol-spam (list mod-pol-spam)) :rule)
|
||||||
|
"spam-hide")
|
||||||
|
|
||||||
|
;; ── policy-for resolution ──
|
||||||
|
|
||||||
|
(mod-pol-test!
|
||||||
|
"policy-for market returns market rules"
|
||||||
|
(mod/policy-for "market")
|
||||||
|
mod-pol-market-rules)
|
||||||
|
(mod-pol-test!
|
||||||
|
"policy-for unknown returns default"
|
||||||
|
(mod/policy-for "events")
|
||||||
|
mod/default-rules)
|
||||||
|
(mod-pol-test!
|
||||||
|
"registered-domains lists market"
|
||||||
|
(mod/registered-domains)
|
||||||
|
(list "market"))
|
||||||
|
|
||||||
|
;; ── a second domain ──
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod-pol-events-rules
|
||||||
|
(list (mod/mk-rule "events-keep-all" :keep (list))))
|
||||||
|
|
||||||
|
(mod/register-policy! "events" mod-pol-events-rules)
|
||||||
|
(mod-pol-test!
|
||||||
|
"events policy keeps everything (even spam)"
|
||||||
|
(get (mod/decide-in "events" mod-pol-spam (list mod-pol-spam)) :action)
|
||||||
|
"keep")
|
||||||
|
(mod-pol-test!
|
||||||
|
"two domains registered"
|
||||||
|
(len (mod/registered-domains))
|
||||||
|
2)
|
||||||
|
(mod-pol-test!
|
||||||
|
"market still removes after second registration"
|
||||||
|
(get (mod/decide-in "market" mod-pol-spam (list mod-pol-spam)) :action)
|
||||||
|
"remove")
|
||||||
|
|
||||||
|
;; ── clean report is keep everywhere ──
|
||||||
|
|
||||||
|
(define mod-pol-clean (mod/mk-report "r2" "a" "b" "a fine post"))
|
||||||
|
(mod-pol-test!
|
||||||
|
"clean report keep in market"
|
||||||
|
(get (mod/decide-in "market" mod-pol-clean (list mod-pol-clean)) :action)
|
||||||
|
"keep")
|
||||||
|
(mod-pol-test!
|
||||||
|
"clean report keep in blog"
|
||||||
|
(get (mod/decide-in "blog" mod-pol-clean (list mod-pol-clean)) :action)
|
||||||
|
"keep")
|
||||||
|
|
||||||
|
(define mod-policies-tests-run! (fn () {:failures mod-pol-failures :total mod-pol-count :passed mod-pol-pass :failed mod-pol-fail}))
|
||||||
119
lib/mod/tests/quorum.sx
Normal file
119
lib/mod/tests/quorum.sx
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
;; lib/mod/tests/quorum.sx — Ext 8: quorum over distinct reporters.
|
||||||
|
|
||||||
|
(define mod-q-count 0)
|
||||||
|
(define mod-q-pass 0)
|
||||||
|
(define mod-q-fail 0)
|
||||||
|
(define mod-q-failures (list))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod-q-test!
|
||||||
|
(fn
|
||||||
|
(name got expected)
|
||||||
|
(begin
|
||||||
|
(set! mod-q-count (+ mod-q-count 1))
|
||||||
|
(if
|
||||||
|
(= got expected)
|
||||||
|
(set! mod-q-pass (+ mod-q-pass 1))
|
||||||
|
(begin
|
||||||
|
(set! mod-q-fail (+ mod-q-fail 1))
|
||||||
|
(append!
|
||||||
|
mod-q-failures
|
||||||
|
(str name "\n expected: " expected "\n got: " got)))))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod-q-rules
|
||||||
|
(list
|
||||||
|
(mod/mk-rule
|
||||||
|
"quorum-hide"
|
||||||
|
:hide (list (list :reporters-at-least 2)))
|
||||||
|
(mod/mk-rule "default-keep" :keep (list))))
|
||||||
|
|
||||||
|
;; ── two distinct reporters meet quorum ──
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod-q-two
|
||||||
|
(list
|
||||||
|
(mod/mk-report "r1" "alice" "bob" "off-topic")
|
||||||
|
(mod/mk-report "r2" "carol" "bob" "off-topic")))
|
||||||
|
|
||||||
|
(mod-q-test!
|
||||||
|
"two distinct reporters → hide"
|
||||||
|
(get (mod/decide-quorum (first mod-q-two) mod-q-two mod-q-rules) :action)
|
||||||
|
"hide")
|
||||||
|
(mod-q-test!
|
||||||
|
"quorum decision names the rule"
|
||||||
|
(get (mod/decide-quorum (first mod-q-two) mod-q-two mod-q-rules) :rule)
|
||||||
|
"quorum-hide")
|
||||||
|
(mod-q-test!
|
||||||
|
"quorum decision tagged strategy"
|
||||||
|
(get (mod/decide-quorum (first mod-q-two) mod-q-two mod-q-rules) :strategy)
|
||||||
|
"quorum")
|
||||||
|
|
||||||
|
;; ── single reporter does not meet quorum ──
|
||||||
|
|
||||||
|
(define mod-q-one (list (mod/mk-report "r1" "alice" "bob" "off-topic")))
|
||||||
|
(mod-q-test!
|
||||||
|
"one reporter → keep (below quorum)"
|
||||||
|
(get (mod/decide-quorum (first mod-q-one) mod-q-one mod-q-rules) :action)
|
||||||
|
"keep")
|
||||||
|
|
||||||
|
;; ── anti-brigade: one user filing many reports does NOT meet quorum ──
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod-q-brigade
|
||||||
|
(list
|
||||||
|
(mod/mk-report "r1" "alice" "bob" "off-topic")
|
||||||
|
(mod/mk-report "r2" "alice" "bob" "off-topic")
|
||||||
|
(mod/mk-report "r3" "alice" "bob" "off-topic")))
|
||||||
|
|
||||||
|
(mod-q-test!
|
||||||
|
"three reports, one reporter → keep (quorum counts distinct)"
|
||||||
|
(get
|
||||||
|
(mod/decide-quorum (first mod-q-brigade) mod-q-brigade mod-q-rules)
|
||||||
|
:action)
|
||||||
|
"keep")
|
||||||
|
|
||||||
|
;; contrast: the count rule WOULD fire on the same brigade (3 reports ≥ 3) —
|
||||||
|
;; quorum is strictly stronger against single-actor brigading
|
||||||
|
(mod-q-test!
|
||||||
|
"count rule fires on the brigade (distinct from quorum)"
|
||||||
|
(get
|
||||||
|
(mod/decide-report (first mod-q-brigade) mod-q-brigade mod/default-rules)
|
||||||
|
:action)
|
||||||
|
"escalate")
|
||||||
|
|
||||||
|
;; ── three distinct reporters ──
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod-q-three
|
||||||
|
(list
|
||||||
|
(mod/mk-report "r1" "alice" "bob" "off-topic")
|
||||||
|
(mod/mk-report "r2" "carol" "bob" "off-topic")
|
||||||
|
(mod/mk-report "r3" "dave" "bob" "off-topic")))
|
||||||
|
|
||||||
|
(mod-q-test!
|
||||||
|
"three distinct reporters → hide"
|
||||||
|
(get
|
||||||
|
(mod/decide-quorum (first mod-q-three) mod-q-three mod-q-rules)
|
||||||
|
:action)
|
||||||
|
"hide")
|
||||||
|
(mod-q-test!
|
||||||
|
"quorum proof goal solved"
|
||||||
|
(get
|
||||||
|
(first
|
||||||
|
(get
|
||||||
|
(get
|
||||||
|
(mod/decide-quorum (first mod-q-three) mod-q-three mod-q-rules)
|
||||||
|
:proof)
|
||||||
|
:goals))
|
||||||
|
:solved)
|
||||||
|
true)
|
||||||
|
|
||||||
|
;; ── cond->goal compiles :reporters-at-least ──
|
||||||
|
|
||||||
|
(mod-q-test!
|
||||||
|
"cond->goal :reporters-at-least"
|
||||||
|
(mod/cond->goal (list :reporters-at-least 2) "Id")
|
||||||
|
"report(Id, _, Sr), setof(Br, report(_, Br, Sr), Bsr), length(Bsr, Nr), Nr >= 2")
|
||||||
|
|
||||||
|
(define mod-quorum-tests-run! (fn () {:failures mod-q-failures :total mod-q-count :passed mod-q-pass :failed mod-q-fail}))
|
||||||
120
lib/mod/tests/severity.sx
Normal file
120
lib/mod/tests/severity.sx
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
;; lib/mod/tests/severity.sx — Ext 6: strictest-wins decision strategy.
|
||||||
|
|
||||||
|
(define mod-sev-count 0)
|
||||||
|
(define mod-sev-pass 0)
|
||||||
|
(define mod-sev-fail 0)
|
||||||
|
(define mod-sev-failures (list))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod-sev-test!
|
||||||
|
(fn
|
||||||
|
(name got expected)
|
||||||
|
(begin
|
||||||
|
(set! mod-sev-count (+ mod-sev-count 1))
|
||||||
|
(if
|
||||||
|
(= got expected)
|
||||||
|
(set! mod-sev-pass (+ mod-sev-pass 1))
|
||||||
|
(begin
|
||||||
|
(set! mod-sev-fail (+ mod-sev-fail 1))
|
||||||
|
(append!
|
||||||
|
mod-sev-failures
|
||||||
|
(str name "\n expected: " expected "\n got: " got)))))))
|
||||||
|
|
||||||
|
;; ── severity ranking ──
|
||||||
|
|
||||||
|
(mod-sev-test! "ban most severe" (mod/action-severity "ban") 4)
|
||||||
|
(mod-sev-test!
|
||||||
|
"remove > hide"
|
||||||
|
(< (mod/action-severity "hide") (mod/action-severity "remove"))
|
||||||
|
true)
|
||||||
|
(mod-sev-test! "keep least severe" (mod/action-severity "keep") 0)
|
||||||
|
(mod-sev-test!
|
||||||
|
"escalate above keep"
|
||||||
|
(< (mod/action-severity "keep") (mod/action-severity "escalate"))
|
||||||
|
true)
|
||||||
|
|
||||||
|
;; ── strictest agrees with default-rules on simple cases ──
|
||||||
|
|
||||||
|
(define mod-sev-spam (mod/mk-report "r1" "a" "b" "this is spam"))
|
||||||
|
(mod-sev-test!
|
||||||
|
"strictest spam → hide"
|
||||||
|
(get
|
||||||
|
(mod/decide-strictest mod-sev-spam (list mod-sev-spam) mod/default-rules)
|
||||||
|
:action)
|
||||||
|
"hide")
|
||||||
|
(define mod-sev-clean (mod/mk-report "r2" "a" "b" "a fine post"))
|
||||||
|
(mod-sev-test!
|
||||||
|
"strictest clean → keep"
|
||||||
|
(get
|
||||||
|
(mod/decide-strictest
|
||||||
|
mod-sev-clean
|
||||||
|
(list mod-sev-clean)
|
||||||
|
mod/default-rules)
|
||||||
|
:action)
|
||||||
|
"keep")
|
||||||
|
(mod-sev-test!
|
||||||
|
"decision tagged strategy strictest"
|
||||||
|
(get
|
||||||
|
(mod/decide-strictest mod-sev-spam (list mod-sev-spam) mod/default-rules)
|
||||||
|
:strategy)
|
||||||
|
"strictest")
|
||||||
|
|
||||||
|
;; ── strictest diverges from first-match when order ≠ severity ──
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod-sev-rules
|
||||||
|
(list
|
||||||
|
(mod/mk-rule
|
||||||
|
"early-escalate"
|
||||||
|
:escalate (list (list :count-at-least 1)))
|
||||||
|
(mod/mk-rule "spam-remove" :remove (list (list :classification "spam")))
|
||||||
|
(mod/mk-rule "default-keep" :keep (list))))
|
||||||
|
|
||||||
|
(define mod-sev-r (mod/mk-report "r3" "a" "b" "this is spam"))
|
||||||
|
|
||||||
|
(mod-sev-test!
|
||||||
|
"first-match picks earliest rule (escalate)"
|
||||||
|
(get (mod/decide-report mod-sev-r (list mod-sev-r) mod-sev-rules) :action)
|
||||||
|
"escalate")
|
||||||
|
(mod-sev-test!
|
||||||
|
"strictest picks harshest action (remove)"
|
||||||
|
(get
|
||||||
|
(mod/decide-strictest mod-sev-r (list mod-sev-r) mod-sev-rules)
|
||||||
|
:action)
|
||||||
|
"remove")
|
||||||
|
(mod-sev-test!
|
||||||
|
"strictest names the harshest rule"
|
||||||
|
(get (mod/decide-strictest mod-sev-r (list mod-sev-r) mod-sev-rules) :rule)
|
||||||
|
"spam-remove")
|
||||||
|
(mod-sev-test!
|
||||||
|
"strictest carries proof goals"
|
||||||
|
(len
|
||||||
|
(get
|
||||||
|
(get
|
||||||
|
(mod/decide-strictest mod-sev-r (list mod-sev-r) mod-sev-rules)
|
||||||
|
:proof)
|
||||||
|
:goals))
|
||||||
|
1)
|
||||||
|
|
||||||
|
;; ── strictest among three matches (spam + repeated) ──
|
||||||
|
|
||||||
|
(define mod-sev-rep (mod/mk-report "r4" "a" "b" "buy now spam"))
|
||||||
|
(define mod-sev-reps (list mod-sev-rep mod-sev-rep mod-sev-rep))
|
||||||
|
(mod-sev-test!
|
||||||
|
"strictest among hide+escalate+keep → hide (default rules)"
|
||||||
|
(get
|
||||||
|
(mod/decide-strictest mod-sev-rep mod-sev-reps mod/default-rules)
|
||||||
|
:action)
|
||||||
|
"hide")
|
||||||
|
|
||||||
|
;; ── strictest-sol helper ──
|
||||||
|
|
||||||
|
(mod-sev-test!
|
||||||
|
"strictest-sol picks max severity"
|
||||||
|
(dict-get
|
||||||
|
(mod/strictest-sol (list {:Action "keep" :Rule "k"} {:Action "remove" :Rule "r"} {:Action "hide" :Rule "h"}))
|
||||||
|
"Action")
|
||||||
|
"remove")
|
||||||
|
(mod-sev-test! "strictest-sol nil for empty" (mod/strictest-sol (list)) nil)
|
||||||
|
|
||||||
|
(define mod-severity-tests-run! (fn () {:failures mod-sev-failures :total mod-sev-count :passed mod-sev-pass :failed mod-sev-fail}))
|
||||||
108
lib/mod/tests/sla.sx
Normal file
108
lib/mod/tests/sla.sx
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
;; lib/mod/tests/sla.sx — Ext 13: SLA sweep over pending lifecycle cases.
|
||||||
|
|
||||||
|
(define mod-sla-count 0)
|
||||||
|
(define mod-sla-pass 0)
|
||||||
|
(define mod-sla-fail 0)
|
||||||
|
(define mod-sla-failures (list))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod-sla-test!
|
||||||
|
(fn
|
||||||
|
(name got expected)
|
||||||
|
(begin
|
||||||
|
(set! mod-sla-count (+ mod-sla-count 1))
|
||||||
|
(if
|
||||||
|
(= got expected)
|
||||||
|
(set! mod-sla-pass (+ mod-sla-pass 1))
|
||||||
|
(begin
|
||||||
|
(set! mod-sla-fail (+ mod-sla-fail 1))
|
||||||
|
(append!
|
||||||
|
mod-sla-failures
|
||||||
|
(str name "\n expected: " expected "\n got: " got)))))))
|
||||||
|
|
||||||
|
;; ── pending-state? ──
|
||||||
|
|
||||||
|
(mod-sla-test! "open is pending" (mod/pending-state? "open") true)
|
||||||
|
(mod-sla-test! "triaged is pending" (mod/pending-state? "triaged") true)
|
||||||
|
(mod-sla-test! "appealed is pending" (mod/pending-state? "appealed") true)
|
||||||
|
(mod-sla-test! "decided is not pending" (mod/pending-state? "decided") false)
|
||||||
|
(mod-sla-test! "final is not pending" (mod/pending-state? "final") false)
|
||||||
|
|
||||||
|
;; build cases in known states
|
||||||
|
(define mod-sla-spam (mod/mk-report "r1" "u" "bob" "this is spam"))
|
||||||
|
(define mod-sla-spam-reports (list mod-sla-spam))
|
||||||
|
(define
|
||||||
|
mod-sla-triaged
|
||||||
|
(mod/case-triage
|
||||||
|
(mod/mk-case mod-sla-spam)
|
||||||
|
mod-sla-spam-reports
|
||||||
|
mod/default-rules))
|
||||||
|
(define mod-sla-decided (mod/case-resolve mod-sla-triaged))
|
||||||
|
(define mod-sla-open (mod/mk-case (mod/mk-report "r2" "u" "eve" "hello")))
|
||||||
|
|
||||||
|
;; ── overdue? ──
|
||||||
|
|
||||||
|
(define mod-sla-tc-old (mod/mk-timed-case mod-sla-triaged 0))
|
||||||
|
(define mod-sla-tc-fresh (mod/mk-timed-case mod-sla-triaged 90))
|
||||||
|
(define mod-sla-tc-done (mod/mk-timed-case mod-sla-decided 0))
|
||||||
|
|
||||||
|
(mod-sla-test!
|
||||||
|
"old triaged case is overdue"
|
||||||
|
(mod/overdue? mod-sla-tc-old 100 50)
|
||||||
|
true)
|
||||||
|
(mod-sla-test!
|
||||||
|
"fresh triaged case not overdue"
|
||||||
|
(mod/overdue? mod-sla-tc-fresh 100 50)
|
||||||
|
false)
|
||||||
|
(mod-sla-test!
|
||||||
|
"decided case never overdue"
|
||||||
|
(mod/overdue? mod-sla-tc-done 100 50)
|
||||||
|
false)
|
||||||
|
(mod-sla-test!
|
||||||
|
"age computes elapsed ticks"
|
||||||
|
(mod/age mod-sla-tc-old 100)
|
||||||
|
100)
|
||||||
|
(mod-sla-test!
|
||||||
|
"boundary: exactly at deadline not overdue"
|
||||||
|
(mod/overdue?
|
||||||
|
(mod/mk-timed-case mod-sla-triaged 50)
|
||||||
|
100
|
||||||
|
50)
|
||||||
|
false)
|
||||||
|
(mod-sla-test!
|
||||||
|
"boundary: one past deadline overdue"
|
||||||
|
(mod/overdue?
|
||||||
|
(mod/mk-timed-case mod-sla-triaged 49)
|
||||||
|
100
|
||||||
|
50)
|
||||||
|
true)
|
||||||
|
|
||||||
|
;; ── sweep over a mixed queue ──
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod-sla-queue
|
||||||
|
(list
|
||||||
|
(mod/mk-timed-case mod-sla-triaged 0)
|
||||||
|
(mod/mk-timed-case mod-sla-decided 0)
|
||||||
|
(mod/mk-timed-case mod-sla-open 90))) ;; r2, pending, age 10 → not
|
||||||
|
|
||||||
|
(mod-sla-test!
|
||||||
|
"sweep finds only the overdue pending case"
|
||||||
|
(mod/sla-sweep mod-sla-queue 100 50)
|
||||||
|
(list "r1"))
|
||||||
|
(mod-sla-test!
|
||||||
|
"overdue-count agrees"
|
||||||
|
(mod/overdue-count mod-sla-queue 100 50)
|
||||||
|
1)
|
||||||
|
|
||||||
|
;; tighten deadline so the young open case also breaches
|
||||||
|
(mod-sla-test!
|
||||||
|
"tighter deadline catches the open case too"
|
||||||
|
(mod/overdue-count mod-sla-queue 100 5)
|
||||||
|
2)
|
||||||
|
(mod-sla-test!
|
||||||
|
"empty queue → no breaches"
|
||||||
|
(mod/sla-sweep (list) 100 50)
|
||||||
|
(list))
|
||||||
|
|
||||||
|
(define mod-sla-tests-run! (fn () {:failures mod-sla-failures :total mod-sla-count :passed mod-sla-pass :failed mod-sla-fail}))
|
||||||
156
lib/mod/tests/temporal.sx
Normal file
156
lib/mod/tests/temporal.sx
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
;; lib/mod/tests/temporal.sx — Ext 12: burst detection over a time window.
|
||||||
|
|
||||||
|
(define mod-tm-count 0)
|
||||||
|
(define mod-tm-pass 0)
|
||||||
|
(define mod-tm-fail 0)
|
||||||
|
(define mod-tm-failures (list))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod-tm-test!
|
||||||
|
(fn
|
||||||
|
(name got expected)
|
||||||
|
(begin
|
||||||
|
(set! mod-tm-count (+ mod-tm-count 1))
|
||||||
|
(if
|
||||||
|
(= got expected)
|
||||||
|
(set! mod-tm-pass (+ mod-tm-pass 1))
|
||||||
|
(begin
|
||||||
|
(set! mod-tm-fail (+ mod-tm-fail 1))
|
||||||
|
(append!
|
||||||
|
mod-tm-failures
|
||||||
|
(str name "\n expected: " expected "\n got: " got)))))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod-tm-at
|
||||||
|
(fn (id about t) (mod/with-at (mod/mk-report id "u" about "off-topic") t)))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod-tm-rules
|
||||||
|
(list
|
||||||
|
(mod/mk-rule "burst-hide" :hide (list (list :burst-at-least 3)))
|
||||||
|
(mod/mk-rule "default-keep" :keep (list))))
|
||||||
|
|
||||||
|
;; ── window-count helper ──
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod-tm-burst
|
||||||
|
(list
|
||||||
|
(mod-tm-at "r1" "bob" 10)
|
||||||
|
(mod-tm-at "r2" "bob" 11)
|
||||||
|
(mod-tm-at "r3" "bob" 12)))
|
||||||
|
(define
|
||||||
|
mod-tm-slow
|
||||||
|
(list
|
||||||
|
(mod-tm-at "r1" "bob" 1)
|
||||||
|
(mod-tm-at "r2" "bob" 2)
|
||||||
|
(mod-tm-at "r3" "bob" 12)))
|
||||||
|
|
||||||
|
(mod-tm-test!
|
||||||
|
"window-count: all 3 within window"
|
||||||
|
(mod/window-count "bob" mod-tm-burst 12 5)
|
||||||
|
3)
|
||||||
|
(mod-tm-test!
|
||||||
|
"window-count: only 1 within window"
|
||||||
|
(mod/window-count "bob" mod-tm-slow 12 5)
|
||||||
|
1)
|
||||||
|
(mod-tm-test!
|
||||||
|
"window-count: subject filter"
|
||||||
|
(mod/window-count "eve" mod-tm-burst 12 5)
|
||||||
|
0)
|
||||||
|
|
||||||
|
;; ── burst fires; slow accumulation does not ──
|
||||||
|
|
||||||
|
(mod-tm-test!
|
||||||
|
"burst (3 in window) → hide"
|
||||||
|
(get
|
||||||
|
(mod/decide-temporal
|
||||||
|
(first mod-tm-burst)
|
||||||
|
mod-tm-burst
|
||||||
|
mod-tm-rules
|
||||||
|
12
|
||||||
|
5)
|
||||||
|
:action)
|
||||||
|
"hide")
|
||||||
|
(mod-tm-test!
|
||||||
|
"slow accumulation (1 in window) → keep"
|
||||||
|
(get
|
||||||
|
(mod/decide-temporal
|
||||||
|
(first mod-tm-slow)
|
||||||
|
mod-tm-slow
|
||||||
|
mod-tm-rules
|
||||||
|
12
|
||||||
|
5)
|
||||||
|
:action)
|
||||||
|
"keep")
|
||||||
|
|
||||||
|
;; ── contrast: the plain count rule fires on BOTH (3 total reports) ──
|
||||||
|
(mod-tm-test!
|
||||||
|
"count rule fires on slow case (distinct from burst)"
|
||||||
|
(get
|
||||||
|
(mod/decide-report (first mod-tm-slow) mod-tm-slow mod/default-rules)
|
||||||
|
:action)
|
||||||
|
"escalate")
|
||||||
|
|
||||||
|
;; ── decision shape ──
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod-tm-d
|
||||||
|
(mod/decide-temporal
|
||||||
|
(first mod-tm-burst)
|
||||||
|
mod-tm-burst
|
||||||
|
mod-tm-rules
|
||||||
|
12
|
||||||
|
5))
|
||||||
|
(mod-tm-test! "burst decision rule" (get mod-tm-d :rule) "burst-hide")
|
||||||
|
(mod-tm-test!
|
||||||
|
"burst decision tagged strategy"
|
||||||
|
(get mod-tm-d :strategy)
|
||||||
|
"temporal")
|
||||||
|
(mod-tm-test!
|
||||||
|
"burst recorded in proof"
|
||||||
|
(get (get mod-tm-d :proof) :burst)
|
||||||
|
3)
|
||||||
|
(mod-tm-test!
|
||||||
|
"burst proof goal solved"
|
||||||
|
(get (first (get (get mod-tm-d :proof) :goals)) :solved)
|
||||||
|
true)
|
||||||
|
|
||||||
|
;; ── window boundary is inclusive ──
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod-tm-edge
|
||||||
|
(list
|
||||||
|
(mod-tm-at "r1" "bob" 7)
|
||||||
|
(mod-tm-at "r2" "bob" 8)
|
||||||
|
(mod-tm-at "r3" "bob" 9)))
|
||||||
|
(mod-tm-test!
|
||||||
|
"window boundary inclusive (now-window = at)"
|
||||||
|
(mod/window-count "bob" mod-tm-edge 12 5)
|
||||||
|
3)
|
||||||
|
|
||||||
|
;; ── schema :at round-trips and survives evidence attach ──
|
||||||
|
|
||||||
|
(mod-tm-test!
|
||||||
|
"report-at reads timestamp"
|
||||||
|
(mod/report-at (mod-tm-at "r1" "bob" 42))
|
||||||
|
42)
|
||||||
|
(mod-tm-test!
|
||||||
|
"default report-at is 0"
|
||||||
|
(mod/report-at (mod/mk-report "r1" "a" "b" "x"))
|
||||||
|
0)
|
||||||
|
(mod-tm-test!
|
||||||
|
"attach-evidence preserves :at"
|
||||||
|
(mod/report-at
|
||||||
|
(mod/attach-evidence
|
||||||
|
(mod-tm-at "r1" "bob" 42)
|
||||||
|
(mod/mk-evidence "k" "v")))
|
||||||
|
42)
|
||||||
|
|
||||||
|
;; ── cond->goal :burst-at-least ──
|
||||||
|
|
||||||
|
(mod-tm-test!
|
||||||
|
"cond->goal :burst-at-least"
|
||||||
|
(mod/cond->goal (list :burst-at-least 3) "Id")
|
||||||
|
"report(Id, _, Sb), burst_count(Sb, Nb), Nb >= 3")
|
||||||
|
|
||||||
|
(define mod-temporal-tests-run! (fn () {:failures mod-tm-failures :total mod-tm-count :passed mod-tm-pass :failed mod-tm-fail}))
|
||||||
116
lib/mod/tests/trace.sx
Normal file
116
lib/mod/tests/trace.sx
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
;; lib/mod/tests/trace.sx — Ext 9: policy dry-run diagnostics.
|
||||||
|
|
||||||
|
(define mod-tr-count 0)
|
||||||
|
(define mod-tr-pass 0)
|
||||||
|
(define mod-tr-fail 0)
|
||||||
|
(define mod-tr-failures (list))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod-tr-test!
|
||||||
|
(fn
|
||||||
|
(name got expected)
|
||||||
|
(begin
|
||||||
|
(set! mod-tr-count (+ mod-tr-count 1))
|
||||||
|
(if
|
||||||
|
(= got expected)
|
||||||
|
(set! mod-tr-pass (+ mod-tr-pass 1))
|
||||||
|
(begin
|
||||||
|
(set! mod-tr-fail (+ mod-tr-fail 1))
|
||||||
|
(append!
|
||||||
|
mod-tr-failures
|
||||||
|
(str name "\n expected: " expected "\n got: " got)))))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod-tr-find
|
||||||
|
(fn
|
||||||
|
(trace nm)
|
||||||
|
(reduce (fn (acc t) (if (= (get t :rule) nm) t acc)) nil trace)))
|
||||||
|
|
||||||
|
;; ── trace a spam report against the default rules ──
|
||||||
|
|
||||||
|
(define mod-tr-spam (mod/mk-report "r1" "alice" "bob" "this is spam"))
|
||||||
|
(define
|
||||||
|
mod-tr-t
|
||||||
|
(mod/trace-rules mod-tr-spam (list mod-tr-spam) mod/default-rules))
|
||||||
|
|
||||||
|
(mod-tr-test! "trace covers every rule" (len mod-tr-t) 6)
|
||||||
|
(mod-tr-test!
|
||||||
|
"spam-hide fires"
|
||||||
|
(get (mod-tr-find mod-tr-t "spam-hide") :proved)
|
||||||
|
true)
|
||||||
|
(mod-tr-test!
|
||||||
|
"default-keep always fires"
|
||||||
|
(get (mod-tr-find mod-tr-t "default-keep") :proved)
|
||||||
|
true)
|
||||||
|
(mod-tr-test!
|
||||||
|
"reviewer-remove does not fire (no evidence)"
|
||||||
|
(get (mod-tr-find mod-tr-t "reviewer-remove") :proved)
|
||||||
|
false)
|
||||||
|
(mod-tr-test!
|
||||||
|
"exonerated-keep does not fire"
|
||||||
|
(get (mod-tr-find mod-tr-t "exonerated-keep") :proved)
|
||||||
|
false)
|
||||||
|
(mod-tr-test!
|
||||||
|
"abuse-remove does not fire"
|
||||||
|
(get (mod-tr-find mod-tr-t "abuse-remove") :proved)
|
||||||
|
false)
|
||||||
|
|
||||||
|
;; ── winner matches the engine ──
|
||||||
|
|
||||||
|
(mod-tr-test!
|
||||||
|
"first-proved is spam-hide"
|
||||||
|
(get (mod/first-proved mod-tr-t) :rule)
|
||||||
|
"spam-hide")
|
||||||
|
(mod-tr-test!
|
||||||
|
"winner action matches decide-report"
|
||||||
|
(get (mod/first-proved mod-tr-t) :action)
|
||||||
|
(get
|
||||||
|
(mod/decide-report mod-tr-spam (list mod-tr-spam) mod/default-rules)
|
||||||
|
:action))
|
||||||
|
|
||||||
|
;; ── an unproved rule shows which goal failed ──
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod-tr-rev-goals
|
||||||
|
(get (mod-tr-find mod-tr-t "reviewer-remove") :goals))
|
||||||
|
(mod-tr-test!
|
||||||
|
"reviewer-remove goal is unsolved"
|
||||||
|
(get (first mod-tr-rev-goals) :solved)
|
||||||
|
false)
|
||||||
|
(define mod-tr-spam-goals (get (mod-tr-find mod-tr-t "spam-hide") :goals))
|
||||||
|
(mod-tr-test!
|
||||||
|
"spam-hide goal is solved"
|
||||||
|
(get (first mod-tr-spam-goals) :solved)
|
||||||
|
true)
|
||||||
|
|
||||||
|
;; ── proved-rules list + rendering ──
|
||||||
|
|
||||||
|
(mod-tr-test!
|
||||||
|
"proved-rules lists fired rules in order"
|
||||||
|
(mod/proved-rules mod-tr-t)
|
||||||
|
(list "spam-hide" "default-keep"))
|
||||||
|
(mod-tr-test!
|
||||||
|
"trace-report marks a firing rule"
|
||||||
|
(mod/str-contains? (mod/trace-report mod-tr-t) "[fires] spam-hide")
|
||||||
|
true)
|
||||||
|
(mod-tr-test!
|
||||||
|
"trace-report marks a non-firing rule"
|
||||||
|
(mod/str-contains? (mod/trace-report mod-tr-t) "[ - ] reviewer-remove")
|
||||||
|
true)
|
||||||
|
|
||||||
|
;; ── clean report: only default-keep fires ──
|
||||||
|
|
||||||
|
(define mod-tr-clean (mod/mk-report "r2" "a" "b" "a fine post"))
|
||||||
|
(define
|
||||||
|
mod-tr-tc
|
||||||
|
(mod/trace-rules mod-tr-clean (list mod-tr-clean) mod/default-rules))
|
||||||
|
(mod-tr-test!
|
||||||
|
"clean report: only default-keep proves"
|
||||||
|
(mod/proved-rules mod-tr-tc)
|
||||||
|
(list "default-keep"))
|
||||||
|
(mod-tr-test!
|
||||||
|
"clean report winner is default-keep"
|
||||||
|
(get (mod/first-proved mod-tr-tc) :rule)
|
||||||
|
"default-keep")
|
||||||
|
|
||||||
|
(define mod-trace-tests-run! (fn () {:failures mod-tr-failures :total mod-tr-count :passed mod-tr-pass :failed mod-tr-fail}))
|
||||||
117
lib/mod/tests/whatif.sx
Normal file
117
lib/mod/tests/whatif.sx
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
;; lib/mod/tests/whatif.sx — Ext 10: policy what-if / impact analysis.
|
||||||
|
|
||||||
|
(define mod-wi-count 0)
|
||||||
|
(define mod-wi-pass 0)
|
||||||
|
(define mod-wi-fail 0)
|
||||||
|
(define mod-wi-failures (list))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod-wi-test!
|
||||||
|
(fn
|
||||||
|
(name got expected)
|
||||||
|
(begin
|
||||||
|
(set! mod-wi-count (+ mod-wi-count 1))
|
||||||
|
(if
|
||||||
|
(= got expected)
|
||||||
|
(set! mod-wi-pass (+ mod-wi-pass 1))
|
||||||
|
(begin
|
||||||
|
(set! mod-wi-fail (+ mod-wi-fail 1))
|
||||||
|
(append!
|
||||||
|
mod-wi-failures
|
||||||
|
(str name "\n expected: " expected "\n got: " got)))))))
|
||||||
|
|
||||||
|
;; rules-b is the default policy with spam-hide removed: spam now falls through
|
||||||
|
;; to default-keep. A spam report flips hide → keep; everything else is unchanged.
|
||||||
|
(define mod-wi-rules-a mod/default-rules)
|
||||||
|
(define
|
||||||
|
mod-wi-rules-b
|
||||||
|
(list
|
||||||
|
(mod/mk-rule
|
||||||
|
"reviewer-remove"
|
||||||
|
:remove (list (list :evidence "confirmed-abuse")))
|
||||||
|
(mod/mk-rule
|
||||||
|
"abuse-remove"
|
||||||
|
:remove (list (list :classification "abuse")))
|
||||||
|
(mod/mk-rule
|
||||||
|
"repeated-escalate"
|
||||||
|
:escalate (list (list :count-at-least 3)))
|
||||||
|
(mod/mk-rule "default-keep" :keep (list))))
|
||||||
|
|
||||||
|
(define mod-wi-spam (mod/mk-report "r1" "a" "bob" "this is spam"))
|
||||||
|
(define mod-wi-abuse (mod/mk-report "r2" "a" "carol" "harassment here"))
|
||||||
|
(define mod-wi-clean (mod/mk-report "r3" "a" "dave" "a fine post"))
|
||||||
|
|
||||||
|
;; ── single-report diff ──
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod-wi-d
|
||||||
|
(mod/decision-diff
|
||||||
|
mod-wi-spam
|
||||||
|
(list mod-wi-spam)
|
||||||
|
mod-wi-rules-a
|
||||||
|
mod-wi-rules-b))
|
||||||
|
(mod-wi-test! "spam before = hide" (get mod-wi-d :before) "hide")
|
||||||
|
(mod-wi-test! "spam after = keep" (get mod-wi-d :after) "keep")
|
||||||
|
(mod-wi-test! "spam decision flips" (get mod-wi-d :changed) true)
|
||||||
|
(mod-wi-test! "diff carries report id" (get mod-wi-d :report-id) "r1")
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod-wi-da
|
||||||
|
(mod/decision-diff
|
||||||
|
mod-wi-abuse
|
||||||
|
(list mod-wi-abuse)
|
||||||
|
mod-wi-rules-a
|
||||||
|
mod-wi-rules-b))
|
||||||
|
(mod-wi-test! "abuse unchanged (remove both)" (get mod-wi-da :changed) false)
|
||||||
|
(mod-wi-test! "abuse stays remove" (get mod-wi-da :after) "remove")
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod-wi-dc
|
||||||
|
(mod/decision-diff
|
||||||
|
mod-wi-clean
|
||||||
|
(list mod-wi-clean)
|
||||||
|
mod-wi-rules-a
|
||||||
|
mod-wi-rules-b))
|
||||||
|
(mod-wi-test! "clean unchanged (keep both)" (get mod-wi-dc :changed) false)
|
||||||
|
|
||||||
|
;; ── batch impact ──
|
||||||
|
|
||||||
|
(define mod-wi-batch (list mod-wi-spam mod-wi-abuse mod-wi-clean))
|
||||||
|
(define
|
||||||
|
mod-wi-impact
|
||||||
|
(mod/policy-impact mod-wi-batch mod-wi-rules-a mod-wi-rules-b))
|
||||||
|
|
||||||
|
(mod-wi-test!
|
||||||
|
"impact lists only changed reports"
|
||||||
|
(len mod-wi-impact)
|
||||||
|
1)
|
||||||
|
(mod-wi-test!
|
||||||
|
"impacted report is the spam one"
|
||||||
|
(get (first mod-wi-impact) :report-id)
|
||||||
|
"r1")
|
||||||
|
(mod-wi-test!
|
||||||
|
"impact-count agrees"
|
||||||
|
(mod/impact-count mod-wi-batch mod-wi-rules-a mod-wi-rules-b)
|
||||||
|
1)
|
||||||
|
|
||||||
|
;; ── identical rule sets → no impact ──
|
||||||
|
|
||||||
|
(mod-wi-test!
|
||||||
|
"same rules → zero impact"
|
||||||
|
(mod/impact-count mod-wi-batch mod-wi-rules-a mod-wi-rules-a)
|
||||||
|
0)
|
||||||
|
(mod-wi-test!
|
||||||
|
"same rules → empty report"
|
||||||
|
(mod/impact-report mod-wi-batch mod-wi-rules-a mod-wi-rules-a)
|
||||||
|
"No decisions change.")
|
||||||
|
|
||||||
|
;; ── rendering ──
|
||||||
|
|
||||||
|
(mod-wi-test!
|
||||||
|
"impact-report renders the flip"
|
||||||
|
(mod/str-contains?
|
||||||
|
(mod/impact-report mod-wi-batch mod-wi-rules-a mod-wi-rules-b)
|
||||||
|
"r1: hide → keep")
|
||||||
|
true)
|
||||||
|
|
||||||
|
(define mod-whatif-tests-run! (fn () {:failures mod-wi-failures :total mod-wi-count :passed mod-wi-pass :failed mod-wi-fail}))
|
||||||
96
lib/mod/tests/wire.sx
Normal file
96
lib/mod/tests/wire.sx
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
;; lib/mod/tests/wire.sx — Ext 14: decision wire format + federated transport.
|
||||||
|
|
||||||
|
(define mod-w-count 0)
|
||||||
|
(define mod-w-pass 0)
|
||||||
|
(define mod-w-fail 0)
|
||||||
|
(define mod-w-failures (list))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod-w-test!
|
||||||
|
(fn
|
||||||
|
(name got expected)
|
||||||
|
(begin
|
||||||
|
(set! mod-w-count (+ mod-w-count 1))
|
||||||
|
(if
|
||||||
|
(= got expected)
|
||||||
|
(set! mod-w-pass (+ mod-w-pass 1))
|
||||||
|
(begin
|
||||||
|
(set! mod-w-fail (+ mod-w-fail 1))
|
||||||
|
(append!
|
||||||
|
mod-w-failures
|
||||||
|
(str name "\n expected: " expected "\n got: " got)))))))
|
||||||
|
|
||||||
|
;; ── split-char ──
|
||||||
|
|
||||||
|
(mod-w-test! "split on pipe" (mod/split-char "a|b|c" "|") (list "a" "b" "c"))
|
||||||
|
(mod-w-test! "split single field" (mod/split-char "abc" "|") (list "abc"))
|
||||||
|
(mod-w-test!
|
||||||
|
"split four fields"
|
||||||
|
(len (mod/split-char "MOD1|r1|hide|spam-hide" "|"))
|
||||||
|
4)
|
||||||
|
|
||||||
|
;; ── serialize ──
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod-w-dec
|
||||||
|
(mod/decide-report
|
||||||
|
(mod/mk-report "r1" "a" "bob" "this is spam")
|
||||||
|
(list (mod/mk-report "r1" "a" "bob" "this is spam"))
|
||||||
|
mod/default-rules))
|
||||||
|
(define mod-w-line (mod/decision->wire mod-w-dec))
|
||||||
|
|
||||||
|
(mod-w-test!
|
||||||
|
"wire is versioned + delimited"
|
||||||
|
mod-w-line
|
||||||
|
"MOD1|r1|hide|spam-hide")
|
||||||
|
(mod-w-test!
|
||||||
|
"wire-valid? accepts well-formed"
|
||||||
|
(mod/wire-valid? mod-w-line)
|
||||||
|
true)
|
||||||
|
(mod-w-test!
|
||||||
|
"wire-valid? rejects junk"
|
||||||
|
(mod/wire-valid? "not a wire line")
|
||||||
|
false)
|
||||||
|
(mod-w-test!
|
||||||
|
"wire-valid? rejects wrong version"
|
||||||
|
(mod/wire-valid? "MOD9|r1|hide|x")
|
||||||
|
false)
|
||||||
|
|
||||||
|
;; ── round-trip ──
|
||||||
|
|
||||||
|
(define mod-w-back (mod/wire->decision mod-w-line))
|
||||||
|
(mod-w-test! "round-trip report-id" (get mod-w-back :report-id) "r1")
|
||||||
|
(mod-w-test! "round-trip action" (get mod-w-back :action) "hide")
|
||||||
|
(mod-w-test! "round-trip rule" (get mod-w-back :rule) "spam-hide")
|
||||||
|
(mod-w-test! "round-trip tags :wire" (get mod-w-back :wire) true)
|
||||||
|
(mod-w-test! "malformed → nil" (mod/wire->decision "garbage") nil)
|
||||||
|
|
||||||
|
;; ── full federated transport: serialize → wire → deserialize → trust-gate ──
|
||||||
|
|
||||||
|
(mod/fed-reset!)
|
||||||
|
(define mod-w-peer-dec (mod/wire->decision mod-w-line))
|
||||||
|
|
||||||
|
;; untrusted peer: decision is advisory, not applied
|
||||||
|
(define mod-w-recv1 (mod/fed-receive-decision "peerX" mod-w-peer-dec))
|
||||||
|
(mod-w-test!
|
||||||
|
"wired decision from untrusted peer → advisory"
|
||||||
|
(get mod-w-recv1 :applied)
|
||||||
|
false)
|
||||||
|
(mod-w-test!
|
||||||
|
"untrusted wired decision not applied locally"
|
||||||
|
(mod/fed-applied-action "r1")
|
||||||
|
nil)
|
||||||
|
|
||||||
|
;; trusted peer: decision binds locally
|
||||||
|
(mod/grant-trust "peerY" :mod)
|
||||||
|
(define mod-w-recv2 (mod/fed-receive-decision "peerY" mod-w-peer-dec))
|
||||||
|
(mod-w-test!
|
||||||
|
"wired decision from trusted peer → applied"
|
||||||
|
(get mod-w-recv2 :applied)
|
||||||
|
true)
|
||||||
|
(mod-w-test!
|
||||||
|
"trusted wired decision binds locally"
|
||||||
|
(get (mod/fed-applied-action "r1") :action)
|
||||||
|
"hide")
|
||||||
|
|
||||||
|
(define mod-wire-tests-run! (fn () {:failures mod-w-failures :total mod-w-count :passed mod-w-pass :failed mod-w-fail}))
|
||||||
56
lib/mod/trace.sx
Normal file
56
lib/mod/trace.sx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
;; lib/mod/trace.sx — policy dry-run diagnostics.
|
||||||
|
;;
|
||||||
|
;; decide-report returns the winning rule; a policy author debugging "why didn't
|
||||||
|
;; my rule fire?" needs the whole picture. mod/trace-rules evaluates a report
|
||||||
|
;; against every rule and reports each rule's proved/unproved status plus its
|
||||||
|
;; goal-by-goal derivation — so an unproved rule shows exactly which goal failed.
|
||||||
|
;; The winner is the first proved rule (same precedence as the engine).
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/trace-rules
|
||||||
|
(fn
|
||||||
|
(r reports rules)
|
||||||
|
(let
|
||||||
|
((count (mod/report-count (mod/report-about r) reports))
|
||||||
|
(id (mod/report-id r)))
|
||||||
|
(let
|
||||||
|
((db (pl-load (mod/build-program r count rules))))
|
||||||
|
(let
|
||||||
|
((proved-names (map (fn (s) (dict-get s "Rule")) (pl-query-all db (str "policy_action(" id ", _, Rule)")))))
|
||||||
|
(map
|
||||||
|
(fn (rule) (let ((nm (mod/rule-name rule))) {:proved (mod/member? nm proved-names) :goals (mod/proof-goals db id (mod/rule-when rule)) :action (mod/rule-action rule) :rule nm}))
|
||||||
|
rules))))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/first-proved
|
||||||
|
(fn
|
||||||
|
(trace)
|
||||||
|
(reduce
|
||||||
|
(fn (acc t) (if (nil? acc) (if (get t :proved) t acc) acc))
|
||||||
|
nil
|
||||||
|
trace)))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/proved-rules
|
||||||
|
(fn
|
||||||
|
(trace)
|
||||||
|
(reduce
|
||||||
|
(fn
|
||||||
|
(acc t)
|
||||||
|
(if (get t :proved) (append acc (list (get t :rule))) acc))
|
||||||
|
(list)
|
||||||
|
trace)))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/trace-row
|
||||||
|
(fn
|
||||||
|
(t)
|
||||||
|
(str
|
||||||
|
(if (get t :proved) "[fires] " "[ - ] ")
|
||||||
|
(get t :rule)
|
||||||
|
" → "
|
||||||
|
(get t :action))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/trace-report
|
||||||
|
(fn (trace) (mod/join-with "\n" (map mod/trace-row trace))))
|
||||||
56
lib/mod/whatif.sx
Normal file
56
lib/mod/whatif.sx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
;; lib/mod/whatif.sx — policy what-if / impact analysis.
|
||||||
|
;;
|
||||||
|
;; Before shipping a policy change, a moderation team needs to know which past or
|
||||||
|
;; pending reports would decide differently. mod/decision-diff compares one
|
||||||
|
;; report's action under two rule sets; mod/policy-impact runs a whole batch and
|
||||||
|
;; returns only the reports whose decision flips. Pure SX over decide-report.
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/decision-diff
|
||||||
|
(fn
|
||||||
|
(r reports rules-a rules-b)
|
||||||
|
(let
|
||||||
|
((a (get (mod/decide-report r reports rules-a) :action))
|
||||||
|
(b (get (mod/decide-report r reports rules-b) :action)))
|
||||||
|
{:after b :changed (if (= a b) false true) :report-id (mod/report-id r) :before a})))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/policy-impact
|
||||||
|
(fn
|
||||||
|
(reports rules-a rules-b)
|
||||||
|
(reduce
|
||||||
|
(fn
|
||||||
|
(acc r)
|
||||||
|
(let
|
||||||
|
((d (mod/decision-diff r reports rules-a rules-b)))
|
||||||
|
(if (get d :changed) (append acc (list d)) acc)))
|
||||||
|
(list)
|
||||||
|
reports)))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/impact-count
|
||||||
|
(fn
|
||||||
|
(reports rules-a rules-b)
|
||||||
|
(len (mod/policy-impact reports rules-a rules-b))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/impact-report
|
||||||
|
(fn
|
||||||
|
(reports rules-a rules-b)
|
||||||
|
(let
|
||||||
|
((changed (mod/policy-impact reports rules-a rules-b)))
|
||||||
|
(if
|
||||||
|
(empty? changed)
|
||||||
|
"No decisions change."
|
||||||
|
(mod/join-with
|
||||||
|
"\n"
|
||||||
|
(map
|
||||||
|
(fn
|
||||||
|
(d)
|
||||||
|
(str
|
||||||
|
(get d :report-id)
|
||||||
|
": "
|
||||||
|
(get d :before)
|
||||||
|
" → "
|
||||||
|
(get d :after)))
|
||||||
|
changed))))))
|
||||||
55
lib/mod/wire.sx
Normal file
55
lib/mod/wire.sx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
;; lib/mod/wire.sx — portable decision wire format for federation transport.
|
||||||
|
;;
|
||||||
|
;; fed.sx shares decisions as in-memory dicts and leaves mod/fed-send! as the
|
||||||
|
;; transport seam. This is the bytes that cross it: a versioned, pipe-delimited
|
||||||
|
;; line encoding the verdict a peer needs (report id, action, rule) — enough to
|
||||||
|
;; trust-gate and apply/advise, without shipping the whole proof tree. The
|
||||||
|
;; loaded env has no string split, so split is built over slice/len.
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/split-loop
|
||||||
|
(fn
|
||||||
|
(s ch n start pos acc)
|
||||||
|
(if
|
||||||
|
(= pos n)
|
||||||
|
(append acc (list (slice s start n)))
|
||||||
|
(if
|
||||||
|
(= (slice s pos (+ pos 1)) ch)
|
||||||
|
(mod/split-loop
|
||||||
|
s
|
||||||
|
ch
|
||||||
|
n
|
||||||
|
(+ pos 1)
|
||||||
|
(+ pos 1)
|
||||||
|
(append acc (list (slice s start pos))))
|
||||||
|
(mod/split-loop s ch n start (+ pos 1) acc)))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/split-char
|
||||||
|
(fn (s ch) (mod/split-loop s ch (len s) 0 0 (list))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/decision->wire
|
||||||
|
(fn
|
||||||
|
(d)
|
||||||
|
(str "MOD1|" (get d :report-id) "|" (get d :action) "|" (get d :rule))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/wire-valid?
|
||||||
|
(fn
|
||||||
|
(w)
|
||||||
|
(let
|
||||||
|
((parts (mod/split-char w "|")))
|
||||||
|
(if
|
||||||
|
(= (len parts) 4)
|
||||||
|
(= (nth parts 0) "MOD1")
|
||||||
|
false))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
mod/wire->decision
|
||||||
|
(fn
|
||||||
|
(w)
|
||||||
|
(if
|
||||||
|
(mod/wire-valid? w)
|
||||||
|
(let ((parts (mod/split-char w "|"))) {:action (nth parts 2) :wire true :rule (nth parts 3) :report-id (nth parts 1)})
|
||||||
|
nil)))
|
||||||
@@ -1,639 +0,0 @@
|
|||||||
# Abstraction Radar — backlog
|
|
||||||
|
|
||||||
Maintained by the read-only `radar` loop (see `plans/agent-briefings/radar-loop.md`).
|
|
||||||
Detection only — implementation is a separate, coordinated step owned by the
|
|
||||||
relevant subsystem loop, never by radar.
|
|
||||||
|
|
||||||
**AHA gate to reach _Proposed_:** ≥3 real consumers · all past Phase 2 & API-stable ·
|
|
||||||
structurally identical (file:line evidence) · a natural home (usually NOT lib/guest).
|
|
||||||
Anything short → _Watching_ (what's missing) or _Rejected_ (why).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Last scan
|
|
||||||
|
|
||||||
- **Date:** 2026-06-07 (radar loop, pass 38)
|
|
||||||
- **Pass 38 — migration plan DRAFTED (planning loop worklist complete).** All 5 specs
|
|
||||||
written under `loops/migration:plans/migration/` (host-readiness, strangler-shadow-
|
|
||||||
harness, slice-01-blog, data-migration, slice-sequencing); loop added a 6th revealed
|
|
||||||
thread `open-questions.md` (digest for humans) then is end-of-worklist. **Decision point
|
|
||||||
for the operator: review the plan + decide whether to start an IMPLEMENTATION loop**
|
|
||||||
(first target per the plan: `lib/host` Phase 1 + multi-`Set-Cookie` fix → slice-01-blog
|
|
||||||
1a). Branch `loops/migration` is local/un-pushed (per operator's no-push preference).
|
|
||||||
No new radar candidate; A1 at 13; fed-sx still on deadlock.
|
|
||||||
- **Date:** 2026-06-07 (radar loop, pass 37)
|
|
||||||
- **Pass 37 — migration plan 4/5 specs done.** Long-pole shipped: `data-migration.md`
|
|
||||||
(Postgres → persist via **genesis-import** — seed each stream with current DB state as
|
|
||||||
initial events). Only `slice-sequencing.md` left; loop self-pacing fine. No new radar
|
|
||||||
candidate; events (iCal import) + content (sanitize, 799/799) incremental; A1 at 13.
|
|
||||||
- **Date:** 2026-06-07 (radar loop, pass 36)
|
|
||||||
- **Pass 36 — migration planning loop healthy + productive.** Self-pacing restored (now
|
|
||||||
schedules its own ~20min wake-ups). Shipped 2 more specs (3/5 threads): strangler-shadow-
|
|
||||||
harness (Caddy handle-per-route + offline-replay shadow-diff at the `content/blocks`
|
|
||||||
facade) and slice-01-blog (GET /<slug>/; **found blog already has `Post.sx_content` +
|
|
||||||
lexical→SX pipeline** — a real head-start). data-migration + slice-sequencing pending.
|
|
||||||
No new radar candidate; A1 steady at 13; fed-sx still on deadlock.
|
|
||||||
- **Date:** 2026-06-07 (radar loop, pass 35)
|
|
||||||
- **Pass 35 — quiet for findings; ops note.** The migration PLANNING loop had completed
|
|
||||||
host-readiness and **stalled idle ~1hr** (self-paced `/loop` didn't re-fire after one
|
|
||||||
iteration). Nudged it to continue its worklist (now on strangler-shadow-harness) +
|
|
||||||
schedule its own next wake-up. No new radar candidate; events/content incremental;
|
|
||||||
A1 steady at 13; fed-sx still on the deadlock reproducer.
|
|
||||||
- **Date:** 2026-06-07 (radar loop, pass 34)
|
|
||||||
- **Pass 34 — quiet, no new finding.** Minimal churn: migration planning loop still on
|
|
||||||
host-readiness (next thread pending, self-paced); maude scoreboard refresh; fed-sx
|
|
||||||
grinding the fed-prims deadlock; A1 adopters steady at 13. Nothing new to discover.
|
|
||||||
- **Date:** 2026-06-07 (radar loop, pass 33)
|
|
||||||
- **Pass 33 — host-layer story clarified (refines the migration strategy).** `dream` =
|
|
||||||
**Dream-on-SX**: OCaml's Dream web framework on the SX CEK, and the project owner's
|
|
||||||
**confirmed decision to move rose-ash OFF Quart onto Dream** as the ergonomic HTTP front
|
|
||||||
door over the native SX server (router/session/middleware/cors/csrf/auth/ws/html/json —
|
|
||||||
16 modules). So the host layer is: **host-on-sx native server (Phases 1-3, carries it
|
|
||||||
now) → Dream-on-SX framework front door (gated on ocaml-on-sx Phases 1-5) + host-persist
|
|
||||||
(done) + fed-sx (AP transport).** The migration PLANNING loop (new, tmux `migration`,
|
|
||||||
commit-only) is now the owner of refining this — it already shipped `host-readiness.md`
|
|
||||||
pinning the near-term gate to **`lib/host` (unbuilt) + a multi-`Set-Cookie` primitive
|
|
||||||
fix** (`sx_server.ml:735`). NOTE: `plans/rose-ash-on-sx-migration.md` under-specified the
|
|
||||||
framework layer (said "host-on-sx HTTP host"); the Dream-over-Quart decision + the
|
|
||||||
native→Dream sequence is the correction — the planning loop will fold it into its specs.
|
|
||||||
`maude` at Phase 5 (rewriting-logic substrate). Radar tracks; planning loop details.
|
|
||||||
- **Date:** 2026-06-07 (radar loop, pass 32)
|
|
||||||
- **Pass 32 — A1 DONE.** `loops/conformance` merged to architecture (`db76cc8c`); 13 adopters
|
|
||||||
now on the shared driver; radar spot-checked common-lisp = 487/487 green post-merge →
|
|
||||||
coordination flag CLEARED. A1 moved to a new **Done** section. New nascent subsystems
|
|
||||||
`dream` + `maude` (0 files), `fed-prims` resumed (mutex-deadlock fix). The idle
|
|
||||||
`a1-conformance` loop can be retired (worklist complete).
|
|
||||||
- **Date:** 2026-06-07 (radar loop, pass 31)
|
|
||||||
- **Pass 31 — A1 conformance loop WORKLIST COMPLETE.** tcl excluded (foreign `*.tcl`); final:
|
|
||||||
4 migrated (common-lisp/erlang/feed/go) + 5 excluded (forth/js/ocaml/smalltalk/tcl). A1 =
|
|
||||||
**12 on shared driver + 6 excluded**; only the parity-gated merge to architecture remains.
|
|
||||||
commerce shipped a refund saga on flow (2nd flow use) + finished Phase 5 → going quiescent.
|
|
||||||
relations building graph algos (all-paths) — still unconsumed (W9 unchanged).
|
|
||||||
- **Date:** 2026-06-07 (radar loop, pass 30)
|
|
||||||
- **Pass 30:** conformance loop near done — `ocaml` + `smalltalk` excluded (both foreign
|
|
||||||
`test.sh`/corpus runners, as predicted). Tally: 4 migrated, 4 excluded, **tcl only** left.
|
|
||||||
Next A1 milestone = the `loops/conformance`→architecture merge under adopter-parity. No
|
|
||||||
new candidate; relations/artdag steady (no new W9 delegation).
|
|
||||||
- **Date:** 2026-06-07 (radar loop, pass 29)
|
|
||||||
- **Pass 29:** conformance loop excluded `js` (test262 fixtures) → 4 migrated + 2 excluded,
|
|
||||||
3 remain (ocaml/smalltalk/tcl). New subsystems advancing fast: `relations` → Phase 4
|
|
||||||
federation, `artdag` → Phase 6 federation → both fold into W1 (now 7 federation modules,
|
|
||||||
theme-not-shape holds) and W9 (relations past Phase 2 but not yet consumed by anyone).
|
|
||||||
- **Date:** 2026-06-07 (radar loop, pass 28)
|
|
||||||
- **Pass 28 — fleet expanding again.** Conformance loop: `go` migrated 609/609; **`forth`
|
|
||||||
excluded** (foreign Forth corpus — classify-then-exclude working). 4 migrated +1 excluded
|
|
||||||
on the branch; js/ocaml/smalltalk/tcl remain. **2 new subsystems:** `relations` (Phase 1,
|
|
||||||
parent/child rel facts → new W9 nascent watch) and `artdag` (nascent, 0 files). `events`
|
|
||||||
MERGED to architecture (its persist+flow adoption now integrated — W4/W8 landed). Briefing
|
|
||||||
commit hints more incoming: `dream`, `host`, +5 language chisels.
|
|
||||||
- **Date:** 2026-06-07 (radar loop, passes 26–27)
|
|
||||||
- **Passes 26–27 (routine tracking):** conformance loop steady at ~1 migration/iteration —
|
|
||||||
erlang 761/761, then feed 189/189. A1 = 8 on architecture + 3 on the branch; 6 remain.
|
|
||||||
W4 still gated (host-persist adapter not landed); no new subsystem; app loops on
|
|
||||||
incremental domain work (commerce Phase 5 payment envelope, content/events/identity/fed-sx).
|
|
||||||
Nothing new to discover; merge-time adopter-parity flag still open.
|
|
||||||
- **Date:** 2026-06-07 (radar loop, pass 25)
|
|
||||||
- **Pass 25:** A1 → **8 adopters** (events via its own loop) + common-lisp 487/487 on the
|
|
||||||
conformance branch. The conformance loop **extended the shared `lib/guest` driver**
|
|
||||||
(per-suite counters/preloads) to do it → raised a **coordination flag in A1**: verify the
|
|
||||||
branch is non-regressive against all 8 adopters before merging to architecture. commerce
|
|
||||||
drafting Phase 5 provider-neutral payment envelope. No new candidate; A1 advancing fast.
|
|
||||||
- **Date:** 2026-06-07 (radar loop, pass 24)
|
|
||||||
- **Pass 24 — three real updates.** (1) **A1 → 7 adopters** (search migrated, counters mode
|
|
||||||
— corrects the earlier exclusion). (2) The dedicated `conformance` loop ran its 1st
|
|
||||||
iteration: refused to force-migrate common-lisp (parity gate worked) and surfaced a
|
|
||||||
**driver feature-gap** (per-suite counters + preloads) gating the complex multi-suite
|
|
||||||
candidates → A1 now splits simple-now vs gated-on-driver-enhancement. (3) **W8 commerce
|
|
||||||
is LIVE** ("order lifecycle as a durable flow-on-sx flow, Phase 3 done") → 2 live flow
|
|
||||||
consumers. events shipped TZ/DST; mod reverted its extraction note (declined on re-read).
|
|
||||||
- **Date:** 2026-06-07 (radar loop, pass 23)
|
|
||||||
- **Pass 23 — trigger fired (empty streak ends at 19–22).** commerce recorded a Phase 3
|
|
||||||
**flow-integration design** (order saga as a flow-on-sx flow, payment suspended until
|
|
||||||
webhook resume) → 2nd durable-flow consumer; **W8 broadened** from "delivery" to
|
|
||||||
"externally-resumed orchestration on lib/flow." events made its federation transport
|
|
||||||
**fed-sx-ready** (injected) → reinforces W1's 5/5 inject-fed-sx seam. acl left tmux
|
|
||||||
(now fully quiescent). host-persist adapter still not landed (W4 migration still gated).
|
|
||||||
- **Empty-discovery streak: passes 19–22** (last verified pass 22). Fleet at steady state —
|
|
||||||
active loops (content CvRDT, events recurrence/reschedule, identity grant-mgmt, fed-sx
|
|
||||||
outbox internals) are building *inside* their domains, not cross-cutting infra. Census
|
|
||||||
exhausted (p17); all gates re-tested (W1 p18, W2 p19). No new candidate clears any gate.
|
|
||||||
- **Radar is now trigger-driven.** The next substantive pass needs one of: **(a)** a new
|
|
||||||
subsystem worktree spawning (auto-joins scan), or **(b)** host-persist's durable adapter
|
|
||||||
landing → unblocks the W4 acl/mod→persist/log migration, or **(c)** a quiescent
|
|
||||||
subsystem (acl/mod/search/commerce, static ~9–16 passes) resuming. Polling ~hourly until
|
|
||||||
one fires; will tighten cadence then.
|
|
||||||
- **Date:** 2026-06-07 (radar loop, pass 20)
|
|
||||||
- **Pass 20 — honest empty pass.** 3 new census recurrences since p17 (normalize/index ×2,
|
|
||||||
query ×3) — all **name collisions** (same noun, domain-specific op), added to the table.
|
|
||||||
Recorded the meta-pattern: the fleet shares vocabulary, not structure. Most subsystems
|
|
||||||
quiescent (acl/mod/search/commerce static ~9-15 passes = API-stable); only events/
|
|
||||||
identity/content/fed-sx still committing domain features. No new gate-clearer.
|
|
||||||
- **Date:** 2026-06-07 (radar loop, pass 19)
|
|
||||||
- **Pass 19 — honest empty pass.** Scanned 10 active subsystems. content/index.sx is a
|
|
||||||
blog index/tag-cloud listing (presentation, not full-text search — no search reinvention)
|
|
||||||
and content/multi-doc indexing adds no per-viewer filter. **W2 re-tested: still 2**
|
|
||||||
(feed, search) — acl's `permit?`-like matches are its own authZ *engine* (the home),
|
|
||||||
not a downstream read filter. No new candidate cleared any gate.
|
|
||||||
- **Date:** 2026-06-07 (radar loop, pass 18)
|
|
||||||
- **Pass 18 — W1 gate re-test.** events shipped Phase 4 federation (5th consumer): a 5th
|
|
||||||
divergent merge (sorted agenda + `:origin` provenance), trust-gate = runtime list
|
|
||||||
membership (shares mod's mechanism, not acl's). Reinforces W1's "theme not shape" — but
|
|
||||||
the **inject-fed-sx-transport seam is now 5/5**, strengthening "all are fed-sx
|
|
||||||
consumers-in-waiting." Trust sub-pattern refined: mod+events (runtime set) vs acl (rule).
|
|
||||||
- **Date:** 2026-06-07 (radar loop, pass 17)
|
|
||||||
- **Pass 17 — filename census declared EXHAUSTED** (see the Census-status table above).
|
|
||||||
Examined the last unswept ≥2 recurrences (schema/engine = acl⇄mod substrate twins;
|
|
||||||
catalog/batch = name collisions; store = divergent). No new candidate. Incremental churn
|
|
||||||
elsewhere (content 621/621, identity PAR, events reminders). Future passes pivot from
|
|
||||||
censusing to re-testing gates as consumers mature.
|
|
||||||
- **Date:** 2026-06-07 (radar loop, pass 16)
|
|
||||||
- **Pass 16:** events started Phase 3 — **durable notification delivery on `lib/flow`**
|
|
||||||
(new W8: at-least-once + idempotency exemplar; fed-sx/mod roll their own outbox). The two
|
|
||||||
`notify.sx` (feed vs events) are a name collision (read-side digest vs delivery), noted
|
|
||||||
in W8. Substrate-adoption story deepening: app domains now consume persist (content/
|
|
||||||
commerce/events), flow (events), commerce (events), acl-authZ (identity).
|
|
||||||
- **Date:** 2026-06-07 (radar loop, pass 15)
|
|
||||||
- **Pass 15:** added the **scanning-method note** above after `query.sx` again proved to
|
|
||||||
be merged-lib copies (lib/prolog + lib/persist in every worktree). Corrected census
|
|
||||||
surfaced `wire`×2 (content+mod) → Rejected (shared role, divergent structure: generic SX
|
|
||||||
serializer vs bespoke pipe-format under a Prolog-env string-prim constraint). events↔
|
|
||||||
commerce integration appeared (paid tickets); acl/mod/search quiescent ~7 passes (now
|
|
||||||
API-stable). No new gate-clearer.
|
|
||||||
- **Date:** 2026-06-07 (radar loop, pass 14)
|
|
||||||
- **Pass 14:** filename census flagged `snapshot`×?? — but the `*/lib/persist/snapshot.sx`
|
|
||||||
copies are just the merged `lib/persist` in each worktree, NOT consumers (same artifact
|
|
||||||
as `lib/feed/rank.sx` everywhere). The one distinct file, `content/snapshot.sx`,
|
|
||||||
reimplements persist's projection-checkpoint on raw KV instead of using `persist/snapshot`
|
|
||||||
→ new W7 (persist-adoption nudge). `audit`×3 = the W4 fakes (acl/mod/identity), known.
|
|
||||||
- **Date:** 2026-06-07 (radar loop, pass 13)
|
|
||||||
- **Pass 13 — honest re-test, no gate-clearer.** Re-tested the two longest-waiting gates
|
|
||||||
against the maturing app-domain loops: **W2** (per-viewer visibility) still 2 consumers
|
|
||||||
(feed, search) — commerce/content/events/identity add no per-viewer read filter; **W3**
|
|
||||||
(pagination) still 2 (feed, search) — `content/page.sx` is an HTML wrapper, not
|
|
||||||
pagination (filename collision, noted in W3). Incremental churn only elsewhere.
|
|
||||||
- **Date:** 2026-06-07 (radar loop, pass 12)
|
|
||||||
- **Pass 12:** `events` shipped **transactional booking on persist** (3rd live persist
|
|
||||||
consumer) using `persist/append-expect` (optimistic-concurrency CAS, lock-free capacity
|
|
||||||
safety). W4 ledger now shows a persist feature-ladder append → append-once → append-expect
|
|
||||||
that the hand-rolled fakes can't match. No new candidate; W4 reinforced.
|
|
||||||
- **Date:** 2026-06-07 (radar loop, pass 11)
|
|
||||||
- **Pass 11 — W4 sharpened with a consumer ledger.** commerce built an **order ledger on
|
|
||||||
persist** (2nd live exemplar; uses `persist/append-once` for webhook idempotency) and
|
|
||||||
identity a **grant audit ledger** (in-memory Erlang fake, gated on an Erlang↔persist
|
|
||||||
bridge). The append-only monotonic-seq event-log pattern is now validated across 4
|
|
||||||
domains, 2 live on persist + 3 fakes flagged for adoption. See W4 table.
|
|
||||||
- **Date:** 2026-06-07 (radar loop, pass 10)
|
|
||||||
- **Pass 10:** commerce/content/events/identity advancing (content 238/238). Probed a
|
|
||||||
shape outside the routing table — **guarded lifecycle state machines** (mod/lifecycle +
|
|
||||||
identity/membership) → new W6: shared *design principle*, divergent *structure*
|
|
||||||
(SX transition-table vs Erlang gen_server), NOT an extraction target. No gate-clearer.
|
|
||||||
- **Date:** 2026-06-07 (radar loop, pass 9)
|
|
||||||
- **Pass 9:** `commerce` + `content` reached Phase 2 (`content` 162/162). **Key find:
|
|
||||||
`content` built its op log directly on `persist/log`** (backend-injected, append+replay-
|
|
||||||
to-seq) — the live reference exemplar for W4 (see W4). `events` MONTHLY RRULE,
|
|
||||||
`identity` OAuth2 auth-code + PKCE, search boolean-filtered ranked. A1 still 6 adopters.
|
|
||||||
- **Date:** 2026-06-06 (radar loop, pass 8)
|
|
||||||
- **Pass 8 — fleet expanded by 4 app-domain loops** (the briefing's anticipated
|
|
||||||
`commerce`/`identity` arrivals, auto-picked up by dynamic discovery). All early-stage,
|
|
||||||
**pre-Phase-2 → moving targets, none count toward any gate yet**:
|
|
||||||
- `commerce` (Phase 1: `api/cart/catalog/price`). Its "per-line audit" is a cost
|
|
||||||
*breakdown view* (`api.sx:44`), **not** an append-only decision log → NOT a W4
|
|
||||||
consumer.
|
|
||||||
- `events` (Phase 1: `calendar.sx`, RRULE expansion).
|
|
||||||
- `identity` (early: `session/token`). Defers authZ to acl (`token.sx:15`) — reinforces
|
|
||||||
W2's "delegate `permit?` to acl-on-sx" routing; identity = authN, acl = authZ.
|
|
||||||
- `content` (just-started: `block.sx`).
|
|
||||||
These are the future consumers W2/W3 are waiting on — re-check their per-viewer filters
|
|
||||||
/ pagination once each clears Phase 2. No new gate-clearer this pass.
|
|
||||||
- **Pass 7:** **A1 jumped 4→6 adopters** — `acl` + `mod` migrated to the shared
|
|
||||||
conformance driver (first app-domain adopters; proves it generalizes past substrates).
|
|
||||||
`host-persist` closed its blob-adapter blocker (durable storage adapter now landing →
|
|
||||||
W4 migration path opening). search shipped proximity/NEAR; flow + persist quiescent.
|
|
||||||
- **Pass 6:** new worktree **`host-persist`** (active — building persist's durable host
|
|
||||||
adapter); `feed` went quiescent (left tmux). acl shipped hardening (+25), fed-sx-m1 at
|
|
||||||
Step 6c. **mod loop independently wrote a shared-plumbing note** (`mod-on-sx.md`,
|
|
||||||
538b8a53) corroborating W4/W5 — folded its claims + home disagreements into W1/W4/W5.
|
|
||||||
No new gate-clearer (audit log still 2 consumers), but consumers are now API-stable.
|
|
||||||
- **Pass 5:** search (+highlight/snippet) and fed-sx-m1 (+follower_graph) moved; rest
|
|
||||||
unchanged. Filename census: `api`×6, `fed`×3, then `schema/rank/query/page/explain/
|
|
||||||
engine/batch/audit`×2. Examined the ×6 `api.sx` → Rejected (shared name, divergent
|
|
||||||
structure incl. implicit-vs-explicit-state contract). rank/batch/engine all ≤2 +
|
|
||||||
substrate/domain-divergent → no new gate-clearer.
|
|
||||||
- **Pass 4:** no churn vs pass 3 (same worktrees/tmux/HEADs/adopters). Swept audit+explain
|
|
||||||
surfaces: acl/mod share an append-only-log shape (→ sharpened W4 with persist/log API
|
|
||||||
evidence) and a proof-explain shape (→ new W5, substrate-bound). No new gate-clearer.
|
|
||||||
- **Pass 3 (earlier today):** subsystem set + tmux + A1 adopters (4) all unchanged vs pass 2. Loops
|
|
||||||
advanced: acl shipped Phase 4 federation; search shipped Phase 4 + pagination; feed
|
|
||||||
shipped pagination/threading; mod at Ext 19 (capstone); persist did a worked acl-grants
|
|
||||||
migration (W4). New shape found: offset/limit pagination → folded into W3.
|
|
||||||
- **Subsystem set discovered:** loop worktrees `acl, erlang, fed-prims, fed-sx-m1,
|
|
||||||
feed, flow, go, kernel, mod, ocaml, persist, radar, ruby, search,
|
|
||||||
sx-vm-extensions`; main-repo `lib/*` incl. merged `feed` + substrates (`apl,
|
|
||||||
common-lisp, datalog, erlang, forth, go, haskell, hyperscript, js, lua, minikanren,
|
|
||||||
ocaml, prolog, scheme, smalltalk, tcl`) + `lib/guest`.
|
|
||||||
Actively looping (tmux): `acl, fed-sx-m1, feed, flow, mod, persist, search`
|
|
||||||
(+ radar).
|
|
||||||
- **New since pass 1:** worktrees `kernel` (empty/unset — not yet a repo) and `ocaml`
|
|
||||||
(`lib/ocaml/baseline` only). Both early-stage, pre–Phase 2 → out of proposal scope.
|
|
||||||
- Re-enumerate every pass; new loops (e.g. a future `commerce`/`identity`) auto-join.
|
|
||||||
|
|
||||||
**Census status (pass 17): EXHAUSTED.** Every own-namespace filename recurring ≥2× has
|
|
||||||
been examined and dispositioned — further filename-censusing is low-yield until new
|
|
||||||
subsystems/modules appear. Map:
|
|
||||||
| filename | owners | verdict |
|
|
||||||
|---|---|---|
|
|
||||||
| `api` ×10 | all | Rejected — shared role, divergent state contract |
|
|
||||||
| `fed`/`federation` | feed/search/mod/acl(+content) | W1 — theme not shape |
|
|
||||||
| `audit` ×3 | acl/mod/identity | W4 — append-only log → persist/log |
|
|
||||||
| `page` ×3 | feed/search (pagination) + content (HTML wrapper) | W3 + collision noted |
|
|
||||||
| `explain` ×2 | acl/mod | W5 — proof tree, substrate-bound |
|
|
||||||
| `snapshot` ×2 | persist(facet) + content(reinvents) | W7 |
|
|
||||||
| `wire` ×2 | content(SX serializer) / mod(pipe-format) | Rejected — divergent |
|
|
||||||
| `schema`,`engine` ×2 | acl/mod | substrate-twin parallels (Datalog vs Prolog); only audit (W4) is liftable |
|
|
||||||
| `catalog`,`batch` ×2 | commerce/persist, mod/persist | name collisions, unrelated |
|
|
||||||
| `normalize` ×2 | content(tree-prune)/feed(record-coerce) | name collision (pass 20) |
|
|
||||||
| `index` ×2 | content(listing)/search(inverted index) | name collision (pass 20) |
|
|
||||||
| `query` ×3 | content(doc-block)/search(bool AST)/persist(stream-read) | 3-way name collision (pass 20) |
|
|
||||||
| `store` ×2 | content(on persist) / flow(workflow records) | related concept, divergent |
|
|
||||||
| `rank` ×2 | feed/search | different domains (activities vs docs), ≤2 |
|
|
||||||
**acl⇄mod are structural twins** (decision engine over a logic substrate, Datalog vs
|
|
||||||
Prolog) — they parallel across engine/schema/explain/audit/fed, but only the *audit log*
|
|
||||||
is substrate-agnostic and liftable (→ W4); the rest are substrate-idiomatic. Next passes:
|
|
||||||
re-test gates (W2/W3/W8) as consumers mature, watch new modules — not re-census.
|
|
||||||
|
|
||||||
**Meta-pattern (pass 20):** new module names keep *recurring* but the operations keep
|
|
||||||
*colliding* — same noun, domain-specific op (normalize, index, query, catalog, batch,
|
|
||||||
notify, page, store all proved to be collisions). This is *why* genuine extraction
|
|
||||||
candidates are rare: the fleet shares vocabulary, not structure. The real shared assets
|
|
||||||
are the **substrate subsystems** (persist, flow, acl, fed-sx) that app domains *adopt*
|
|
||||||
(W1/W2/W4/W7/W8), not hand-rolled libs to extract.
|
|
||||||
|
|
||||||
**Scanning-method note (learned the hard way, passes 5/12/14/15):** a filename census
|
|
||||||
for *cross-subsystem* recurrence MUST restrict to each subsystem's OWN namespace —
|
|
||||||
`X/lib/X/*.sx` — never `X/lib/*/`. The merged substrate libs (`lib/prolog`, `lib/persist`,
|
|
||||||
`lib/feed`, `lib/datalog`, …) are checked out inside *every* worktree, so a naive census
|
|
||||||
reports e.g. `query.sx`/`snapshot.sx`/`rank.sx` ×N as phantom recurrences that are really
|
|
||||||
one merged file copied N times. Correct one-liner:
|
|
||||||
`for w in <subsystems>; do for f in $w/lib/$w/*.sx; do basename $f .sx; done; done | sort | uniq -c | sort -rn`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Done
|
|
||||||
|
|
||||||
### A1 · Shared conformance driver — ✅ COMPLETE (merged `db76cc8c`, pass 32)
|
|
||||||
Full closed loop: radar detected it → dedicated `conformance` loop implemented it
|
|
||||||
(classify-then-migrate-or-exclude, hard parity gate) → **merged to architecture**
|
|
||||||
(`db76cc8c Merge loops/conformance into architecture: A1 conformance-driver migration`)
|
|
||||||
→ radar spot-verified post-merge (**common-lisp 487/487 green** on architecture — exercises
|
|
||||||
the new per-suite-counters/preloads driver feature, the riskiest change). Final state:
|
|
||||||
- **13 on the shared driver:** acl, apl, common-lisp, datalog, erlang, events, feed, go,
|
|
||||||
haskell, mod, prolog, relations, search.
|
|
||||||
- **6 correctly excluded** (foreign-program runners — a legitimately different harness):
|
|
||||||
forth, js, ocaml, smalltalk, tcl, lua.
|
|
||||||
- The shared driver gained per-suite counters + per-suite preloads (backward-compatible);
|
|
||||||
spot-check confirms existing adopters unaffected. Coordination flag CLEARED.
|
|
||||||
Detail of the migration arc retained under the original entry below.
|
|
||||||
|
|
||||||
## Proposed (cleared the gate)
|
|
||||||
|
|
||||||
_(empty — A1 graduated to Done, pass 32.)_
|
|
||||||
|
|
||||||
### A1 · Adopt the shared conformance driver across subsystems
|
|
||||||
- **Pattern:** every subsystem hand-rolls a near-identical `conformance.sh`
|
|
||||||
(epoch-load → eval → scoreboard emit) and an inline `<x>-test name got expected`
|
|
||||||
pass/fail counter.
|
|
||||||
- **Consumers (≥3, overwhelming):** 15 `lib/*/conformance.sh` — `apl, feed, datalog,
|
|
||||||
flow, mod, lua, erlang, forth, go, common-lisp, haskell, js, ocaml, prolog,
|
|
||||||
smalltalk, tcl`.
|
|
||||||
- **Home:** `lib/guest` — the one legitimate exception (the shared driver
|
|
||||||
`lib/guest/conformance.sh` + `lib/guest/conformance.sx` already exist; modes
|
|
||||||
`dict` and `counters`).
|
|
||||||
- **Status: IN PROGRESS — 6 adopters (pass 7).** `prolog` (dict), `haskell` (counters),
|
|
||||||
`apl` (dict), `datalog` (dict), and **`acl` (dict) + `mod` (dict), newly migrated this
|
|
||||||
pass** — all 3-line exec shims into `lib/guest/conformance.sh` with a `conformance.conf`.
|
|
||||||
**acl + mod are the first *app-domain* adopters** (not language substrates) — strong
|
|
||||||
evidence the driver generalizes beyond the substrate layer, which was the open question.
|
|
||||||
The `apl` migration earlier *surfaced a latent bug*: the old awk extractor
|
|
||||||
under-counted `pipeline` (40 vs the real 152 assertions); true apl total is **562**,
|
|
||||||
not 450 — evidence that adopting the driver also improves correctness.
|
|
||||||
- **Not a target (different harness shape):** `lua/conformance.sh` is a Python runner
|
|
||||||
(`lib/lua/conformance.py`) that walks real `*.lua` source files via `lua-eval-ast`
|
|
||||||
and classifies pass/fail/timeout — it does not run SX `deftest` suites with a
|
|
||||||
counter/dict scoreboard, so the shared driver does not fit. Excluded, not pending.
|
|
||||||
- **Remaining hand-rolled candidates (~120–220 lines each):** `common-lisp, erlang,
|
|
||||||
feed, forth, go, js, ocaml, smalltalk, tcl` — now being worked by the dedicated
|
|
||||||
`conformance` loop (above). (`lua` excluded: walks real `*.lua` files via Python.
|
|
||||||
`smalltalk` likely excludes too — runs `*.st` via its own `test.sh`. `search` was
|
|
||||||
thought to be excluded but DID migrate via counters mode — see the 7-adopter note.)
|
|
||||||
- **Action:** each remaining subsystem's OWN loop migrates when quiescent — add a
|
|
||||||
`conformance.conf` (+ a `test-harness.sx` preload defining its counters) and
|
|
||||||
replace `conformance.sh` with the 1-line exec shim
|
|
||||||
(`exec bash …/guest/conformance.sh …/conformance.conf "$@"`). Recipe template:
|
|
||||||
`lib/haskell/conformance.conf` (counters) or `lib/prolog/conformance.conf` (dict).
|
|
||||||
Keep the `bash lib/X/conformance.sh` entry point so no loop is disrupted.
|
|
||||||
- **Priority: HIGH** (15 consumers, low risk, interface-preserving, additive).
|
|
||||||
- **8 adopters on architecture** (pass 25): acl, apl, datalog, **events**, haskell, mod,
|
|
||||||
prolog, search — `events` migrated via its OWN loop; `search` via counters mode (which
|
|
||||||
corrects the earlier "search excluded" note). **+4 on the `loops/conformance` branch:
|
|
||||||
`common-lisp` 487/487, `erlang` 761/761, `feed` 189/189, `go` 609/609** — pending merge.
|
|
||||||
**5 EXCLUDED — all foreign-runner harnesses** (correctly, not force-migrated): `forth`
|
|
||||||
(Hayes core.fr via awk+python), `js` (test262 `.js`/`.expected`), `ocaml` (scrapes
|
|
||||||
`test.sh` + `.ml` baseline), `smalltalk` (scrapes `test.sh` + `*.st` corpus), `tcl`
|
|
||||||
(foreign `*.tcl` vs `# expected:` annotations).
|
|
||||||
- **✅ CONFORMANCE LOOP WORKLIST COMPLETE (pass 31).** Final A1 picture:
|
|
||||||
- **12 on the shared driver:** acl, apl, datalog, events, haskell, mod, prolog, search
|
|
||||||
(on architecture) + common-lisp, erlang, feed, go (on `loops/conformance`, pending merge).
|
|
||||||
- **6 correctly excluded** (foreign-program runners — testing a language impl against an
|
|
||||||
external corpus is legitimately a different harness): forth, js, ocaml, smalltalk, tcl, lua.
|
|
||||||
- **Honest finding:** the driver's reach is narrower than the raw "15 conformance.sh"
|
|
||||||
count implied — language substrates that run real `.lua/.st/.ml/.tcl/.js/.fr` programs
|
|
||||||
*should* keep their foreign runners. ~half migrate, ~half don't, and that's correct.
|
|
||||||
- **One step left:** merge `loops/conformance` → architecture under the **adopter-parity
|
|
||||||
check** (the coordination flag above — the shared `lib/guest` driver change must be
|
|
||||||
proven non-regressive against all existing adopters first). The loop is now idle.
|
|
||||||
- **NOW IN PROGRESS — dedicated loop (2026-06-07).** A human-triggered `conformance` loop
|
|
||||||
(worktree `/root/rose-ash-loops/conformance`, branch `loops/conformance`, tmux session
|
|
||||||
`a1-conformance`, briefing `plans/agent-briefings/conformance-loop.md`) is working the
|
|
||||||
remaining candidates (common-lisp, erlang, feed, forth, go, js, ocaml, smalltalk, tcl)
|
|
||||||
one per iteration, **classify-then-migrate-or-exclude with a hard test-count parity gate**
|
|
||||||
(reverts on any mismatch; never pushes to main/architecture). Radar tracks; it implements.
|
|
||||||
- **Driver-capability boundary found (pass 24, first iteration).** The loop did NOT
|
|
||||||
force-migrate `common-lisp` (baseline 305/0 across 12 suites) — the shared driver can't
|
|
||||||
reproduce it: `MODE=counters` supports only ONE global pass/fail counter pair + ONE fixed
|
|
||||||
preload set, but common-lisp needs **per-suite counter names** (8 distinct pairs) and
|
|
||||||
**per-suite preload chains**. It logged a precise blocker + unblock path (extend the
|
|
||||||
`SUITES` entry format with optional per-suite counters/preloads) and moved on.
|
|
||||||
- **Driver gap RESOLVED next iteration (pass 25) — but it touched the shared driver.** The
|
|
||||||
loop extended `lib/guest/conformance.sh` (+38 lines: optional per-suite counters + per-suite
|
|
||||||
preloads in the `SUITES` format, backward-compatible) and then migrated common-lisp at
|
|
||||||
**487/487** (above the 305 baseline — likely another extractor under-count correction, à la
|
|
||||||
apl's `pipeline`). The parity gate held throughout.
|
|
||||||
- **⚠ COORDINATION FLAG (radar): the `loops/conformance` branch now carries a change to the
|
|
||||||
SHARED `lib/guest` driver** used by all 8 adopters. It's additive by design, but **before
|
|
||||||
this branch merges to `architecture`, re-run the existing adopters' suites under the new
|
|
||||||
driver to confirm zero regression** (acl/apl/datalog/events/haskell/mod/prolog/search).
|
|
||||||
This is the one cross-cutting risk in an otherwise per-subsystem-isolated effort — surfaced
|
|
||||||
here so the merge is gated on adopter-parity, not assumed.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Watching (real but not yet through the gate)
|
|
||||||
|
|
||||||
### W1 · Federation scaffold (merge / ingest / backfill / trust-gate)
|
|
||||||
- **FAILS the structural-identity gate (deep-dived 2026-06-06, all 4 read).** Consumer
|
|
||||||
count is met (4) but they are *superficially* similar, not structurally identical —
|
|
||||||
the federated unit and merge op differ fundamentally:
|
|
||||||
|
|
||||||
| Subsystem (file) | Federated unit | Merge op | Trust gate | Injected transport |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| feed (`fed.sx:14,18,40`) | activity streams | dedupe by `(actor verb object)` | none (visibility via `permit?` separately) | `send-fn`, `fetch-fn` |
|
|
||||||
| search (`fed.sx:8`) | inverted indices | relabel DocId `peer*1000+local` + union posting lists | none | none (pure merge fn) |
|
|
||||||
| mod (`fed.sx:11-14,99`) | moderation decisions | advisory-list vs applied-list; bind iff `mod/trusted?` | **yes — runtime list** `mod/trusted? peer scope` | mock outbox / `fed-send!` |
|
|
||||||
| acl (`federation.sx:43,56`) | Datalog delegate facts | pull facts, gate by `trust`/`level_covers` rule, re-saturate | **yes — Datalog rule** at query time | `transport` dict |
|
|
||||||
| events (`federation.sx`) | calendar agendas | fold trusted peers' agendas into one sorted agenda + `:origin` provenance | **yes — runtime list** `ev/trusts?` (peer-id ∈ trust-set) | injected behind `ev/peer-agenda` |
|
|
||||||
|
|
||||||
- **The ONLY real commonality is the injection seam** (now 5/5, pass 18), not extractable
|
|
||||||
code: every one says "the real transport is `fed-sx`'s job; inject `send-fn`/`fetch-fn`/
|
|
||||||
`transport`/`peer-agenda` and mock it in tests." That is an architectural *convention the
|
|
||||||
fleet already follows*. The merge op diverges 5 ways (dedupe / index-union / advisory /
|
|
||||||
fact-saturation / agenda-sort). The trust gate, where present, splits: **mod + events use
|
|
||||||
a runtime trust-set membership check; acl uses a declarative Datalog rule** — so even the
|
|
||||||
trust sub-pattern is 2-of-3, and the membership check is a trivial one-liner (below the
|
|
||||||
extraction threshold). No shared merge, no single shared trust mechanism.
|
|
||||||
- **Disposition:** do NOT extract a shared "federation lib." When `fed-sx` ships its
|
|
||||||
real transport, these 4 become its *consumers* (wiring `send-fn`/`fetch-fn`/`transport`
|
|
||||||
to it) — that work belongs to each subsystem's loop + the `fed-sx` loop, not a
|
|
||||||
cross-cutting extraction. Stop re-proposing on the shared name. Home: `fed-sx`.
|
|
||||||
- **Now 7 federation modules (pass 29):** + `relations` (Phase 4: erel trust-gating,
|
|
||||||
peer_rel/trust, fed-sx mock transport — Datalog-rule trust like acl) and `artdag`
|
|
||||||
(Phase 6: content-addressed cache + trust + **invalidation** — a merge shape unlike any
|
|
||||||
other). Each new one reinforces "theme not shape": 7 divergent merges, all sharing only
|
|
||||||
the inject-fed-sx-transport seam. Verdict unchanged — they're fed-sx consumers-in-waiting.
|
|
||||||
- **Narrower sub-claim (mod note, pass 6; refined pass 18):** mod asserts the *fed
|
|
||||||
trust/outbox* shape shares between mod+acl. Radar evidence refines this: the trust gate
|
|
||||||
splits by mechanism, not by subsystem pair — **mod + events** both use a runtime
|
|
||||||
trust-set membership check (`mod/trusted?`, `ev/trusts?`), while **acl** uses a Datalog
|
|
||||||
rule. So a "trust-set membership" helper has 2 consumers (mod, events) — but it's a
|
|
||||||
one-line `member?` and the merge it gates diverges, so still not worth extracting.
|
|
||||||
Resolve at the architecture-merge point if a heavier shared trust-set surface emerges.
|
|
||||||
|
|
||||||
### W2 · Per-viewer visibility / permission filter
|
|
||||||
- **2 shipped consumers, same shape** — `filter <injected-permit> <ranked/candidate stream>`:
|
|
||||||
- `feed/lib/feed/acl.sx:27` `feed/visible = (feed/filter stream (fn (a) (permit? viewer a)))`,
|
|
||||||
capstone at `:34` (stream → ACL → rank → top-N). `permit?` injected, sig `(viewer activity)→bool`.
|
|
||||||
- `search/lib/search/fed.sx:16` `aclFilter permit docs = filter permit docs`;
|
|
||||||
`topNTfIdfAcl n permit ts idx = take n (aclFilter permit (rankTfIdf ts idx))`.
|
|
||||||
`permit` injected, sig `DocId→Bool` (viewer baked in by caller).
|
|
||||||
- **NOT a consumer:** `mod/lib/mod/policy.sx` is moderation policy (reviewer actions),
|
|
||||||
no per-viewer read filter. So mod won't be the 3rd.
|
|
||||||
- **Missing:** (a) only 2 consumers, need ≥3; (b) the two interfaces *diverge* —
|
|
||||||
feed passes `(viewer, item)`, search bakes the viewer in — so any shared form must
|
|
||||||
pick a convention; (c) both already **inject** the predicate, and the filter body is
|
|
||||||
literally one line (`filter permit xs`). Leaning toward: the predicate's home is
|
|
||||||
`acl-on-sx` (`permit?`), and the one-line filter is too thin to extract.
|
|
||||||
- **Home when ripe:** delegate `permit?` to `acl-on-sx`; do NOT extract the filter.
|
|
||||||
Re-check if a 3rd genuine per-viewer read filter ships (e.g. events/commerce).
|
|
||||||
|
|
||||||
### W3 · Collection helpers (group-by, dedupe-by-key, stable top-N, distinct-order, offset/limit page)
|
|
||||||
- feed built all of these on APL primitives. search/commerce/events will want
|
|
||||||
group-by / top-N.
|
|
||||||
- **NEW (2026-06-06): offset/limit pagination shipped in 2 subsystems, identical shape**
|
|
||||||
`take limit (drop offset xs)`:
|
|
||||||
- `feed/lib/feed/page.sx:9` `feed/page` (offset/limit window over a stream).
|
|
||||||
- `search/lib/search/page.sx:9` `paginate off lim docs = take lim (drop off docs)`.
|
|
||||||
- NOT a 3rd: `persist/lib/persist/query.sx:5` has a *since-cursor* for incremental log
|
|
||||||
consumption — resumable-stream semantics, not result windowing. Different shape.
|
|
||||||
- feed *also* has cursor-by-`:at` recency pagination (`page.sx:21-44`); search has no
|
|
||||||
cursor. So only the plain offset/limit window is shared, and it is a literal 1-liner.
|
|
||||||
- **Missing:** ≥3 stable consumers; AND every item here is collection math that belongs
|
|
||||||
in the **substrate** (APL/Haskell already expose grade/sort/unique/take/drop), not a
|
|
||||||
shared lib. A 1-line `take/drop` window is far below the extraction threshold. Watch;
|
|
||||||
revisit only if a non-substrate subsystem needs the same windowing without take/drop.
|
|
||||||
- **Filename-collision caution (pass 13):** `content/lib/content/page.sx` is an **HTML
|
|
||||||
page wrapper** (full HTML5 doc), NOT pagination — do not count it as a 3rd pagination
|
|
||||||
consumer. `page.sx` now means two unrelated things across the fleet. Re-tested pass 13:
|
|
||||||
pagination still only feed + search (2).
|
|
||||||
|
|
||||||
### W4 · In-memory store fakes → `persist-on-sx`
|
|
||||||
- Not an abstraction to extract — a migration target. Every subsystem fakes its
|
|
||||||
store with a mutable list (`feed/-log`, flow store, mod audit, …).
|
|
||||||
- **Owner:** `persist-on-sx` (in progress). Tracked there, listed here for visibility.
|
|
||||||
- **Concrete instance (file:line, found pass 4): the append-only decision/audit log.**
|
|
||||||
`acl/lib/acl/audit.sx` and `mod/lib/mod/audit.sx` are the SAME hand-rolled shape, and
|
|
||||||
`persist/lib/persist/log.sx` (the persist *log facet*) already implements it durably:
|
|
||||||
|
|
||||||
| role | acl/audit.sx | mod/audit.sx | persist/log.sx (target) |
|
|
||||||
|---|---|---|---|
|
|
||||||
| log var | `acl-audit-log` :9 | `mod/*audit-log*` :10 | backend stream |
|
|
||||||
| monotonic seq | `acl-audit-seq` :10 | `mod/*audit-seq*` :11 | per-stream high-water :1 |
|
|
||||||
| append (auto-seq) | `acl-audit-decide!` | commit :32 | `persist/append` :17 |
|
|
||||||
| count | `acl-audit-count` :51 | `mod/audit-count` :44 | `persist/count` :12 |
|
|
||||||
| read-all oldest-first | snapshot/tail :73 | `mod/audit-all` :43 | `persist/read` :29 |
|
|
||||||
| read seq≥from | — | by-seq | `persist/read-from` :31 |
|
|
||||||
|
|
||||||
Both deliberately use a monotonic seq with **no wall-clock** (deterministic/testable) —
|
|
||||||
identical to persist/log's design. Action when persist's host adapter lands: acl + mod
|
|
||||||
loops swap their in-memory log for `persist/log`. 2 consumers today; not a new lib —
|
|
||||||
the home already exists. Belongs to acl/mod loops × persist loop, not an extraction.
|
|
||||||
- **Cross-loop corroboration (pass 6):** the mod loop independently reached the same
|
|
||||||
conclusion — `mod/plans/mod-on-sx.md` (commit 538b8a53): *"mod-sx (Prolog) and acl-sx
|
|
||||||
(Datalog) converged on the same module shape … only the audit log + fed trust/outbox
|
|
||||||
shapes truly share; extract at the architecture-merge point, refactoring both consumers
|
|
||||||
atomically, not unilaterally from a loop branch."* Confirms the shape AND the
|
|
||||||
do-not-extract-unilaterally stance.
|
|
||||||
- **Home disagreement to resolve at merge:** mod's note proposes lifting the audit-log
|
|
||||||
primitives into **`lib/guest/`**. Radar routing disagrees: a durable append-only log is
|
|
||||||
a **`persist-on-sx`** concern (the log facet already exists), not language-impl plumbing.
|
|
||||||
Hold the line — `lib/guest` is lexer/parser/AST/HM/test-runner, not an event log.
|
|
||||||
- **Migration is becoming concrete:** new `host-persist` loop (worktree + tmux, pass 6)
|
|
||||||
is building the durable-storage host adapter persist was blocked on — once it lands,
|
|
||||||
acl/mod can actually swap to `persist/log`.
|
|
||||||
- **LIVE REFERENCE EXEMPLAR (pass 9): `content` already does it right.** `content`
|
|
||||||
(Phase 2 complete, 162/162) built its op log directly on `persist/log` instead of
|
|
||||||
faking it — `content/lib/content/store.sx`: backend injected via `(persist/open)`
|
|
||||||
("content knows nothing about which backend", :10); append op as event
|
|
||||||
`persist/append b (content/-stream doc-id) …` (:20); read `persist/read` (:36);
|
|
||||||
`persist/last-seq` (:47); **version = replay op stream up to a seq**
|
|
||||||
(filter `persist/event-seq ev <= seq`, :61). "The op log is the source of truth …
|
|
||||||
the materialised doc is a cache, never primary state."
|
|
||||||
This proves the W4 target is real, not hypothetical: acl + mod's hand-rolled
|
|
||||||
monotonic-seq logs should adopt exactly content's `persist/log` pattern.
|
|
||||||
- **Consumer ledger of the append-only monotonic-seq event log (pass 11):**
|
|
||||||
|
|
||||||
| consumer | what | backing | note |
|
|
||||||
|---|---|---|---|
|
|
||||||
| content (`store.sx`) | doc op log | **persist/log ✓ live** | plain append + replay-to-seq |
|
|
||||||
| commerce (`ledger.sx`) | order ledger | **persist/log ✓ live** | `persist/append-once` — idempotent, webhook-replay-safe :40,58 |
|
|
||||||
| events (`booking.sx`) | booking roster | **persist/log ✓ live** | `persist/append-expect` — optimistic-concurrency CAS, capacity-safe, lock-free |
|
|
||||||
| acl (`audit.sx`) | decision log | in-memory fake (SX) | migrate directly when host adapter lands |
|
|
||||||
| mod (`audit.sx`) | decision log | in-memory fake (SX) | migrate directly |
|
|
||||||
| identity (`audit.sx`) | grant ledger | in-memory fake (**Erlang**) | `{Seq,Subject,Action}`; needs an **Erlang↔persist bridge** first — author scoped it out until persist lands ("queryable semantics identical") |
|
|
||||||
|
|
||||||
- **Two takeaways:** (1) the pattern is **validated across domains** — CRDT doc ops,
|
|
||||||
financial orders, event bookings, rule decisions, OAuth grants all reduce to the same
|
|
||||||
append-only monotonic-seq stream; (2) migrating to `persist/log` is strictly *better*
|
|
||||||
than the fakes — persist exposes a **feature ladder the fakes don't have**:
|
|
||||||
`append` (content) → `append-once`/idempotency (commerce) → `append-expect`/optimistic-
|
|
||||||
concurrency (events). Every fake would have to reinvent a weaker version of these.
|
|
||||||
This is an **adoption** item (the home already exists), NOT a new extraction — owned by
|
|
||||||
persist/host-persist × each consumer loop. The SX fakes (acl, mod) migrate directly;
|
|
||||||
the Erlang fake (identity) is gated on an Erlang↔persist bridge.
|
|
||||||
|
|
||||||
### W5 · Proof-tree explanation over a logic-program derivation
|
|
||||||
- `acl/lib/acl/explain.sx` (reconstructs a canonical proof by goal-directed search over a
|
|
||||||
saturated Datalog db) and `mod/lib/mod/explain.sx` (renders a Prolog-style proof tree
|
|
||||||
goal-by-goal with proved/unproved marks + unification bindings) are the same *idea*.
|
|
||||||
- **Missing / disposition:** only 2 consumers, and they sit on **different substrates**
|
|
||||||
(acl→`lib/datalog`, mod→`lib/prolog`). Proof reconstruction/rendering is logic-engine
|
|
||||||
machinery → it belongs in each **substrate** (datalog/prolog), not a shared app lib.
|
|
||||||
Watch; revisit only if a 3rd logic-backed subsystem reimplements proof explanation.
|
|
||||||
- **Cross-loop note (pass 6):** mod's note calls `mod/proof-goals` (re-query-each-goal)
|
|
||||||
generic and proposes lifting it into **`lib/guest/`**. Radar caveat: proof-tree
|
|
||||||
reconstruction *is* engine-agnostic logic machinery, but `lib/guest` is for
|
|
||||||
lexer/parser/AST/HM/match/test-runner — a logic-engine proof helper is a poor fit there.
|
|
||||||
If genuinely shared by ≥3 engines, a `lib/logic`-style substrate helper is the better
|
|
||||||
home than `lib/guest`. Still 2 consumers → stays Watching either way.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### W9 · Parent/child relationship tracking → the new `relations` subsystem (nascent)
|
|
||||||
- **New subsystem (pass 28):** `relations` (loops/relations, Phase 1 — `schema.sx`+`api.sx`,
|
|
||||||
rel facts + `relate`/`unrelate`/`children`/`parents`/`related`, 22 tests). Per CLAUDE.md
|
|
||||||
it's the canonical "cross-domain parent/child relationship tracking."
|
|
||||||
- **Why watch:** several subsystems already track parent/child *locally* — feed reply-to
|
|
||||||
threading (`thread`/`replies`), content nested block trees, events occurrence/RECURRENCE-ID
|
|
||||||
links. If `relations` becomes the shared home, those are candidate *delegators* (like
|
|
||||||
acl=authZ, persist=log). But it's **Phase 1, pre-Phase-2, moving target** — and each
|
|
||||||
local impl is currently domain-specific (different keys/semantics). Do NOT propose yet.
|
|
||||||
Re-check when relations is past Phase 2 AND ≥3 subsystems' relationship logic could
|
|
||||||
genuinely delegate to it. `artdag` also just spawned (nascent, 0 files) — tracking only.
|
|
||||||
(pass 32: `dream` + `maude` also spawned, nascent 0-files; `fed-prims` resumed.)
|
|
||||||
- **Update pass 29:** relations rocketed to **Phase 4** (one gate — past Phase 2 — now met),
|
|
||||||
but it's building ITSELF out (schema/federation), **not yet being consumed** by anyone.
|
|
||||||
The blocker is the other gate: 0 subsystems currently *delegate* their parent/child logic
|
|
||||||
to it (feed/content/events still track locally). Watch for the first real delegation.
|
|
||||||
(artdag also raced to Phase 6 — these ports advance fast; treat committed state as truth.)
|
|
||||||
|
|
||||||
### W8 · Durable externally-resumed orchestration on `lib/flow` (suspend→host-IO→resume)
|
|
||||||
- **The shared shape:** a durable `flow` that `request`s an external action (a suspend
|
|
||||||
point), the **host** performs the IO, then `flow/resume`s the flow with the outcome;
|
|
||||||
flow's deterministic replay means a completed step never re-runs on recovery.
|
|
||||||
- **Consumers (pass 24): 2 LIVE** (events delivery, commerce order saga).
|
|
||||||
- `events/lib/events/notify.sx` (**live**) — reminders/digests as durable flows;
|
|
||||||
suspend on delivery `dispatch`, resume with send outcome. At-least-once + idempotency key.
|
|
||||||
- `commerce` (**LIVE** as of pass 24 — "order lifecycle as a durable flow-on-sx flow,
|
|
||||||
21 tests, Phase 3 done") — order saga `(defflow ordf … (request 'reserve oid) … )`:
|
|
||||||
reserve→pay→fulfil as a flow, **payment stays suspended until the payment webhook calls
|
|
||||||
`flow/resume`**. Carries only the order-id; pure orchestration over `ledger.sx`.
|
|
||||||
- **Now 2 LIVE consumers** of the *same* pattern: long-running process, external resume
|
|
||||||
(delivery dispatch vs payment webhook). fed-sx/mod still roll their own outbox (watch
|
|
||||||
for convergence). Strengthens "lib/flow is the home"; still adoption, not extraction.
|
|
||||||
- **Disposition:** `lib/flow` IS the abstraction (events proves it, commerce adopts it) →
|
|
||||||
this is an **adoption** observation like W4, NOT an extraction. Home = `lib/flow`.
|
|
||||||
- **Flow-onboarding friction (light signal):** commerce's note logs real gotchas adopting
|
|
||||||
flow — `flow-make-env` returns a large likely-cyclic env (don't print it), env build is
|
|
||||||
slow (budget ~540s like flow's own suite). If ≥3 subsystems hit the same onboarding
|
|
||||||
gotchas, that's a signal to smooth `lib/flow`'s adopter API — flow's concern, flagged here.
|
|
||||||
- **Name-collision caveat:** `notify.sx` means two unrelated things — `feed/notify.sx` is
|
|
||||||
a *read-side digest* (group inbox by verb+object), NOT delivery. Do not pair them.
|
|
||||||
|
|
||||||
### W7 · Snapshot/projection-checkpoint reimplemented vs `persist/snapshot` (delegate)
|
|
||||||
- `persist/lib/persist/snapshot.sx` already provides a **generic** projection checkpoint:
|
|
||||||
store `{:value :seq}` in the kv facet under a namespaced key; the headline property is
|
|
||||||
**snapshot + tail == full replay** (pure, clock-free).
|
|
||||||
- `content/lib/content/snapshot.sx` **reimplements that same pattern on raw persist KV**
|
|
||||||
rather than delegating: `persist/kv-put b (content/-snap-key doc-id) {:doc … :seq seq}`
|
|
||||||
(:20), `persist/kv-has?`/`kv-get` (:27-28), and its own tail-replay (:53-59). It never
|
|
||||||
calls `persist/snapshot-*`. content's doc-materialisation *is* a projection fold over
|
|
||||||
its op stream — exactly what `persist/snapshot` checkpoints generically.
|
|
||||||
- **Disposition:** persist-adoption nudge (like W4): content could delegate to
|
|
||||||
`persist/snapshot` (its projection = "fold ops → doc"), dropping the duplicated
|
|
||||||
KV+replay code. Home already exists → NOT an extraction; owned by content × persist
|
|
||||||
loops. Only 1 reinventor today; watch whether commerce/events/identity also hand-roll a
|
|
||||||
snapshot on raw KV instead of using the facet (would strengthen the nudge). NB timeline:
|
|
||||||
unclear if `persist/snapshot` predated content's — flag, don't blame.
|
|
||||||
|
|
||||||
### W6 · Guarded lifecycle state machine (illegal transition = explicit error)
|
|
||||||
- Recurs as a **design principle**, NOT a shared structure (found pass 10):
|
|
||||||
- `mod/lib/mod/lifecycle.sx` — pure SX: immutable case `{:state :error :history …}`,
|
|
||||||
explicit transition table `mod/lc-transitions` (:31), illegal transition returns the
|
|
||||||
case unchanged with `:error` set. States open→triaged→decided→appealed→final.
|
|
||||||
- `identity/lib/identity/membership.sx` — an **Erlang `gen_server`** fragment (identity
|
|
||||||
runs on erlang-on-sx): a `receive` loop with `case find(...) of … {error, St}` guards.
|
|
||||||
States none→pending→active→lapsed→revoked.
|
|
||||||
- **Both share the guideline** ("invalid transitions are explicit errors, never silent
|
|
||||||
no-ops") but **implement it substrate-idiomatically** — SX transition-table over
|
|
||||||
immutable values vs an Erlang process loop with per-message case guards. Same W1/`api.sx`
|
|
||||||
trap: shared *idea*, divergent *structure*.
|
|
||||||
- **Disposition:** not an extraction target — the FSM mechanism is ~10 substrate-specific
|
|
||||||
lines; the value is in each domain's state graph, not the plumbing. At most a **design
|
|
||||||
guideline** ("model lifecycle as a guarded FSM with explicit-error transitions"). Watch
|
|
||||||
whether commerce-checkout / events-booking add their own — if so it confirms the
|
|
||||||
*guideline*, still not a lib. Do not propose extracting a shared state-machine lib.
|
|
||||||
|
|
||||||
## Rejected (considered, declined — do not re-propose)
|
|
||||||
|
|
||||||
- **"Continuous auto-implementing abstractor loop."** Rejected at design time: an
|
|
||||||
agent writing across `lib/<x>/**` breaks the worktree isolation that makes the
|
|
||||||
fleet safe, and is rewarded for manufacturing premature/wrong abstractions. The
|
|
||||||
radar is read-only by design. (This file is the alternative.)
|
|
||||||
- **Shared `api.sx` "public boundary" module (×6).** Rejected pass 4-5: every subsystem
|
|
||||||
has an `api.sx` (acl, feed, flow, mod, persist, search — a 100% filename match), but it
|
|
||||||
is a naming *convention for the public entry point*, not a shared structure. They
|
|
||||||
disagree on the most basic contract: acl/feed use **implicit module state**
|
|
||||||
(`acl/api.sx` "implicit current db", `feed/api.sx` "single mutable log") while
|
|
||||||
`persist/api.sx` threads an **explicit backend as every call's first arg**; flow's api
|
|
||||||
*builds a Scheme env*, search's api *concatenates a Haskell source string*, mod's is a
|
|
||||||
*lifecycle state-machine façade* (17 defs vs persist's 1). Same role, no common shape —
|
|
||||||
the W1 coincidental-resemblance trap. Do not re-propose on the filename.
|
|
||||||
- **Shared `wire.sx` "serialization" module (×2).** Rejected pass 15: content + mod both
|
|
||||||
have a `wire.sx`, but `content/wire.sx` uses the **generic SX serializer**
|
|
||||||
(`serialize`/`parse`, full-fidelity round-trip) while `mod/wire.sx` is a **bespoke
|
|
||||||
versioned pipe-delimited line** (subset of fields, `split` hand-built over slice/len
|
|
||||||
because mod's Prolog-loaded env strips string prims). Shared role (wire format),
|
|
||||||
divergent structure + substrate constraint → not a candidate; the SX serializer is
|
|
||||||
already the shared tool for SX-substrate subsystems, and mod can't use it. (Same family
|
|
||||||
as the `api.sx` rejection above.)
|
|
||||||
- **Dumping app-domain plumbing into `lib/guest`.** Rejected: `lib/guest` is for
|
|
||||||
language-implementation plumbing. App patterns route to acl/fed-sx/persist/
|
|
||||||
substrate/host instead (see the routing rule in the briefing).
|
|
||||||
136
plans/agent-briefings/mod-loop.md
Normal file
136
plans/agent-briefings/mod-loop.md
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
# mod-on-sx loop agent (single agent, queue-driven)
|
||||||
|
|
||||||
|
Role: iterates `plans/mod-on-sx.md` forever. **Moderation on Prolog** — reports,
|
||||||
|
policy rules, decisions as backtracking proof search, audit trails, escalation
|
||||||
|
state machine, federation. Where acl-sx asks "may this happen?", mod-sx asks
|
||||||
|
"should this stay?" Sits on `lib/prolog/` (its test suite already green); adds a
|
||||||
|
moderation-shaped vocabulary on top.
|
||||||
|
|
||||||
|
```
|
||||||
|
description: mod-on-sx queue loop
|
||||||
|
subagent_type: general-purpose
|
||||||
|
run_in_background: true
|
||||||
|
isolation: worktree
|
||||||
|
```
|
||||||
|
|
||||||
|
## Prompt
|
||||||
|
|
||||||
|
You are the sole background agent working `plans/mod-on-sx.md`. Isolated worktree
|
||||||
|
`/root/rose-ash-loops/mod` on branch `loops/mod`, forever, one commit per feature.
|
||||||
|
Push to `origin/loops/mod` after every commit. Never touch `main` or `architecture`.
|
||||||
|
|
||||||
|
## Restart baseline — check before iterating
|
||||||
|
|
||||||
|
1. Read `plans/mod-on-sx.md` — roadmap + Progress log.
|
||||||
|
2. `ls lib/mod/` — pick up from the most advanced file.
|
||||||
|
3. If `lib/mod/tests/*.sx` exist, run them via `bash lib/mod/conformance.sh`. Green
|
||||||
|
before new work.
|
||||||
|
4. If `lib/mod/scoreboard.md` exists, that's your baseline.
|
||||||
|
5. Read the `lib/prolog/` public API once — that's your substrate. The plan cites
|
||||||
|
`lib/prolog/prolog.sx` but that file does **not** exist; the real entry points
|
||||||
|
are `lib/prolog/runtime.sx`, `query.sx`, `compiler.sx`, `parser.sx`. Investigate
|
||||||
|
them (sx_find_all / grep for `(define ` heads) to learn how to assert facts and
|
||||||
|
run queries before writing any policy code.
|
||||||
|
|
||||||
|
## The queue
|
||||||
|
|
||||||
|
Phase order per `plans/mod-on-sx.md`:
|
||||||
|
|
||||||
|
- **Phase 1** — report representation + simple policy (schema, defrule→clause,
|
||||||
|
`(decide id)` query, api). Tests: spam keyword → hide, repeated reports →
|
||||||
|
escalate, no rule → keep.
|
||||||
|
- **Phase 2** — evidence accumulation + audit trail (proof tree from derivation,
|
||||||
|
append-only decision log, retrieval).
|
||||||
|
- **Phase 3** — escalation + lifecycle state machine
|
||||||
|
(`:open → :triaged → :decided → :appealed → :final`), auto/human tiers, appeal.
|
||||||
|
- **Phase 4** — federation (cross-instance reports, decision sharing, trust model,
|
||||||
|
revocation; mock fed-sx in tests).
|
||||||
|
|
||||||
|
Within a phase, pick the checkbox that unlocks the most tests per effort.
|
||||||
|
|
||||||
|
Every iteration: implement → test → commit → tick `[ ]` → Progress log → next.
|
||||||
|
|
||||||
|
## Ground rules (hard)
|
||||||
|
|
||||||
|
- **Scope:** only `lib/mod/**` and `plans/mod-on-sx.md`. Do **not** edit `spec/`,
|
||||||
|
`hosts/`, `shared/`, other `lib/<lang>/` dirs, `lib/stdlib.sx`, or `lib/` root.
|
||||||
|
May **import** from `lib/prolog/` only (its public API). Do **not** modify Prolog.
|
||||||
|
- **NEVER call `sx_build`.** 600s watchdog. If the sx_server binary is broken →
|
||||||
|
Blockers entry, stop. Run tests by invoking the sx_server binary directly from a
|
||||||
|
conformance.sh (see how `lib/prolog/conformance.sh` drives it), pointing
|
||||||
|
`SX_SERVER` at `/root/rose-ash/hosts/ocaml/_build/default/bin/sx_server.exe`
|
||||||
|
(fresh worktrees have no `_build/`).
|
||||||
|
- **Shared-file issues** → plan's Blockers with minimal repro; don't fix here.
|
||||||
|
- **SX files:** `sx-tree` MCP tools ONLY. **They take `file:` not `path:`** — a
|
||||||
|
wrong key yields `Yojson Type_error("Expected string, got null")`, which looks
|
||||||
|
like a broken binary but is just a param mismatch. `sx_validate` after edits.
|
||||||
|
Path-based edits (`sx_replace_node`) count comment headers in their indices and
|
||||||
|
can clobber the wrong node — re-read after, or prefer `sx_write_file` for small
|
||||||
|
files. **Default to `sx_write_file` (rewrite the whole file) over path/pattern
|
||||||
|
edits** — these are small files and the rewrite always parses-before-writing.
|
||||||
|
`sx_insert_near` inserts only the FIRST top-level form of a multi-form source
|
||||||
|
(it silently drops the rest; byte count barely moves) — never use it to add a
|
||||||
|
block of forms; rewrite the file instead. `sx_replace_by_pattern` is fiddly to
|
||||||
|
match — don't fight it, just rewrite.
|
||||||
|
- **Unicode in `.sx`:** raw UTF-8 only, never `\uXXXX` escapes.
|
||||||
|
- **Commit granularity:** one feature per commit. Short factual messages
|
||||||
|
(`mod: spam-keyword policy rule → :hide + 6 tests`). Push to `origin/loops/mod`.
|
||||||
|
- **Plan file:** update Progress log (newest first) + tick boxes every commit.
|
||||||
|
|
||||||
|
## mod-specific gotchas
|
||||||
|
|
||||||
|
- **Decisions are proofs, not booleans.** A decision should carry *why* — the
|
||||||
|
matching rule / derivation — so Phase 2's audit trail can persist it. Design the
|
||||||
|
Phase-1 `decide` return shape with that in mind (don't return a bare keyword you
|
||||||
|
later have to retrofit).
|
||||||
|
- **Policy chains backtrack.** Order matters: first matching rule wins. Make rule
|
||||||
|
precedence explicit and deterministic (tests will depend on it). A "no rule
|
||||||
|
matched" outcome must be a real, testable result (`:keep`), not a query failure
|
||||||
|
you forget to handle.
|
||||||
|
- **You may lean on backtracking and cut.** The substrate is full Prolog —
|
||||||
|
`pl-query-all` gives every proven clause (use it for "strictest-wins" or
|
||||||
|
multi-match analysis), `pl-query-one` gives the first (clause order = precedence).
|
||||||
|
Cut (`!`) and the other control constructs are available if you need to prune
|
||||||
|
alternatives inside a body, but for rule precedence prefer plain clause ordering
|
||||||
|
resolved by `pl-query-one` — it's the clean, testable default. Don't hand-roll
|
||||||
|
precedence in SX when the engine's backtracking already gives it to you.
|
||||||
|
- **Negative decisions need closed-world care.** "No evidence of violation" vs
|
||||||
|
"evidence absent" differ. Be explicit about negation-as-failure where you use it.
|
||||||
|
In this substrate, negation is the **functor** `not(Goal)` / `\+(Goal)` — the
|
||||||
|
prefix `\+ Goal` operator does **not** parse. Unknown predicates *fail* (no
|
||||||
|
existence error), so a report lacking some fact safely falls through a rule that
|
||||||
|
references it. Quote user-data atoms (`'foo-bar'`) — a bare hyphen is the minus
|
||||||
|
operator and will misparse.
|
||||||
|
- **Loaded-env strips the high-level string prims.** After the prolog preloads are
|
||||||
|
loaded, the eval env loses `includes?`, `chars`, `str-join`, `keyword` and
|
||||||
|
friends — they are **undefined** (a function calling one fails only when called,
|
||||||
|
often mid-test-load, looking like a mystery crash). Only the set the Prolog
|
||||||
|
tokenizer itself uses survives: `slice`, `len`, `nth`, `=`, `join` (sep first:
|
||||||
|
`(join sep list)`), `downcase`, `map`, `reduce`, `append`/`append!`, `when`,
|
||||||
|
`cond`, `if`, `let`, `begin`, `get`, `dict-get`, `keys`, `empty?`, `first`,
|
||||||
|
`reverse`, `+`, `-`, `<`, `<=`. Build substring search yourself over `slice`/
|
||||||
|
`len` (see `mod/str-contains?`). Treat `not`, `and`, `or`, `>` as suspect in
|
||||||
|
guest code unless you've confirmed them — nest `if`/`when` and use `(< a b)`.
|
||||||
|
- **Lifecycle state is separate from policy.** Keep the state machine (Phase 3) as
|
||||||
|
an SX module over the engine, not tangled into Prolog rules.
|
||||||
|
- **Federation trust is advisory by default.** A peer's decision only binds locally
|
||||||
|
when `(trust peer :mod)` holds; otherwise it's a suggestion. Don't auto-apply.
|
||||||
|
|
||||||
|
## General gotchas (all loops)
|
||||||
|
|
||||||
|
- SX `do` = R7RS iteration. Use `begin` for multi-expr sequences.
|
||||||
|
- `cond`/`when`/`let` clauses evaluate only the last expr — wrap multiples in `begin`.
|
||||||
|
- `let` is parallel, not sequential — nest `let`s when a binding references an earlier one.
|
||||||
|
- `env-bind!` creates a binding; `env-set!` mutates an existing one (walks scope chain).
|
||||||
|
- `sx_validate` after every structural edit.
|
||||||
|
- Namespace-prefix all guest helpers (`mod/...`) — short/host-colliding names
|
||||||
|
(`bind`, `conj`, `name`) get silently shadowed or hang the runtime.
|
||||||
|
|
||||||
|
## Style
|
||||||
|
|
||||||
|
- No comments in `.sx` unless non-obvious.
|
||||||
|
- No new planning docs — update `plans/mod-on-sx.md` inline.
|
||||||
|
- Short, factual commit messages.
|
||||||
|
- One feature per iteration. Commit. Log. Push. Next.
|
||||||
|
|
||||||
|
Go. Start by reading the plan; find the first unchecked `[ ]`; implement it.
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
# abstraction-radar loop agent (read-only scout)
|
|
||||||
|
|
||||||
Role: continuously scan **all** rose-ash subsystems for genuine abstraction /
|
|
||||||
deduplication opportunities and maintain a ranked, evidence-backed backlog at
|
|
||||||
`plans/abstractions.md`. You are a **scout, not an implementer** — you detect and
|
|
||||||
document; you never refactor across subsystems.
|
|
||||||
|
|
||||||
```
|
|
||||||
description: abstraction-radar (read-only scout)
|
|
||||||
subagent_type: general-purpose
|
|
||||||
run_in_background: true
|
|
||||||
isolation: worktree
|
|
||||||
```
|
|
||||||
|
|
||||||
## Prompt
|
|
||||||
|
|
||||||
You are the sole background agent on branch `loops/radar`, worktree
|
|
||||||
`/root/rose-ash-loops/radar`, forever. Self-paced. Your ONLY writes are to
|
|
||||||
`plans/abstractions.md` (and, rarely, refining this briefing). Push to
|
|
||||||
`origin/loops/radar` after each update. Never touch `main` or `architecture`.
|
|
||||||
|
|
||||||
## The one hard rule: you do NOT edit `lib/**` — ever
|
|
||||||
|
|
||||||
You read across every subsystem and write findings to `plans/abstractions.md`.
|
|
||||||
You do **not** implement abstractions, migrate code, or edit any `lib/<x>/**`
|
|
||||||
file in any worktree. Implementation is a separate, coordinated, human-triggered
|
|
||||||
step — proposing well is your whole job. An abstractor that writes across
|
|
||||||
subsystems would collide with the very isolation that keeps the other loops safe;
|
|
||||||
that is exactly why you are read-only.
|
|
||||||
|
|
||||||
## Dynamic discovery — re-enumerate every iteration, never hardcode
|
|
||||||
|
|
||||||
The set of subsystems grows as new loops are spawned. Each iteration, rebuild the
|
|
||||||
list from the filesystem + tmux so newly-added subsystems are automatically in
|
|
||||||
scope:
|
|
||||||
|
|
||||||
1. `ls -d /root/rose-ash-loops/*/` — every loop worktree. For a worktree named `X`,
|
|
||||||
its in-flight subsystem is `lib/X/` **inside that worktree**
|
|
||||||
(`/root/rose-ash-loops/X/lib/X/`) — that's the current, possibly-uncommitted
|
|
||||||
state. Read it there, not from your own worktree.
|
|
||||||
2. `ls -d /root/rose-ash/lib/*/` — subsystems merged into / dormant on the main repo
|
|
||||||
(e.g. `feed` once merged, the language substrates `apl`/`haskell`/`prolog`/…).
|
|
||||||
3. `tmux ls` — which subsystems are actively looping right now (affects whether a
|
|
||||||
candidate's consumers are "stable" — see the gate).
|
|
||||||
|
|
||||||
Treat the union as your scan surface. When a `commerce` or `identity` loop appears
|
|
||||||
later, step 1 picks it up with no change to you. Note in `abstractions.md` the
|
|
||||||
date and the subsystem set you scanned, so drift is visible.
|
|
||||||
|
|
||||||
## The AHA gate — before ANY candidate goes in the backlog as "proposed"
|
|
||||||
|
|
||||||
"Avoid Hasty Abstractions." A wrong shared abstraction is far costlier than the
|
|
||||||
duplication it replaces. A candidate may be listed as **proposed** only if ALL hold:
|
|
||||||
|
|
||||||
- **≥3 real consumers** (not 2 — three independent uses). Fewer → log it under
|
|
||||||
"Watching" with its consumer count, do not propose.
|
|
||||||
- **All consumers past Phase 2 and API-stable.** If a consumer's loop is mid-flight
|
|
||||||
and its interfaces are still moving (`tmux ls` shows it active + its plan has
|
|
||||||
unchecked early-phase boxes), the pattern is a moving target → "Watching."
|
|
||||||
- **Structurally identical, not superficially similar.** Show the shared shape with
|
|
||||||
file:line evidence from each consumer. Coincidental resemblance is the #1 trap.
|
|
||||||
- **It has a natural home.** And that home is usually **not** `lib/guest` — see the
|
|
||||||
routing rule below.
|
|
||||||
|
|
||||||
Anything failing a gate goes under **Watching** (with what's missing) or
|
|
||||||
**Rejected** (with why), never silently dropped — so it isn't re-proposed each pass.
|
|
||||||
|
|
||||||
## Routing rule — most patterns do NOT belong in lib/guest
|
|
||||||
|
|
||||||
`lib/guest` is for **language-implementation plumbing** (lexer/parser/AST/HM/match/
|
|
||||||
test-runner), and it has its own consumer-gated roadmap. App-subsystem patterns
|
|
||||||
almost always have a better home — route, don't dump:
|
|
||||||
|
|
||||||
| Pattern kind | Home (not lib/guest) |
|
|
||||||
|---|---|
|
|
||||||
| per-viewer visibility / permission filter | `acl-on-sx` (delegate to `permit?`) |
|
|
||||||
| federation scaffold (merge/ingest/backfill/trust) | `fed-sx` |
|
|
||||||
| durable store / event log / kv | `persist-on-sx` |
|
|
||||||
| collection math (group-by, dedupe, stable top-N) | the substrate (APL/Haskell/…) |
|
|
||||||
| HTTP/handler/middleware plumbing | `host-on-sx` |
|
|
||||||
| conformance/test harness | `lib/guest` (the one real exception — `test-runner.sx` + the shared driver live there) |
|
|
||||||
|
|
||||||
If a pattern's home is one of the subsystems, the recommended **action** is "adopt
|
|
||||||
/ delegate there," and the work belongs to that subsystem's own loop (in its
|
|
||||||
scope), not to a cross-cutting change.
|
|
||||||
|
|
||||||
## Each iteration
|
|
||||||
|
|
||||||
1. Re-discover the subsystem set (above). Record it + the date in `abstractions.md`.
|
|
||||||
2. Pick ONE thread: either deep-dive a "Watching" candidate to gather file:line
|
|
||||||
evidence and re-test its gates, or sweep for a new recurring shape across the
|
|
||||||
current set.
|
|
||||||
3. Update `plans/abstractions.md`: move items between Watching / Proposed /
|
|
||||||
In-progress (owned by a subsystem loop) / Done / Rejected, with evidence.
|
|
||||||
4. Keep it ranked by (consumers × effort-saved ÷ risk). Short, factual.
|
|
||||||
5. Commit (`radar: <one-line finding>`) and push to `origin/loops/radar`.
|
|
||||||
|
|
||||||
Do not invent work to look busy: if a pass finds nothing that clears the gate,
|
|
||||||
record "scanned N subsystems on <date>, no new candidates cleared the gate" and
|
|
||||||
stop until next iteration. Empty passes are a valid, honest result.
|
|
||||||
|
|
||||||
## Gotchas
|
|
||||||
|
|
||||||
- SX files: `sx-tree` MCP tools take `file:` not `path:`. But you mostly READ —
|
|
||||||
prefer `sx_find_across`, `sx_comp_usage`, `sx_comp_list`, `sx_summarise`, plus
|
|
||||||
`Grep`/`Glob`/`Bash` for cross-worktree scanning.
|
|
||||||
- `plans/abstractions.md` is a `.md` — edit it with normal Write/Edit, not sx-tree.
|
|
||||||
- Never run `sx_build`. You don't build anything; you read.
|
|
||||||
|
|
||||||
## Style
|
|
||||||
|
|
||||||
- Evidence over assertion: every claim cites file:line in ≥3 consumers.
|
|
||||||
- Honest empty passes. Rejected items stay rejected with a reason.
|
|
||||||
- One finding per commit. Update. Push. Next.
|
|
||||||
|
|
||||||
Go. Read `plans/abstractions.md` (seeded), re-discover the subsystem set, and
|
|
||||||
advance the highest-value thread.
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
# commerce-on-sx: Catalog, cart, pricing & orders on miniKanren
|
|
||||||
|
|
||||||
> **DRAFT outline.** The revenue vertical. Depends on `persist-on-sx` (durable
|
|
||||||
> orders) and `flow-on-sx` (checkout as a durable flow). Don't start before
|
|
||||||
> persist-on-sx Phase 1 is green.
|
|
||||||
|
|
||||||
rose-ash's revenue engine — market (catalog), cart (checkout), orders (SumUp
|
|
||||||
payment, reconciliation) — has no SX subsystem. The hard part of commerce isn't
|
|
||||||
CRUD; it's **pricing**: discounts, bundles, tax, membership rates, promotions that
|
|
||||||
stack (or don't). These are relations, and a relational engine can run them in
|
|
||||||
multiple directions — forward ("what's the total?") and backward ("what promo code
|
|
||||||
yields this total?", "which line item triggered the discount?").
|
|
||||||
|
|
||||||
That's a miniKanren fit. Pricing/promotion rules are relational; cart and order
|
|
||||||
*lifecycle* (reserve → pay → fulfil → reconcile) is a durable `flow`; the order
|
|
||||||
ledger is a `persist` stream. Commerce is the first real **composition** subsystem.
|
|
||||||
|
|
||||||
End-state: a catalog model, a relational pricing/promotion engine, a cart with
|
|
||||||
deterministic totals, and an order lifecycle flow with payment-webhook
|
|
||||||
reconciliation — all auditable via the event log.
|
|
||||||
|
|
||||||
## Status (rolling)
|
|
||||||
|
|
||||||
`bash lib/commerce/conformance.sh` → **0/0** (not yet started)
|
|
||||||
|
|
||||||
## Ground rules
|
|
||||||
|
|
||||||
- **Scope:** only `lib/commerce/**` and `plans/commerce-on-sx.md`. May **import**
|
|
||||||
from `lib/minikanren/`, and (once they exist) `lib/persist/` + `lib/flow/`. Do not
|
|
||||||
edit substrates.
|
|
||||||
- **Architecture:** prices/promotions are miniKanren relations over catalog facts;
|
|
||||||
a cart total is a *deterministic* query result (first solution under a fixed rule
|
|
||||||
order). Order lifecycle is a `flow` that suspends at the payment IO boundary.
|
|
||||||
Money is integer minor units — never floats.
|
|
||||||
- **Determinism:** promotion stacking must have explicit, tested precedence;
|
|
||||||
totals must be reproducible from the cart + catalog snapshot.
|
|
||||||
- **Commits:** one feature per commit. Progress log + tick boxes.
|
|
||||||
|
|
||||||
## Architecture sketch
|
|
||||||
|
|
||||||
```
|
|
||||||
Catalog + cart Total / order
|
|
||||||
product(id,price,tags) {:subtotal :discounts :tax :total}
|
|
||||||
│ ▲
|
|
||||||
▼ │
|
|
||||||
lib/commerce/catalog.sx lib/commerce/price.sx
|
|
||||||
— product / variant / stock facts — miniKanren pricing relations
|
|
||||||
│ — promo stacking, membership rates
|
|
||||||
▼ ▲
|
|
||||||
lib/commerce/cart.sx lib/commerce/order.sx (flow + store)
|
|
||||||
— line items, quantities — reserve→pay→fulfil→reconcile
|
|
||||||
│ — SumUp webhook = flow resume
|
|
||||||
▼ │
|
|
||||||
lib/commerce/api.sx ── (commerce/add) (commerce/total) (commerce/checkout) ──┘
|
|
||||||
```
|
|
||||||
|
|
||||||
## Phase 1 — Catalog + cart + deterministic totals
|
|
||||||
- [ ] `catalog.sx` — product/variant/stock as facts
|
|
||||||
- [ ] `cart.sx` — line items, add/remove/qty
|
|
||||||
- [ ] `price.sx` — base pricing relation, subtotal; tax
|
|
||||||
- [ ] `api.sx` + tests + scoreboard + conformance.sh
|
|
||||||
|
|
||||||
## Phase 2 — Promotions (relational)
|
|
||||||
- [ ] promo rules: percentage, fixed, bundle, member rate
|
|
||||||
- [ ] explicit stacking precedence; "best price" backward query
|
|
||||||
- [ ] tests: stacking order, mutually-exclusive promos, member vs guest
|
|
||||||
|
|
||||||
## Phase 3 — Order lifecycle (flow + store)
|
|
||||||
- [ ] order flow: reserve stock → await payment → fulfil
|
|
||||||
- [ ] payment webhook resumes the suspended flow
|
|
||||||
- [ ] order ledger as a `persist` stream; idempotent reconciliation
|
|
||||||
|
|
||||||
## Phase 4 — Reconciliation + federation
|
|
||||||
- [ ] mismatch detection (paid≠ordered) as queries over the ledger
|
|
||||||
- [ ] cross-instance catalog (federated marketplace) — out-of-scope stub
|
|
||||||
- [ ] tests: webhook replay, partial refund, double-charge guard
|
|
||||||
|
|
||||||
## Progress log
|
|
||||||
(loop fills this in)
|
|
||||||
|
|
||||||
## Blockers
|
|
||||||
(loop fills this in)
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
# content-on-sx: Documents, blocks & collaborative editing on Smalltalk
|
|
||||||
|
|
||||||
> **DRAFT outline.** The CMS vertical — blog, WYSIWYG editor, Ghost sync. Depends
|
|
||||||
> on `persist-on-sx` (document history as an event log). Ghost/CMS sync stays a thin
|
|
||||||
> external adapter (Python/FFI) until a native replacement exists.
|
|
||||||
|
|
||||||
rose-ash's `blog` domain is content management: a block-based WYSIWYG editor,
|
|
||||||
navigation, Ghost CMS sync. A document is a tree of live blocks; editing is a
|
|
||||||
stream of operations; collaboration needs conflict-free merge. That is an object
|
|
||||||
model — blocks are objects, edits are messages, and a document is the object graph
|
|
||||||
responding to them. Smalltalk's "everything is an object responding to messages"
|
|
||||||
maps directly to a block/WYSIWYG model, and a semilattice (CRDT) merge keeps
|
|
||||||
concurrent edits conflict-free.
|
|
||||||
|
|
||||||
End-state: a Smalltalk-on-SX document model (typed blocks, structural ops),
|
|
||||||
operation log + CRDT merge for collaborative editing, versioning/history via the
|
|
||||||
event store, and a render boundary to HTML/SX. External CMS (Ghost) sync is an
|
|
||||||
injected adapter, not core.
|
|
||||||
|
|
||||||
## Status (rolling)
|
|
||||||
|
|
||||||
`bash lib/content/conformance.sh` → **0/0** (not yet started)
|
|
||||||
|
|
||||||
## Ground rules
|
|
||||||
|
|
||||||
- **Scope:** only `lib/content/**` and `plans/content-on-sx.md`. May **import**
|
|
||||||
from `lib/smalltalk/`, and (once it exists) `lib/persist/`. Do not edit substrates.
|
|
||||||
- **Architecture:** a document is an ordered tree of blocks (objects); an edit is a
|
|
||||||
message (`insert`/`update`/`move`/`delete`); concurrent edits merge via a
|
|
||||||
commutative (CRDT/semilattice) operation so order doesn't matter. History is the
|
|
||||||
`persist` event stream; any version is a replay.
|
|
||||||
- **Determinism:** merge must be commutative + idempotent (test: apply ops in any
|
|
||||||
order / twice → same document).
|
|
||||||
- **Commits:** one feature per commit. Progress log + tick boxes.
|
|
||||||
|
|
||||||
## Architecture sketch
|
|
||||||
|
|
||||||
```
|
|
||||||
Edit op Rendered document
|
|
||||||
(insert block after id) ... HTML / SX tree
|
|
||||||
│ ▲
|
|
||||||
▼ │
|
|
||||||
lib/content/block.sx lib/content/render.sx
|
|
||||||
— typed blocks as objects — block tree → HTML/SX
|
|
||||||
— heading/text/image/embed — (reuses SX render boundary)
|
|
||||||
│ ▲
|
|
||||||
▼ │
|
|
||||||
lib/content/doc.sx lib/content/merge.sx
|
|
||||||
— ordered block tree — CRDT/semilattice op merge
|
|
||||||
— apply op, structural moves — concurrent-edit reconciliation
|
|
||||||
│ ▲
|
|
||||||
▼ │
|
|
||||||
lib/content/api.sx ── (content/edit) (content/render) (content/history) ──┐
|
|
||||||
│ │
|
|
||||||
├── op log + versions → persist │
|
|
||||||
└── Ghost/CMS sync → injected external adapter (thin, non-core) ──┘
|
|
||||||
```
|
|
||||||
|
|
||||||
## Phase 1 — Block document model
|
|
||||||
- [ ] `block.sx` — typed block objects
|
|
||||||
- [ ] `doc.sx` — ordered tree, apply edit op, structural moves
|
|
||||||
- [ ] `render.sx` — block tree → HTML/SX
|
|
||||||
- [ ] `api.sx` + tests + scoreboard + conformance.sh
|
|
||||||
|
|
||||||
## Phase 2 — Op log + versioning
|
|
||||||
- [ ] edit ops as `persist` events; replay to any version
|
|
||||||
- [ ] `(content/history doc)`, diff between versions
|
|
||||||
|
|
||||||
## Phase 3 — Collaborative merge (CRDT)
|
|
||||||
- [ ] commutative/idempotent op merge
|
|
||||||
- [ ] concurrent-edit tests (any order, double-apply → identical)
|
|
||||||
|
|
||||||
## Phase 4 — External sync + federation
|
|
||||||
- [ ] Ghost/CMS sync via injected adapter (import/export)
|
|
||||||
- [ ] federated documents (peer-authored blocks) — trust-gated stub
|
|
||||||
- [ ] tests: round-trip import/export, conflict on concurrent external edit
|
|
||||||
|
|
||||||
## Progress log
|
|
||||||
(loop fills this in)
|
|
||||||
|
|
||||||
## Blockers
|
|
||||||
(loop fills this in)
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
# events-on-sx: Calendar, ticketing & notification delivery on Datalog
|
|
||||||
|
|
||||||
> **DRAFT outline.** The events vertical + the shared notification-delivery edge.
|
|
||||||
> Depends on `persist-on-sx` (bookings ledger) and `flow-on-sx` (reminders, retrying
|
|
||||||
> delivery). Pairs with `commerce-on-sx` for paid tickets.
|
|
||||||
|
|
||||||
rose-ash's `events` domain is calendar + ticketing: recurring events, availability,
|
|
||||||
capacity, bookings. Scheduling is constraint reasoning — "is this slot free given
|
|
||||||
recurrence, capacity, and the attendee's other bookings?" — which is rule
|
|
||||||
evaluation over facts. Datalog expresses availability, recurrence expansion, and
|
|
||||||
capacity as rules; a booking is a transaction; reminders and digests are durable
|
|
||||||
`flow`s. Notification *delivery* (email/push) — needed here and by `feed/notify` —
|
|
||||||
is folded in as an injected transport, extractable later.
|
|
||||||
|
|
||||||
End-state: a Datalog-on-SX events layer with recurrence expansion, availability +
|
|
||||||
capacity rules, transactional booking, and a flow-driven notification dispatcher
|
|
||||||
(reminders, digests, retries) over an injected transport.
|
|
||||||
|
|
||||||
## Status (rolling)
|
|
||||||
|
|
||||||
`bash lib/events/conformance.sh` → **0/0** (not yet started)
|
|
||||||
|
|
||||||
## Ground rules
|
|
||||||
|
|
||||||
- **Scope:** only `lib/events/**` and `plans/events-on-sx.md`. May **import** from
|
|
||||||
`lib/datalog/`, and (once they exist) `lib/persist/` + `lib/flow/`. Do not edit
|
|
||||||
substrates.
|
|
||||||
- **Architecture:** events/availability/capacity are Datalog facts + rules;
|
|
||||||
recurrence expands to occurrence facts within a window; a booking checks rules
|
|
||||||
then appends a `persist` event (idempotent, capacity-safe). Notifications are flows
|
|
||||||
that suspend on transport IO and retry on failure.
|
|
||||||
- **Determinism:** recurrence expansion + availability must be reproducible for a
|
|
||||||
fixed window + ruleset; capacity checks must be race-safe (no overbooking).
|
|
||||||
- **Commits:** one feature per commit. Progress log + tick boxes.
|
|
||||||
|
|
||||||
## Architecture sketch
|
|
||||||
|
|
||||||
```
|
|
||||||
Event + booking Result
|
|
||||||
event(id,start,rrule,capacity) {:booked | :full | :conflict} + reminders
|
|
||||||
│ ▲
|
|
||||||
▼ │
|
|
||||||
lib/events/calendar.sx lib/events/availability.sx
|
|
||||||
— event facts, recurrence (RRULE) — free/busy + capacity rules (Datalog)
|
|
||||||
— expand occurrences in window │
|
|
||||||
│ ▲
|
|
||||||
▼ │
|
|
||||||
lib/events/booking.sx lib/events/notify.sx (flow)
|
|
||||||
— transactional, capacity-safe — reminders / digests, retry on fail
|
|
||||||
— bookings → persist ledger — injected transport (email/push)
|
|
||||||
│ │
|
|
||||||
▼ ▼
|
|
||||||
lib/events/api.sx ── (events/schedule) (events/book) (events/agenda) ──────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
## Phase 1 — Calendar + recurrence
|
|
||||||
- [ ] `calendar.sx` — event facts, RRULE expansion in a window
|
|
||||||
- [ ] `availability.sx` — free/busy rules
|
|
||||||
- [ ] `api.sx` + tests + scoreboard + conformance.sh
|
|
||||||
|
|
||||||
## Phase 2 — Ticketing + booking
|
|
||||||
- [ ] capacity rules; transactional booking → `persist` (no overbooking)
|
|
||||||
- [ ] paid tickets compose with `commerce` order flow
|
|
||||||
- [ ] tests: capacity edge, double-book guard, conflict detection
|
|
||||||
|
|
||||||
## Phase 3 — Notification delivery (flow)
|
|
||||||
- [ ] `notify.sx` — reminder/digest flows over injected transport
|
|
||||||
- [ ] retry/backoff on transport failure (flow suspend/resume)
|
|
||||||
- [ ] tests: delivery success, retry path, idempotent re-send
|
|
||||||
- [ ] NOTE: shared with `feed/notify` — candidate for later extraction to a
|
|
||||||
`delivery-on-sx` once a second consumer is real
|
|
||||||
|
|
||||||
## Phase 4 — Federation
|
|
||||||
- [ ] cross-instance events (peer calendar) — trust-gated stub
|
|
||||||
- [ ] tests: federated agenda merge
|
|
||||||
|
|
||||||
## Progress log
|
|
||||||
(loop fills this in)
|
|
||||||
|
|
||||||
## Blockers
|
|
||||||
(loop fills this in)
|
|
||||||
@@ -14,7 +14,7 @@ APL, ACL visibility filtering via `lib/acl/`, federation via fed-sx.
|
|||||||
|
|
||||||
## Status (rolling)
|
## Status (rolling)
|
||||||
|
|
||||||
`bash lib/feed/conformance.sh` → **189/189** (Phases 1–4 + TF-IDF, notifications, home, smart-dedupe, trending, mute, pagination, threading)
|
`bash lib/feed/conformance.sh` → **0/0** (not yet started)
|
||||||
|
|
||||||
## Ground rules
|
## Ground rules
|
||||||
|
|
||||||
@@ -59,118 +59,47 @@ lib/feed/api.sx lib/feed/fed.sx
|
|||||||
|
|
||||||
## Phase 1 — Stream model + basic ops
|
## Phase 1 — Stream model + basic ops
|
||||||
|
|
||||||
- [x] `lib/feed/normalize.sx` — activity record schema; coerce arbitrary inputs
|
- [ ] `lib/feed/normalize.sx` — activity record schema; coerce arbitrary inputs
|
||||||
- [x] `lib/feed/stream.sx` — APL vector representation; filter by predicate; sort by
|
- [ ] `lib/feed/stream.sx` — APL vector representation; filter by predicate; sort by
|
||||||
`:at`; take N (`↑`); reverse (`⌽`)
|
`:at`; take N (`↑`); reverse (`⌽`)
|
||||||
- [x] `lib/feed/api.sx` — `(feed/post activity)`, `(feed/all)`
|
- [ ] `lib/feed/api.sx` — `(feed/post activity)`, `(feed/all)`
|
||||||
- [x] `lib/feed/tests/basic.sx` — 30 cases: normalize defaults, filter, sort, take, api
|
- [ ] `lib/feed/tests/basic.sx` — 15+ cases: post, query, filter, sort
|
||||||
- [x] `lib/feed/scoreboard.{json,md}`
|
- [ ] `lib/feed/scoreboard.{json,md}`
|
||||||
- [x] `lib/feed/conformance.sh`
|
- [ ] `lib/feed/conformance.sh`
|
||||||
|
|
||||||
## Phase 2 — Fanout via outer product
|
## Phase 2 — Fanout via outer product
|
||||||
|
|
||||||
- [x] follower graph: `followers user → vector of user ids` (`feed/follow-graph`,
|
- [ ] follower graph: `followers user → vector of user ids`
|
||||||
`feed/followers`; graph = `{followee -> (followers)}` dict)
|
- [ ] fanout: activities `∘.×` followers → matrix `(activity, follower)` pairs
|
||||||
- [x] fanout: activities `∘.×` audience → matrix via `apl-outer feed/-mk-event`
|
- [ ] flatten to inbox events vector
|
||||||
- [x] flatten to inbox events vector (`feed/-flatten` rank-2 → rank-1)
|
- [ ] dedupe — group by `(actor, verb, object)` collapse to one inbox event per
|
||||||
- [x] dedupe — `feed/dedupe-inbox` by `(to, actor, verb, object)`; also
|
receiver
|
||||||
`feed/dedupe-activities` `(actor verb object)` and `feed/dedupe-collapse`
|
- [ ] `lib/feed/tests/fanout.sx` — 20+ cases: small graph, mutual follow, popular
|
||||||
`(verb object)` for cross-actor likes
|
actor (high-fanout), cross-post dedupe
|
||||||
- [x] `lib/feed/tests/fanout.sx` — 29 cases: small graph, mutual follow, star
|
|
||||||
(high-fanout), empty graph, unfollowed actor, cross-post dedupe
|
|
||||||
|
|
||||||
## Phase 3 — Aggregation + ranking
|
## Phase 3 — Aggregation + ranking
|
||||||
|
|
||||||
- [x] group-by — `feed/group-by`/`feed/group-count` key-reduce; `feed/by-actor-day`
|
- [ ] group-by — `(actor, day) → count` via key-reduce
|
||||||
buckets `(actor, day)` via `feed/day` (string-joined keys)
|
- [ ] velocity score — recent activity count over window
|
||||||
- [x] velocity score — `feed/velocity` counts actor's activities in `(at-window, at]`
|
- [ ] recency score — decay by age
|
||||||
- [x] recency score — `feed/recency` half-life decay `0.5^(age/hl)`
|
- [ ] composite rank — weighted sum of components
|
||||||
- [x] composite rank — `feed/composite` weighted sum of `(weight scorer)` parts
|
- [ ] top-N per timeline
|
||||||
- [x] top-N per timeline — `feed/top` = rank then take
|
- [ ] `lib/feed/tests/rank.sx` — 20+ cases: ranking stable on tie, decay shape,
|
||||||
- [x] `lib/feed/tests/rank.sx` — 24 cases: decay shape, velocity burst, stable
|
per-user weighting
|
||||||
tie-break, top-N, composite
|
|
||||||
|
|
||||||
## Phase 4 — Visibility filter + federation
|
## Phase 4 — Visibility filter + federation
|
||||||
|
|
||||||
`lib/acl/` and fed-sx don't exist yet and are out of scope (import `lib/apl/`
|
- [ ] ACL filter — each candidate activity passed through `(acl/permit? viewer :read
|
||||||
only), so ACL/transport are injected: `permit?`, `remote?`, `send-fn`, `fetch-fn`
|
activity)`
|
||||||
are function parameters. Real acl-sx / fed-sx wire in at the call site unchanged.
|
- [ ] fed-sx outbound — local `feed/post` fans out to remote followers' inboxes
|
||||||
|
- [ ] fed-sx inbound — peer activities arrive at local inbox
|
||||||
- [x] ACL filter — `feed/visible stream viewer permit?`; default `feed/permit-acl?`
|
- [ ] backfill on subscribe — request peer history, merge into local stream
|
||||||
reads `:visible-to` allowlist (+ author-sees-own); per-viewer, never cached
|
- [ ] `lib/feed/tests/integration.sx` — federated timeline with ACL applied
|
||||||
- [x] fed-sx outbound — `feed/federate`/`feed/deliver` fan out then partition
|
|
||||||
local vs remote inboxes; remote events handed to injected `send-fn`
|
|
||||||
- [x] fed-sx inbound — `feed/inbound` normalizes + `feed/ingest` dedupes peer
|
|
||||||
activities into the local stream
|
|
||||||
- [x] backfill on subscribe — `feed/backfill local fetch-fn peer-id`
|
|
||||||
- [x] `lib/feed/tests/integration.sx` — 22 cases incl. end-to-end
|
|
||||||
`feed/timeline` (federated → ACL for viewer → recency rank → top-N)
|
|
||||||
|
|
||||||
## Progress log
|
## Progress log
|
||||||
|
|
||||||
- **Phase 1 done (30/30).** Stream = APL rank-1 array whose ravel holds activity
|
(loop fills this in)
|
||||||
dicts. `normalize.sx` (record schema + accessors), `stream.sx` (filter via `/`
|
|
||||||
compress, sort via `⍋` grade-up [stable], take via `↑`, reverse via `⌽`,
|
|
||||||
by-actor/verb/object/since predicates), `api.sx` (mutable log: post/all/reset!/size).
|
|
||||||
Substrate: `apl-compress`, `apl-grade-up`, `apl-take`, `apl-reverse`, `make-array`.
|
|
||||||
Grade-up returns 1-based indices (⎕IO=1), is stable on ties → deterministic sort.
|
|
||||||
- **Phase 2 done (59/59 total).** `fanout.sx` (graph + `apl-outer` showcase),
|
|
||||||
`dedupe.sx` (per-key dedupe, first-wins stable). Key APL gotcha: `scalar?` is
|
|
||||||
true for ANY dict and `disclose` nils a non-array dict, so an apl-outer combiner
|
|
||||||
MUST `enclose` its event dict — apl-outer discloses it back intact. `apl-unique`
|
|
||||||
preserves first-occurrence order; dict `keys` order is NOT stable, so
|
|
||||||
`feed/audience` sorts (else recipient ordering flakes). `apl-compress` needs a
|
|
||||||
rank-1 array, so the (activity×follower) matrix is flattened to its ravel before
|
|
||||||
the edge-guard filter.
|
|
||||||
- **Phase 3 done (83/83 total).** `aggregate.sx` (group-by/count, day buckets) +
|
|
||||||
`rank.sx` (recency/velocity/engagement scorers, composite, top-N). `sort` is
|
|
||||||
single-arg ascending only — no comparator — so ranking uses a stable two-pass
|
|
||||||
`apl-grade-down` (by :at desc, then by score desc) for deterministic tie-breaks.
|
|
||||||
Dict keys must be strings, so composite group keys are string-joined ("actor#day").
|
|
||||||
- **Phase 4 done (105/105 total).** `acl.sx` (per-viewer `feed/visible`,
|
|
||||||
`feed/timeline` capstone) + `fed.sx` (merge/ingest/inbound/backfill/federate/
|
|
||||||
deliver). ACL/transport are dependency-injected (permit?/remote?/send-fn/fetch-fn)
|
|
||||||
since lib/acl + fed-sx don't exist. `feed/normalize` now MERGEs defaults over the
|
|
||||||
raw dict (was projecting to 5 keys) so extra metadata (:visible-to, peer fields)
|
|
||||||
survives — matches the "flexible bag" principle.
|
|
||||||
|
|
||||||
## Roadmap is complete (all 4 phases). Possible follow-ups:
|
## Blockers
|
||||||
|
|
||||||
- Wire real acl-sx once `lib/acl/` exists (swap injected `permit?`).
|
(loop fills this in)
|
||||||
- Wire real fed-sx transport (swap `send-fn`/`fetch-fn`).
|
|
||||||
- [x] TF-IDF over `:tags` for content ranking — `content.sx`: `feed/tag-df`,
|
|
||||||
`feed/tag-idf` (log N/df), `feed/tfidf-score`, `feed/by-relevance`; 15 tests.
|
|
||||||
Composes as a scorer with rank.sx. (120/120 total.)
|
|
||||||
- [x] Notification feed (verb-filtered, per-recipient) — `notify.sx`:
|
|
||||||
`feed/notifications`, `feed/notify-verbs`, `feed/notify-digest` (collapses
|
|
||||||
"X, Y liked Z" by (verb,object), sorted-deterministic); 8 tests. (128/128 total.)
|
|
||||||
- [x] **Capstone** `feed/home` — the whole pipeline as one line: fanout ∘ inbox ∘
|
|
||||||
dedupe ∘ ACL ∘ rank ∘ take (`home.sx`); 6 tests incl. per-viewer ACL + cross-post
|
|
||||||
dedupe. (134/134 total.)
|
|
||||||
- [x] Per-verb dedupe rules (briefing gotcha #3) — `feed/dedupe-smart` /
|
|
||||||
`feed/smart-key`: reactions (like/follow/boost/...) collapse cross-actor on
|
|
||||||
(verb,object); posts stay distinct per actor. `feed/collapse-verbs` is
|
|
||||||
rebindable policy; 9 tests. (143/143 total.)
|
|
||||||
- [x] Trending — `feed/trending` / `feed/trending-actors`: objects/actors ranked
|
|
||||||
by activity count in a recency window, count-desc with key-asc tiebreak
|
|
||||||
(`trending.sx`); 11 tests. (154/154 total.)
|
|
||||||
- [x] Mute/block — `feed/mute-actors` / `feed/mute-tags` / `feed/mute-objects` /
|
|
||||||
`feed/apply-prefs`: viewer-controlled per-request filtering (complements ACL's
|
|
||||||
author-controlled visibility) (`mute.sx`); 9 tests. (163/163 total.)
|
|
||||||
- [x] Pagination — `feed/page`/`feed/page-count` (offset) + `feed/before`/
|
|
||||||
`feed/after`/`feed/page-before`/`feed/next-cursor` (cursor by :at, stable under
|
|
||||||
inserts) (`page.sx`); 14 tests. (177/177 total.)
|
|
||||||
- [x] Threading — `feed/replies`/`feed/reply-count`/`feed/thread`/
|
|
||||||
`feed/thread-objects`/`feed/thread-size`: conversation closure over `:reply-to`
|
|
||||||
(transitive fixpoint), chronological (`thread.sx`); 12 tests. (189/189 total.)
|
|
||||||
|
|
||||||
(none)
|
|
||||||
|
|
||||||
## Notes for next iteration
|
|
||||||
|
|
||||||
- sx-tree MCP tools take `file:` NOT `path:` (CLAUDE.md is stale). Wrong key →
|
|
||||||
`Yojson Type_error("Expected string, got null")`. Looks like a broken binary, isn't.
|
|
||||||
- sx_server binary lives in main repo: `/root/rose-ash/hosts/ocaml/_build/default/bin/sx_server.exe`
|
|
||||||
(worktree has no `_build`). conformance.sh already points there with relative fallback.
|
|
||||||
- Phase 2 substrate verified available: `apl-outer` (∘.×), `apl-member` (∊),
|
|
||||||
`apl-unique`, `apl-iota` (1-based).
|
|
||||||
|
|||||||
@@ -1,100 +0,0 @@
|
|||||||
# host-on-sx: The SX web host — off Quart, onto the kernel (Dream-bound)
|
|
||||||
|
|
||||||
> **DRAFT outline.** The integration boundary that turns the subsystem libraries
|
|
||||||
> into running services, and the strangler path off Python/Quart. This is the
|
|
||||||
> dependency hub — it imports every subsystem. Decision recorded below: native
|
|
||||||
> server + SXTP **now**, `dream-on-sx` framework layer **next**, Python only at the
|
|
||||||
> external-integration edges.
|
|
||||||
|
|
||||||
The subsystems (`feed`, `search`, `acl`, `mod`, `flow`, `commerce`, `identity`,
|
|
||||||
`content`, `events`) are libraries. Something has to receive an HTTP request, route
|
|
||||||
it, call the right subsystem, and serialize the response. Today that's Python/Quart
|
|
||||||
— the one large non-SX component in the stack: separate runtime, deploy, and
|
|
||||||
failure mode. The goal is to move the web/host/domain layer onto the SX substrate
|
|
||||||
and retire Quart, **incrementally (strangler-fig), never big-bang.**
|
|
||||||
|
|
||||||
This is already underway: a native OCaml HTTP server is live in prod on
|
|
||||||
`sx.rose-ash.com` (~3ms cached, ~323 req/s, ~2MB RSS), `defhandler`/`defpage`
|
|
||||||
exist, and a partial **SXTP** protocol is specced. That is the unblocked near-term
|
|
||||||
host — no `ocaml-on-sx` dependency.
|
|
||||||
|
|
||||||
## Two layers, two timelines
|
|
||||||
|
|
||||||
1. **Now (unblocked): native server + SXTP adapter + SX handlers.** Route rose-ash
|
|
||||||
endpoints onto the SX host one at a time. Each migrated endpoint is an SX
|
|
||||||
handler dispatching to a subsystem; Quart proxies the rest until cut over.
|
|
||||||
2. **Next: `dream-on-sx` as the framework layer.** Dream gives Quart-grade
|
|
||||||
ergonomics — typed routing, middleware stacks, sessions, CSRF. It is gated on
|
|
||||||
`ocaml-on-sx` Phases 1–5 + minimal stdlib. **This plan is the concrete target
|
|
||||||
user that un-parks `dream-on-sx`** (see `plans/dream-on-sx.md`): "the subsystems
|
|
||||||
need an HTTP front door" is the real feature pulling Dream. Until then, do not
|
|
||||||
block migration on Dream — the native server is sufficient.
|
|
||||||
3. **Always: Python only at the edges.** External integrations — SumUp payments,
|
|
||||||
Ghost CMS, ActivityPub crypto, IPFS/Kubo — ride Python libraries today. They
|
|
||||||
stay as thin injected adapters (Python/FFI) behind subsystem interfaces until
|
|
||||||
native replacements exist. "Drop Quart" ≠ "drop every line of Python."
|
|
||||||
|
|
||||||
## Status (rolling)
|
|
||||||
|
|
||||||
`bash lib/host/conformance.sh` → **0/0** (not yet started)
|
|
||||||
|
|
||||||
## Ground rules
|
|
||||||
|
|
||||||
- **Scope:** `lib/host/**` and `plans/host-on-sx.md`. May **import** every subsystem
|
|
||||||
+ the kernel's server/SXTP surface. Do **not** edit `spec/`, `hosts/`, `shared/`,
|
|
||||||
or subsystem internals — wire to their public APIs only. Host-primitive / server
|
|
||||||
changes belong in `hosts/` (out of scope) → Blockers.
|
|
||||||
- **Architecture:** a route maps (method, path) → handler; a handler is an SX fn
|
|
||||||
`request -> response` that calls subsystem APIs; middleware is composed handlers
|
|
||||||
(auth via `identity`, permission via `acl`, mute via subsystem prefs). SXTP is the
|
|
||||||
wire format between host and subsystem-as-service.
|
|
||||||
- **Migration discipline:** each endpoint moved must be behavior-equivalent to its
|
|
||||||
Quart original (golden-response test before flip). Keep a migration ledger.
|
|
||||||
- **Commits:** one feature per commit. Progress log + tick boxes.
|
|
||||||
|
|
||||||
## Architecture sketch
|
|
||||||
|
|
||||||
```
|
|
||||||
HTTP request HTTP response
|
|
||||||
│ ▲
|
|
||||||
▼ │
|
|
||||||
native OCaml http server (prod) ──────► lib/host/router.sx
|
|
||||||
(hosts/ — out of scope) — (method,path) → handler
|
|
||||||
│ ▲
|
|
||||||
▼ │
|
|
||||||
lib/host/middleware.sx lib/host/handler.sx
|
|
||||||
— auth(identity) ∘ acl ∘ mute ∘ ... — request → subsystem call → response
|
|
||||||
│ ▲
|
|
||||||
▼ │
|
|
||||||
lib/host/sxtp.sx subsystem APIs (feed/search/commerce/…)
|
|
||||||
— wire format, host↔service — called via public interfaces
|
|
||||||
│
|
|
||||||
└── external edges: SumUp / Ghost / AP / IPFS → injected Python/FFI adapters
|
|
||||||
```
|
|
||||||
|
|
||||||
## Phase 1 — Router + handler + one real endpoint
|
|
||||||
- [ ] `router.sx` — route table, (method,path) match
|
|
||||||
- [ ] `handler.sx` — request/response model, subsystem dispatch
|
|
||||||
- [ ] migrate ONE read endpoint (e.g. a feed timeline) end-to-end, golden test
|
|
||||||
- [ ] `conformance.sh` + scoreboard
|
|
||||||
|
|
||||||
## Phase 2 — Middleware + SXTP
|
|
||||||
- [ ] `middleware.sx` — composable auth/acl/mute/error layers
|
|
||||||
- [ ] `sxtp.sx` — host↔subsystem wire format (align with existing spec)
|
|
||||||
- [ ] migrate a write endpoint (auth + permission + action)
|
|
||||||
|
|
||||||
## Phase 3 — Strangler migration ledger
|
|
||||||
- [ ] enumerate Quart endpoints; track migrated vs proxied
|
|
||||||
- [ ] golden-response harness vs the live Quart responses
|
|
||||||
- [ ] cut over a whole domain (smallest: `likes` or `relations`) as proof
|
|
||||||
|
|
||||||
## Phase 4 — Dream framework layer (gated)
|
|
||||||
- [ ] gate: `ocaml-on-sx` Phases 1–5 + minimal stdlib green
|
|
||||||
- [ ] adopt `dream-on-sx` routing/middleware/session ergonomics over the same handlers
|
|
||||||
- [ ] re-home external adapters as native where replacements land
|
|
||||||
|
|
||||||
## Progress log
|
|
||||||
(loop fills this in)
|
|
||||||
|
|
||||||
## Blockers
|
|
||||||
(loop fills this in)
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
# identity-on-sx: OAuth2, sessions & membership on Erlang
|
|
||||||
|
|
||||||
> **DRAFT outline.** The identity core `acl-on-sx` assumes already exists. `acl`
|
|
||||||
> answers "may X do Y"; identity answers "who is X, and how did they prove it."
|
|
||||||
> Depends on `persist-on-sx` (grant/audit ledger). Pairs with `acl-on-sx`.
|
|
||||||
|
|
||||||
rose-ash's `account` domain is the OAuth2 authorization server every other app is
|
|
||||||
a client of: silent SSO, per-app first-party cookies, grant verification,
|
|
||||||
membership. Sessions and grants are **long-lived, concurrent, individually
|
|
||||||
addressable, and expire on their own** — that is the actor model. Erlang's
|
|
||||||
processes + mailboxes map cleanly: a session is a process, token issue/refresh/
|
|
||||||
revoke are messages, expiry is a process timeout, and SSO is one process answering
|
|
||||||
many apps.
|
|
||||||
|
|
||||||
End-state: an Erlang-on-SX layer with the OAuth2 authorization-code + silent
|
|
||||||
(`prompt=none`) flows as message protocols, a session/grant registry, token
|
|
||||||
lifecycle (issue/refresh/revoke/introspect), and membership state — all auditable
|
|
||||||
through the event log, all authorization questions delegated to `acl-on-sx`.
|
|
||||||
|
|
||||||
## Status (rolling)
|
|
||||||
|
|
||||||
`bash lib/identity/conformance.sh` → **0/0** (not yet started)
|
|
||||||
|
|
||||||
## Ground rules
|
|
||||||
|
|
||||||
- **Scope:** only `lib/identity/**` and `plans/identity-on-sx.md`. May **import**
|
|
||||||
from `lib/erlang/`, and (once they exist) `lib/persist/` + `lib/acl/`. Do not edit
|
|
||||||
substrates.
|
|
||||||
- **Architecture:** a session/grant is a process holding its own state; the
|
|
||||||
registry routes messages by subject/client id. Tokens are opaque + introspected,
|
|
||||||
not self-validating (revocation must be real). Authorization decisions are NOT
|
|
||||||
made here — `identity` proves identity, `acl` decides permission.
|
|
||||||
- **Security:** revocation is immediate (kill the process / tombstone the grant);
|
|
||||||
no decision relies on a token that outlived its grant. Negative answers are
|
|
||||||
explicit, never "absence of a yes."
|
|
||||||
- **Commits:** one feature per commit. Progress log + tick boxes.
|
|
||||||
|
|
||||||
## Architecture sketch
|
|
||||||
|
|
||||||
```
|
|
||||||
Auth request Token / session
|
|
||||||
(authorize client scope subject) {:access :refresh :expires :grant}
|
|
||||||
│ ▲
|
|
||||||
▼ │
|
|
||||||
lib/identity/oauth.sx lib/identity/token.sx
|
|
||||||
— authz-code + prompt=none flows — issue / refresh / revoke / introspect
|
|
||||||
— as Erlang message protocols — opaque tokens, grant-backed
|
|
||||||
│ ▲
|
|
||||||
▼ │
|
|
||||||
lib/identity/session.sx lib/identity/registry.sx
|
|
||||||
— session = process, expiry=timeout — route by subject/client; SSO fan-out
|
|
||||||
│ │
|
|
||||||
▼ ▼
|
|
||||||
lib/identity/api.sx ── (identity/login) (identity/grant?) (identity/revoke) ──┐
|
|
||||||
│ │
|
|
||||||
└──────── grant + audit events → persist ; permission? → acl ──────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
## Phase 1 — Sessions + tokens
|
|
||||||
- [ ] `session.sx` — session process, create/lookup/expire
|
|
||||||
- [ ] `token.sx` — issue/introspect/revoke (opaque, grant-backed)
|
|
||||||
- [ ] `registry.sx` — route by subject/client
|
|
||||||
- [ ] `api.sx` + tests + scoreboard + conformance.sh
|
|
||||||
|
|
||||||
## Phase 2 — OAuth2 flows
|
|
||||||
- [ ] authorization-code flow as a message protocol
|
|
||||||
- [ ] refresh + rotation; revocation cascades to issued tokens
|
|
||||||
- [ ] tests: full code exchange, refresh, revoke-then-use (must fail)
|
|
||||||
|
|
||||||
## Phase 3 — Silent SSO + membership
|
|
||||||
- [ ] `prompt=none` cross-app login (one session, many clients)
|
|
||||||
- [ ] membership state + per-app grant projection
|
|
||||||
- [ ] grant verification delegated cache (mirror Redis-cache pattern)
|
|
||||||
|
|
||||||
## Phase 4 — Audit + federation
|
|
||||||
- [ ] every issue/refresh/revoke is a `persist` event; `(identity/audit subject)`
|
|
||||||
- [ ] federated identity (peer-asserted subject) — advisory, trust-gated stub
|
|
||||||
- [ ] tests: audit completeness, cross-instance subject mapping
|
|
||||||
|
|
||||||
## Progress log
|
|
||||||
(loop fills this in)
|
|
||||||
|
|
||||||
## Blockers
|
|
||||||
(loop fills this in)
|
|
||||||
@@ -16,7 +16,7 @@ federation extension.
|
|||||||
|
|
||||||
## Status (rolling)
|
## Status (rolling)
|
||||||
|
|
||||||
`bash lib/mod/conformance.sh` → **0/0** (not yet started)
|
`bash lib/mod/conformance.sh` → **390/390** (roadmap + 19 extensions complete)
|
||||||
|
|
||||||
## Ground rules
|
## Ground rules
|
||||||
|
|
||||||
@@ -66,47 +66,386 @@ lib/mod/fed.sx
|
|||||||
|
|
||||||
## Phase 1 — Report representation + simple policy
|
## Phase 1 — Report representation + simple policy
|
||||||
|
|
||||||
- [ ] `lib/mod/schema.sx` — `report(id, by, about, reason)`, `evidence(id, kind, val)`,
|
- [x] `lib/mod/schema.sx` — `report(id, by, about)`, `classification(id, kind)`,
|
||||||
`policy-action(report, action)` predicates as Prolog facts/rules
|
`report_count(subject, n)` Prolog facts; keyword classifier derives evidence
|
||||||
- [ ] `lib/mod/policy.sx` — rule declarations: `(defrule action :when conditions)`
|
- [x] `lib/mod/policy.sx` — `mod/mk-rule` + ordered `mod/default-rules`; conditions
|
||||||
desugars to Prolog clause
|
(`:classification`, `:count-at-least`) compile to Prolog goals; `policy_action/3`
|
||||||
- [ ] `lib/mod/engine.sx` — `(decide report-id)` runs Prolog query, returns first
|
clauses, last clause `true` so every report yields at least `:keep`
|
||||||
matching action
|
- [x] `lib/mod/engine.sx` — `(mod/decide-report r reports rules)` queries
|
||||||
- [ ] `lib/mod/api.sx` — `(mod/report by about reason)`, `(mod/decide id)`
|
`policy_action(Id, Action, Rule)` with `pl-query-one` (clause order = precedence);
|
||||||
- [ ] `lib/mod/tests/decide.sx` — 15+ cases: spam keyword → hide, repeated reports →
|
returns a decision dict `{:action :rule :report-id :proof}` carrying the why
|
||||||
escalate, no rule matches → keep
|
- [x] `lib/mod/api.sx` — registry + `(mod/report by about reason)`, `(mod/decide id)`
|
||||||
- [ ] `lib/mod/scoreboard.{json,md}`
|
- [x] `lib/mod/tests/decide.sx` — 31 cases: spam/abuse keyword, repeated→escalate,
|
||||||
- [ ] `lib/mod/conformance.sh`
|
no-rule→keep, precedence (spam beats repeated), proof shape, registry ids
|
||||||
|
- [x] `lib/mod/scoreboard.{json,md}`
|
||||||
|
- [x] `lib/mod/conformance.sh`
|
||||||
|
|
||||||
## Phase 2 — Evidence + audit trail
|
## Phase 2 — Evidence + audit trail
|
||||||
|
|
||||||
- [ ] evidence accumulation — additional facts asserted before query
|
- [x] evidence accumulation — `report :evidence` list; `mod/attach-evidence` +
|
||||||
- [ ] proof tree from Prolog derivation tree
|
api `mod/add-evidence`; asserted as `evidence(Id, 'kind', 'val')` facts;
|
||||||
- [ ] `lib/mod/audit.sx` — append-only log (decision + proof + evidence snapshot)
|
new `:evidence` condition + `reviewer-remove` rule consume it
|
||||||
- [ ] `(mod/audit id)` retrieval
|
- [x] proof tree from Prolog derivation — `mod/proof-goals` re-queries each body
|
||||||
- [ ] `lib/mod/tests/audit.sx` — proof correctness, trail completeness
|
goal (id bound) against the same DB, recording goal text, solved?, and the
|
||||||
|
bindings that satisfied it (e.g. count goal yields N=3, S=subject)
|
||||||
|
- [x] `lib/mod/audit.sx` — append-only log: monotonic `:seq`, decision + proof +
|
||||||
|
evidence snapshot; never mutates prior entries
|
||||||
|
- [x] `(mod/audit id)` retrieval (+ `mod/audit-latest`, `mod/audit-all`, count)
|
||||||
|
- [x] `lib/mod/tests/audit.sx` — 29 cases: proof goal text/bindings, evidence-driven
|
||||||
|
decisions, append-only ordering, per-report retrieval, snapshot-at-decision-time
|
||||||
|
|
||||||
## Phase 3 — Escalation + lifecycle state machine
|
## Phase 3 — Escalation + lifecycle state machine
|
||||||
|
|
||||||
- [ ] state machine: `:open → :triaged → :decided → :appealed → :final`
|
- [x] state machine: `lib/mod/lifecycle.sx` — `:open → :triaged → :decided →
|
||||||
- [ ] auto-tier: first-pass rules decide quick cases
|
:appealed → :final` as a pure SX module over the engine; transition table guards
|
||||||
- [ ] human-tier: rules that emit `:escalate` move to next state
|
illegal moves (sets `:error`, leaves state); immutable cases with `:history`
|
||||||
- [ ] appeal: re-runs with appeal evidence, may override prior decision
|
- [x] auto-tier: `mod/case-triage` runs the engine; terminal action (hide/remove/
|
||||||
- [ ] `(mod/appeal id new-evidence)` API
|
keep) → tier `auto`, `mod/case-resolve` advances to `:decided`
|
||||||
- [ ] `lib/mod/tests/escalation.sx` — full lifecycle traversal cases
|
- [x] human-tier: `:escalate` action → tier `human`; `mod/case-resolve` is blocked
|
||||||
|
(sets `:error`); `mod/case-review` attaches evidence, re-decides, advances
|
||||||
|
- [x] appeal: `mod/case-appeal` attaches appeal evidence + re-runs the engine; new
|
||||||
|
`exonerated-keep` rule (top precedence) lets exoneration override a prior `:hide`
|
||||||
|
- [x] `(mod/appeal id new-evidence)` API — lifecycle façade over a case registry in
|
||||||
|
api.sx (`mod/triage` / `resolve` / `review` / `appeal` / `finalize`), logging
|
||||||
|
each committed decision to the audit trail
|
||||||
|
- [x] `lib/mod/tests/escalation.sx` — 46 cases: transition guards, auto/human tiers,
|
||||||
|
blocked resolve, full appeal-override traversal, history, api façade
|
||||||
|
|
||||||
## Phase 4 — Federation
|
## Phase 4 — Federation
|
||||||
|
|
||||||
- [ ] cross-instance reports — peer raises report about local content (or vice versa)
|
- [x] cross-instance reports — `mod/fed-receive-report peer …` ingests a peer's
|
||||||
- [ ] decision sharing — actions taken locally propagate to peers via fed-sx
|
report into the local registry, tagging origin; `mod/report-origin` resolves it
|
||||||
- [ ] trust model — peer's decision is advisory unless `(trust peer :mod)` is granted
|
(local reports default to `"local"`); the engine decides federated reports
|
||||||
- [ ] revocation — undo applied moderation if proof was invalidated
|
unchanged
|
||||||
- [ ] `lib/mod/tests/fed.sx` — federated decision chains (mock fed-sx in tests)
|
- [x] decision sharing — `mod/fed-share-decision decision peers` pushes messages to
|
||||||
|
the mock outbox (`mod/fed-send!` is the seam the real fed-sx transport replaces)
|
||||||
|
- [x] trust model — `mod/fed-receive-decision` applies a peer's decision locally
|
||||||
|
ONLY when `(mod/trusted? peer :mod)`; otherwise it lands in the advisory log,
|
||||||
|
unapplied. `mod/grant-trust` / `mod/revoke-trust` manage the trust registry
|
||||||
|
- [x] revocation — `mod/fed-revoke!` marks the applied action revoked + emits a
|
||||||
|
revocation message to the origin; `mod/fed-revoke-if-invalidated` re-runs the
|
||||||
|
engine and revokes only when the action no longer holds (proof invalidated)
|
||||||
|
- [x] `lib/mod/tests/fed.sx` — 26 cases: trust grant/scope/revoke, cross-instance
|
||||||
|
ingest + origin, outbox sharing, advisory-vs-trusted apply, revocation +
|
||||||
|
invalidation (exoneration flips hide→keep → revoked)
|
||||||
|
|
||||||
|
## Extensions (post-roadmap)
|
||||||
|
|
||||||
|
- [x] **Ext 1 — negation-as-failure** (`lib/mod/tests/extensions.sx`, +14). Report
|
||||||
|
`:attrs`; policy conditions `(:attr "x")` → `attr(Id, x)` and `(:not <cond>)` →
|
||||||
|
`not(<cond>)` (the Prolog supports `not/1` and `\+/1` as *functors*, not the
|
||||||
|
prefix `\+` operator). Closed-world example: "hide spam UNLESS author verified".
|
||||||
|
Default policy untouched — demonstrated via custom rule sets, so all 132 base
|
||||||
|
tests stay green.
|
||||||
|
- [x] **Ext 2 — weighted/aggregate scoring** (+8). Report `:signals` ({:kind
|
||||||
|
:weight}) project to `signal(Id, 'kind', weight)` facts; condition
|
||||||
|
`(:score-at-least N)` → `aggregate_all(sum(W), signal(Id, _, W), T), T >= N`.
|
||||||
|
Many weak signals accumulate past a threshold — genuine Prolog arithmetic
|
||||||
|
aggregation. Default policy untouched.
|
||||||
|
- [x] **Ext 3 — proof explanation** (`lib/mod/explain.sx`, +10). `mod/explain`
|
||||||
|
renders a decision into a readable "why": action + rule, evidence line, and the
|
||||||
|
derivation goal-by-goal with `[proved]`/`[unproved]` marks and unification
|
||||||
|
bindings. E.g. `Report rc: escalate (rule: repeated-escalate)` … `[proved]
|
||||||
|
report(rc, B, S), report_count(S, N), N >= 3 {B=ann, N=3, S=dave}`.
|
||||||
|
- [x] **Ext 19 — end-to-end triage pipeline** (`lib/mod/pipeline.sx`, +15).
|
||||||
|
`mod/triage-pipeline domain r reports actor` runs a report through domain-policy
|
||||||
|
decision → explanation → AP activity → wire, returning the full bundle. The test
|
||||||
|
is a genuine integration across 5 modules including a federated handoff (market
|
||||||
|
decision → wire → peer → trust-gated apply). The capstone that proves the
|
||||||
|
independently-built modules compose.
|
||||||
|
- [x] **Ext 18 — ergonomic defrule / ruleset** (`lib/mod/defrule.sx`, +11). The
|
||||||
|
roadmap's `(defrule …)` surface, done with `&rest` variadics (no macro needed —
|
||||||
|
conditions are already plain data): `mod/defrule` collects trailing conditions,
|
||||||
|
`mod/ruleset` assembles rules. Produces structurally identical rules to `mk-rule`
|
||||||
|
and works in the engine unchanged.
|
||||||
|
- [x] **Ext 17 — per-domain policy registry** (`lib/mod/policies.sx`, +14).
|
||||||
|
`mod/register-policy! domain rules` + `mod/decide-in domain r reports` give each
|
||||||
|
rose-ash domain (blog/market/events/…) its own rule set; unregistered domains
|
||||||
|
fall back to default-rules so a new domain is never unmoderated. Same spam report
|
||||||
|
→ :remove under a strict market policy, :hide under blog's default.
|
||||||
|
- [x] **Ext 16 — ActivityPub-shaped export** (`lib/mod/activity.sx`, +17).
|
||||||
|
`mod/decision->activity` maps a decision to a moderation verb (remove→Delete,
|
||||||
|
ban→Block, hide/escalate→Flag, keep→no activity) shaped like an AP activity
|
||||||
|
({:type :actor :object :summary}), the precise mod action preserved in :action.
|
||||||
|
`mod/decisions->activities` batch-exports, dropping keeps — ready for the
|
||||||
|
platform's AP event bus / federated peers.
|
||||||
|
- [x] **Ext 15 — disjunctive conditions** (`policy.sx` + `tests/disjunction.sx`,
|
||||||
|
+10). `(:any (list c1 c2 …))` compiles to Prolog disjunction `(g1 ; g2 ; …)`,
|
||||||
|
completing the condition boolean algebra (AND via the :when list, `:not`, `:any`).
|
||||||
|
Composes recursively — `:any` over `:not`/`:attr`/classification, and ANDs with
|
||||||
|
other conditions in the same rule. One rule now covers "spam OR abuse".
|
||||||
|
- [x] **Ext 14 — decision wire format** (`lib/mod/wire.sx`, +16). The bytes that
|
||||||
|
cross `fed/fed-send!`: `mod/decision->wire` emits a versioned pipe-delimited line
|
||||||
|
(`MOD1|r1|hide|spam-hide`), `mod/wire->decision` parses it back (`mod/wire-valid?`
|
||||||
|
guards). Built `mod/split-char` over `slice`/`len` (loaded env has no split).
|
||||||
|
Integration test exercises the full path: serialize → wire → deserialize →
|
||||||
|
`fed-receive-decision` trust-gating (untrusted→advisory, trusted→applied).
|
||||||
|
- [x] **Ext 13 — SLA sweep over pending cases** (`lib/mod/sla.sx`, +15). Composes
|
||||||
|
lifecycle (Phase 3) with time (Ext 12): a timed-case pairs a case with the tick
|
||||||
|
it entered its state; `mod/overdue?` flags pending cases (open/triaged/appealed)
|
||||||
|
past a deadline; `mod/sla-sweep` returns the breached report ids. Terminal states
|
||||||
|
never breach. Pure overlay — lifecycle stays timeless, the caller stamps entry.
|
||||||
|
- [x] **Ext 12 — temporal burst detection** (`lib/mod/temporal.sx`, +15). Reports
|
||||||
|
gain an `:at` tick (deterministic, supplied — never clock-read).
|
||||||
|
`mod/decide-temporal now window` counts reports about the subject within
|
||||||
|
`[now-window, now]`, asserts `burst_count/2`, and a `(:burst-at-least K)` rule
|
||||||
|
fires only on a real burst. Verified: 3 reports at ticks 10/11/12 → hide;
|
||||||
|
3 reports at 1/2/12 (window 5) → keep, while the plain count rule escalates both.
|
||||||
|
- [x] **Ext 11 — batch triage + corpus analytics** (`lib/mod/batch.sx`, +17).
|
||||||
|
`mod/decide-batch` triages a queue; `mod/action-histogram` summarizes outcomes by
|
||||||
|
action; `mod/rule-coverage` / `mod/never-fired` measure which rules fire across a
|
||||||
|
corpus — the *empirical* complement to lint's static unreachable check (Ext 5):
|
||||||
|
lint finds rules that can't fire, never-fired finds rules that didn't.
|
||||||
|
- [x] **Ext 10 — policy what-if / impact** (`lib/mod/whatif.sx`, +13).
|
||||||
|
`mod/decision-diff` compares one report's action under two rule sets;
|
||||||
|
`mod/policy-impact` runs a batch and returns only the reports whose decision
|
||||||
|
flips; `mod/impact-count` / `mod/impact-report` summarize. Lets a team measure a
|
||||||
|
policy change before shipping it (e.g. "removing spam-hide flips r1 hide→keep").
|
||||||
|
- [x] **Ext 9 — policy dry-run trace** (`lib/mod/trace.sx`, +15). `mod/trace-rules`
|
||||||
|
evaluates a report against every rule and returns each rule's proved/unproved
|
||||||
|
status + its goal-by-goal derivation, so an unproved rule shows which goal
|
||||||
|
failed. `mod/first-proved` = the winner (engine precedence), `mod/proved-rules`
|
||||||
|
the full firing set, `mod/trace-report` a `[fires]`/`[ - ]` rendering. Answers
|
||||||
|
"why didn't my rule fire?" without instrumenting the engine.
|
||||||
|
- [x] **Ext 8 — quorum over distinct reporters** (`lib/mod/quorum.sx`, +9). Anti-
|
||||||
|
brigade: `(:reporters-at-least N)` compiles to `setof(Br, report(_, Br, Sr), Bsr),
|
||||||
|
length(Bsr, Nr), Nr >= N` — distinct reporters, not raw report count.
|
||||||
|
`mod/decide-quorum` asserts every report's `report/3` fact (the base engine only
|
||||||
|
asserts the decided one) so Prolog can aggregate reporters. Verified one user
|
||||||
|
filing 3 reports stays `:keep` under quorum while the count rule would escalate.
|
||||||
|
(Substrate note: `^` existential doesn't parse; `setof(B, p(_, B, S), …)` with `_`
|
||||||
|
yields the distinct set in a single solution here.)
|
||||||
|
- [x] **Ext 7 — repeat-offender escalation** (`lib/mod/offenders.sx`, +19). The
|
||||||
|
audit log as evidence: `mod/subject-sanctions` counts prior hide/remove/ban
|
||||||
|
decisions about a subject; `mod/decide-escalating id k` decides normally then
|
||||||
|
upgrades a *sanction* to `:ban` when the subject already has ≥k prior sanctions.
|
||||||
|
Non-sanction outcomes (keep/escalate) pass through untouched. First decision
|
||||||
|
whose input spans history beyond the single report — read from the trail, not
|
||||||
|
re-derived.
|
||||||
|
- [x] **Ext 6 — strictest-wins strategy** (`lib/mod/severity.sx`, +14). Alternative
|
||||||
|
to first-match: `mod/decide-strictest` collects every proven rule (`pl-query-all`)
|
||||||
|
and picks the highest-`mod/action-severity` action (keep<escalate<hide<remove<ban).
|
||||||
|
Diverges from the default engine when rule order and severity disagree. Same
|
||||||
|
decision shape + `:strategy`; engine untouched.
|
||||||
|
- [x] **Ext 5 — policy lint** (`lib/mod/lint.sx`, +14). Static analysis of a rule
|
||||||
|
set: `mod/unreachable-rules` flags rules placed after an unconditional (always-
|
||||||
|
matching) rule — structurally dead under first-match precedence;
|
||||||
|
`mod/has-catchall?` checks every report gets a decision; `mod/duplicate-rule-names`
|
||||||
|
+ `mod/rules-ok?` give a one-call well-formedness verdict. No engine run needed.
|
||||||
|
- [x] **Ext 4 — report linking / dedup** (`lib/mod/link.sx`, +12). `mod/related-ids`
|
||||||
|
and `mod/reporters-of` find reports about a subject via a Prolog relational query
|
||||||
|
(`report(Id, _, 'subject')`) — the policy substrate reused for retrieval.
|
||||||
|
`mod/dedup-reports` collapses identical reports (reporter|subject|reason key,
|
||||||
|
case-insensitive); `mod/distinct-reporters-of` counts unique reporters.
|
||||||
|
|
||||||
|
## Shared-plumbing extraction — evaluated post-merge, DECLINED
|
||||||
|
|
||||||
|
Both layers now live on architecture; the extraction was evaluated by reading
|
||||||
|
both implementations side by side. **Finding: do not extract — the convergence is
|
||||||
|
in module *names* only, not implementations.** The engines and decision models
|
||||||
|
genuinely differ, so a shared module would be premature abstraction that ages
|
||||||
|
badly. (This reverses the pre-read note that listed audit + fed trust/outbox as
|
||||||
|
candidates; reading the code showed they don't actually share.)
|
||||||
|
|
||||||
|
- **Federation — zero shared code.** mod gates trust in SX (a `{:peer :scope}`
|
||||||
|
registry + `grant`/`revoke`/`trusted?`) and shares *decisions* (outbox,
|
||||||
|
advisory/applied logs, `receive-decision`). acl gates trust *inside Datalog*
|
||||||
|
(`trust(Peer,L)` / `level_covers` facts + an engine rule re-checked per query)
|
||||||
|
and shares *facts* (`fetch`/`collect`/`build-db`, `assert!`/`retract!`). acl has
|
||||||
|
no trust registry, no `trusted?`, no outbox. Opposite architectures — the only
|
||||||
|
common token is the word "trust."
|
||||||
|
- **Audit — only a ~5-fn core overlaps, and it diverges.** Entry shapes differ
|
||||||
|
entirely (mod `{:action :rule :proof :evidence :report-id :seq}` vs acl
|
||||||
|
`{:allowed? :act :subj :res :seq}`); seq base differs (acl 0, mod 1, both
|
||||||
|
test-visible); op sets barely intersect (mod: by-`report-id` + `latest`; acl:
|
||||||
|
`tail`/`snapshot`/`restore`/`serialize`); even the list idiom differs (acl
|
||||||
|
`append!`+copy vs mod pure `append`+`set!`). A shared module would also have to
|
||||||
|
satisfy two different restricted eval envs (prolog- vs datalog-loaded). Cost
|
||||||
|
(shared module + refactor both + rewrite acl's serialize/snapshot onto a foreign
|
||||||
|
core + cross-env risk + coupling two independent loops) far exceeds the benefit
|
||||||
|
(dedup ~5 trivial lines that don't even agree on seq-base or mutation idiom).
|
||||||
|
- **Engines + `explain`** were never shareable: Datalog yields derivation trees
|
||||||
|
natively; mod reconstructs proofs via per-goal `pl-query-all`.
|
||||||
|
- **Trivia** (`join-with`, `any?`, `str-contains?`, `distinct`) is one-liners, not
|
||||||
|
worth a module.
|
||||||
|
|
||||||
|
**Outcome:** keep mod (Prolog) and acl (Datalog) as parallel independent
|
||||||
|
implementations. The parallel structure is correct for two different engines; the
|
||||||
|
shared abstraction is not. Revisit only if a third rule-engine consumer appears
|
||||||
|
with the *same* trust/audit model (rule of three), not before.
|
||||||
|
|
||||||
## Progress log
|
## Progress log
|
||||||
|
|
||||||
(loop fills this in)
|
- **Ext 19 — end-to-end triage pipeline, 390/390** (+15). Capstone: one
|
||||||
|
orchestration call composes domain policy + decide + explain + activity + wire,
|
||||||
|
and the integration test runs the whole federated path (decide in a domain →
|
||||||
|
wire → peer → trust-gated apply) across 5 modules. Confirms the subsystem — built
|
||||||
|
module-by-module — actually composes end to end. mod-sx now spans schema → policy
|
||||||
|
DSL (boolean algebra + count/score/reporters/burst) → engine + proofs → audit →
|
||||||
|
lifecycle → SLA → federation (trust/wire/AP) → analytics (trace/whatif/lint/batch)
|
||||||
|
→ domain policies → pipeline, all on the green lib/prolog substrate, 390 tests.
|
||||||
|
- **Ext 18 — ergonomic defrule / ruleset, 375/375** (+11). Closes the roadmap's
|
||||||
|
original `defrule` surface. `fn` supports `&rest` here, and conditions evaluate
|
||||||
|
to plain data, so no macro is needed — variadic functions give the ergonomics
|
||||||
|
safely. Equivalence to `mk-rule` is asserted, so it's pure sugar with no new
|
||||||
|
semantics.
|
||||||
|
- **Ext 17 — per-domain policy registry, 364/364** (+14). Multi-tenant policy:
|
||||||
|
the engine already took `rules` as a parameter, so domain-scoping is just a
|
||||||
|
registry + a default fallback — no engine change. Makes the whole policy
|
||||||
|
vocabulary (16 prior features) per-domain configurable. Default fallback means
|
||||||
|
adding a domain can't accidentally leave it unmoderated.
|
||||||
|
- **Ext 16 — ActivityPub-shaped export, 350/350** (+17). Bridges mod-sx to the
|
||||||
|
wider rose-ash platform, which propagates cross-domain effects as AP-shaped
|
||||||
|
activities. Decisions become Flag/Delete/Block activities (keep = no-op); with
|
||||||
|
the wire format (Ext 14) and fed trust model (Phase 4) the federated moderation
|
||||||
|
path is now end-to-end: decide → activity/wire → peer → trust-gate → apply.
|
||||||
|
- **Ext 15 — disjunctive conditions, 333/333** (+10). The condition DSL is now a
|
||||||
|
full boolean algebra: AND (the :when list), `:not` (NAF), `:any` (Prolog `;`).
|
||||||
|
cond->goal recurses, so the combinators nest arbitrarily — `:any` of `:not`s, an
|
||||||
|
`:any` ANDed with a `:not`, etc. — and the proof tree shows the compiled
|
||||||
|
disjunction verbatim. Maps directly onto Prolog's own control constructs rather
|
||||||
|
than reimplementing boolean logic in SX.
|
||||||
|
- **Ext 14 — decision wire format, 323/323** (+16). Fills the federation transport
|
||||||
|
seam: decisions now serialize to a portable line and parse back, and the
|
||||||
|
integration test runs the whole federated path end-to-end (serialize on one
|
||||||
|
instance → trust-gated apply on another). Needed a hand-rolled `split-char`
|
||||||
|
(loaded env has no split) — over `slice`/`len`, same toolkit as `str-contains?`.
|
||||||
|
- **Ext 13 — SLA sweep, 307/307** (+15). Two subsystems compose cleanly: lifecycle
|
||||||
|
states + temporal ticks → "which pending cases have sat too long". Kept lifecycle
|
||||||
|
pure by having the SLA layer carry entry-time externally (timed-case wrapper)
|
||||||
|
rather than stamping the case — same separation-of-concerns as keeping the state
|
||||||
|
machine out of Prolog.
|
||||||
|
- **Ext 12 — temporal burst detection, 292/292** (+15). Adds the time dimension:
|
||||||
|
a windowed count distinguishes a burst from slow accumulation, where the plain
|
||||||
|
count rule cannot. Time is a supplied tick (`:at`), keeping everything
|
||||||
|
deterministic and testable — no clock primitive. Fifth report field (`:at`)
|
||||||
|
threaded through the rebuild helpers, same non-breaking pattern as
|
||||||
|
evidence/attrs/signals; all 277 prior tests stayed green.
|
||||||
|
- **Ext 11 — batch triage + corpus analytics, 277/277** (+17). Operational layer:
|
||||||
|
triage a queue, histogram the outcomes, and measure rule coverage over real
|
||||||
|
data. `never-fired` pairs with lint (Ext 5) — static "can't fire" vs empirical
|
||||||
|
"didn't fire" — giving policy authors both views of dead rules. Histogram avoids
|
||||||
|
dict mutation by counting over a fixed action vocabulary.
|
||||||
|
- **Ext 10 — policy what-if / impact, 260/260** (+13). Decisions are now
|
||||||
|
comparable across rule sets — diff one report, or batch a whole set and surface
|
||||||
|
only the flips. Pure SX over `decide-report`, no engine change. Closes the
|
||||||
|
policy-authoring loop alongside lint (Ext 5) and trace (Ext 9): lint checks
|
||||||
|
well-formedness, trace explains one report, what-if measures a change's blast
|
||||||
|
radius before it ships.
|
||||||
|
- **Ext 9 — policy dry-run trace, 247/247** (+15). Whole-rule-set diagnostics over
|
||||||
|
the proof machinery: every rule's fire/no-fire and the goal that decided it. The
|
||||||
|
winner agrees with `decide-report` by construction (first proved = pl-query-one),
|
||||||
|
cross-checked in a test. Turns the proof tree from a per-decision artifact into a
|
||||||
|
policy-debugging tool.
|
||||||
|
- **Ext 8 — quorum over distinct reporters, 232/232** (+9). Distinct-reporter
|
||||||
|
consensus via Prolog `setof`/`length`, requiring a second engine variant that
|
||||||
|
asserts all reports (the base engine deliberately scopes facts to the decided
|
||||||
|
report). Demonstrates the substrate handles set-aggregation, and that the
|
||||||
|
brigade case (one actor, many reports) is defeated by counting reporters not
|
||||||
|
reports. `^` existential doesn't parse here — `setof(B, p(_,B,S), …)` with `_`
|
||||||
|
gives the distinct set in one solution.
|
||||||
|
- **Ext 7 — repeat-offender escalation, 223/223** (+19). Decisions can now depend
|
||||||
|
on history: the append-only audit log is read back as evidence, and a subject
|
||||||
|
with k prior sanctions has its next sanction upgraded to `:ban`. Closes the loop
|
||||||
|
between audit (Phase 2) and policy — the trail isn't just a record, it feeds
|
||||||
|
future decisions. Non-sanction outcomes never escalate (verified: a clean post
|
||||||
|
that the count rule escalates stays `:escalate`, never `:ban`).
|
||||||
|
- **Ext 6 — strictest-wins strategy, 204/204** (+14). A second decision strategy
|
||||||
|
alongside first-match: collect all proven rules and apply the harshest sanction.
|
||||||
|
Shows the substrate supports more than one precedence policy over the same rule
|
||||||
|
facts — `pl-query-all` for the full match set, severity ranking in SX. Verified
|
||||||
|
it diverges from first-match exactly when rule order and severity disagree.
|
||||||
|
- **Ext 5 — policy lint, 190/190** (+14). Static analysis of the rule set itself,
|
||||||
|
catching the failure modes first-match precedence makes easy: dead rules after a
|
||||||
|
catch-all, missing catch-all (undecided reports), duplicate names. `mod/rules-ok?`
|
||||||
|
is a single well-formedness gate a policy author can assert in their own tests.
|
||||||
|
- **Ext 4 — report linking / dedup, 176/176** (+12). Relational retrieval
|
||||||
|
(`related-ids`, `reporters-of`) reuses the Prolog substrate for *querying* report
|
||||||
|
clusters, not just deciding them — `report(Id, _, 'subject')` by unification.
|
||||||
|
Dedup is pure SX over a normalized link key. Own suite (`tests/link.sx`) — going
|
||||||
|
forward, new extensions get their own test file rather than growing
|
||||||
|
`extensions.sx`. With roadmap + 4 extensions the subsystem now spans schema →
|
||||||
|
policy DSL (6 condition types) → engine + proofs → audit → lifecycle →
|
||||||
|
federation → explanation → linking, all on the green `lib/prolog` substrate.
|
||||||
|
- **Ext 3 — proof explanation, 164/164** (+10). `mod/explain` turns the Phase-2
|
||||||
|
proof tree into human-readable text — the audit trail's "why" made legible. Pure
|
||||||
|
SX over existing decision data; no engine change. Renders unification bindings
|
||||||
|
inline (`{B=ann, N=3, S=dave}`) so a moderator sees exactly which facts proved
|
||||||
|
the decision.
|
||||||
|
- **Ext 2 — weighted/aggregate scoring, 154/154** (+8). `:signals` + the
|
||||||
|
`(:score-at-least N)` condition push aggregation into Prolog
|
||||||
|
(`aggregate_all(sum(W), …)`), so low-confidence signals can accumulate to a
|
||||||
|
takedown. The schema's report-rebuild helpers (`report*` / `with-*`) now thread
|
||||||
|
six fields; each addition stays non-breaking because empty collections project
|
||||||
|
to empty fact blocks. Default policy and its 132 tests untouched (proven via
|
||||||
|
custom rule sets).
|
||||||
|
- **Ext 1 — negation-as-failure, 146/146** (+14). `:attr` and `:not` conditions
|
||||||
|
give the policy closed-world reasoning. The substrate's negation is a functor
|
||||||
|
(`not(Goal)`), not the ISO prefix `\+` operator (that doesn't parse here) —
|
||||||
|
noted for any future negation work. Kept the default rule set and its 132 tests
|
||||||
|
untouched by proving the feature through custom rule sets instead.
|
||||||
|
- **Phase 4 complete — 132/132** (+26 fed). **Full roadmap done.** Federation:
|
||||||
|
cross-instance reports, decision sharing, advisory-by-default trust, revocation.
|
||||||
|
fed-sx is mocked behind `mod/fed-send!` (in-memory outbox) — the only seam a real
|
||||||
|
transport must replace. The hard rule is enforced: a peer's decision binds
|
||||||
|
locally only under `(mod/trusted? peer :mod)`; otherwise it is recorded as a
|
||||||
|
suggestion and never auto-applied. Revocation composes with the proof model from
|
||||||
|
Phase 2 — `mod/fed-revoke-if-invalidated` re-runs the *same* engine and undoes a
|
||||||
|
moderation only when the action it once proved no longer holds (an exoneration
|
||||||
|
evidence flips hide→keep, triggering revocation + an origin-bound revocation
|
||||||
|
message).
|
||||||
|
- **Liftable (acl-sx watch):** the trust registry (`grant`/`revoke`/`trusted?`
|
||||||
|
over `{:peer :scope}`) and the outbox/send! seam are generic federation
|
||||||
|
plumbing; candidates for `lib/guest/` if acl-sx grows a federation phase.
|
||||||
|
- **Phase 3 complete — 106/106** (+46 escalation). Lifecycle state machine,
|
||||||
|
auto/human tiers, appeal-override, and an api façade. The state machine is a
|
||||||
|
pure SX module (`lib/mod/lifecycle.sx`) over the engine — policy stays in
|
||||||
|
Prolog, lifecycle stays out of it, per the design constraint. Cases are
|
||||||
|
immutable values threaded through transitions; illegal moves set `:error`
|
||||||
|
rather than throwing (the env's error handling is untested, so this keeps tests
|
||||||
|
deterministic). Tier logic: triage runs the engine, an `:escalate` action parks
|
||||||
|
the case at the human tier where `mod/case-resolve` is blocked until
|
||||||
|
`mod/case-review` supplies evidence. Appeal-override works because the new
|
||||||
|
`exonerated-keep` rule sits at top precedence — appeal evidence re-runs the same
|
||||||
|
engine and a higher-precedence clause wins. The api façade (`mod/triage` …
|
||||||
|
`mod/finalize`) keeps a per-report case registry and logs each committed
|
||||||
|
decision to the Phase-2 audit trail, so lifecycle + audit compose.
|
||||||
|
- **Gotcha:** `sx_insert_near` inserts only the FIRST top-level form of a
|
||||||
|
multi-form source — silently drops the rest (byte count barely changes). For
|
||||||
|
multi-form additions, rewrite the file with `sx_write_file`.
|
||||||
|
- **Phase 2 complete — 60/60** (+29 audit). Evidence accumulation, constructive
|
||||||
|
proof trees, append-only audit log. A decision's `:proof :goals` is a real
|
||||||
|
derivation: each body goal is re-queried against the same Prolog DB with the
|
||||||
|
report id bound, so the count rule's proof carries `N=3, S=<subject>` straight
|
||||||
|
from unification — not a reconstruction. Evidence is asserted as
|
||||||
|
`evidence(Id, 'kind', 'val')`; the new `reviewer-remove` rule (placed first =
|
||||||
|
highest precedence) lets human review override automated classification.
|
||||||
|
`mod/decide` now commits each decision to the audit log with the evidence
|
||||||
|
snapshot in force at decision time. Unknown predicates in this Prolog fail
|
||||||
|
gracefully (verified) — so an evidence-less report safely falls through the
|
||||||
|
reviewer rule without an existence error.
|
||||||
|
- **Liftable (acl-sx watch):** the proof-tree builder (`mod/proof-goals` —
|
||||||
|
re-query-each-goal) and the append-only log shape are both generic. Both
|
||||||
|
subsystems are now past Phase 2; next time either touches plumbing, evaluate
|
||||||
|
lifting `proof-goals` + the audit-log primitives into `lib/guest/`.
|
||||||
|
- **Phase 1 complete — 31/31.** Report schema, keyword classifier, policy DSL,
|
||||||
|
engine, registry api, conformance harness. Decisions are proofs: each carries
|
||||||
|
`:rule` (matching clause), `:proof {:rule :conditions :evidence :count}`.
|
||||||
|
Precedence is Prolog clause order resolved by `pl-query-one`; a trailing
|
||||||
|
`true`-bodied default rule makes "no rule matched" a real `:keep`, not a query
|
||||||
|
failure. Evidence (spam/abuse classification) derived in SX and asserted as
|
||||||
|
`classification/2` facts; repeated-report escalation uses a genuine Prolog
|
||||||
|
join + arithmetic (`report(Id,_,S), report_count(S,N), N >= 3`).
|
||||||
|
- **Gotcha (env):** loading the prolog libs strips `includes?` (and other
|
||||||
|
high-level string prims) from the eval env — only the set the prolog
|
||||||
|
tokenizer itself uses survives (`slice`, `len`, `nth`, `=`, `join`,
|
||||||
|
`downcase`, `map`, `reduce`, `append!`). Implemented `mod/str-contains?` over
|
||||||
|
`slice`/`len` rather than relying on `includes?`. Watch for this in later
|
||||||
|
phases — stick to the blessed primitive set.
|
||||||
|
- **Liftable (acl-sx watch):** `mod/join-with`, `mod/str-contains?`, `mod/any?`,
|
||||||
|
and the rule→clause compilation shape are generic rule-engine plumbing. Do not
|
||||||
|
extract to `lib/guest/` until both mod-sx and acl-sx are past Phase 2.
|
||||||
|
|
||||||
## Blockers
|
## Blockers
|
||||||
|
|
||||||
(loop fills this in)
|
(none)
|
||||||
|
|||||||
@@ -1,119 +0,0 @@
|
|||||||
# persist-on-sx: Durable state on the SX kernel
|
|
||||||
|
|
||||||
> **DRAFT outline.** Foundation subsystem — the durable substrate the other five
|
|
||||||
> currently fake with in-memory mutable lists. Build this first.
|
|
||||||
>
|
|
||||||
> **"persist" = persistence / data store, NOT the shop.** The shop/commerce vertical
|
|
||||||
> is `commerce-on-sx`.
|
|
||||||
|
|
||||||
rose-ash needs durable state: every subsystem (feed log, flow store, mod audit,
|
|
||||||
search index, acl grants, sessions) today hand-rolls an in-memory structure that
|
|
||||||
vanishes on restart. `persist-on-sx` is the one durable substrate they share. It
|
|
||||||
lives directly on the SX kernel's IO-suspension primitives (`perform`/`cek-resume`
|
|
||||||
— the third CEK phase) so a read/write `perform`s and the kernel persists at the
|
|
||||||
boundary. Concrete storage backends are injected.
|
|
||||||
|
|
||||||
## Does it cover ALL persistence? No — and on purpose.
|
|
||||||
|
|
||||||
Event-sourcing-everything is a known trap (replay cost, event schema evolution,
|
|
||||||
awkward ad-hoc queries, 5MB images in a log). So persist owns the **durable
|
|
||||||
source-of-truth substrate**, exposed as **two facets over one backend protocol**,
|
|
||||||
with two things explicitly delegated out:
|
|
||||||
|
|
||||||
| Shape | Owner | Notes |
|
|
||||||
|-------|-------|-------|
|
|
||||||
| **Event streams** (append-only, history matters) | persist — **log facet** | feed activities, mod audit, order ledger, flow state, content edits |
|
|
||||||
| **Current-state values** (KV / document, no history) | persist — **kv facet** | profiles, stock counts, config, session blobs; also where projections materialize |
|
|
||||||
| **Snapshots / read models** (derived, queryable) | persist — projections → kv/log | rebuildable from the log; persisted so you don't replay to answer a query |
|
|
||||||
| **Blobs / large objects** (images, media) | **delegated** → content-addressed store (artdag/IPFS already) | persist stores the *reference/CID*, never the bytes |
|
|
||||||
| **Cache** (ephemeral, evictable) | **out of scope** | not persistence — different lifecycle (Redis-shaped) |
|
|
||||||
| **Ad-hoc relational query** | the subsystem, over a projected read model | the log is bad at "all orders by X in March"; project into a queryable kv/SQL backend |
|
|
||||||
|
|
||||||
So: persist is the **single durable substrate** for state that's either a stream of
|
|
||||||
changes or a current value — but it does **not** force everything into an event
|
|
||||||
log, it does **not** hold blobs (only their content-addressed refs), and it does
|
|
||||||
**not** do caching. Those boundaries are the whole point of calling it a substrate
|
|
||||||
rather than "the database."
|
|
||||||
|
|
||||||
End-state: `log` (append/read streams) + `kv` (get/put/delete by key) facets, an
|
|
||||||
injectable backend protocol (mem → file → Postgres → IPFS-ref), pure projections
|
|
||||||
with incremental snapshots, optimistic concurrency, and a subscription hook so
|
|
||||||
read models (feeds, indices, audit logs) update incrementally.
|
|
||||||
|
|
||||||
## Status (rolling)
|
|
||||||
|
|
||||||
`bash lib/persist/conformance.sh` → **0/0** (not yet started)
|
|
||||||
|
|
||||||
## Ground rules
|
|
||||||
|
|
||||||
- **Scope:** only `lib/persist/**` and `plans/persist-on-sx.md`. May **import** the
|
|
||||||
kernel's IO-suspension surface (`perform`, platform IO ops) — verify what's
|
|
||||||
exported first. Do not add host primitives; a missing durable IO op is a Blockers
|
|
||||||
entry (it belongs in `hosts/`, out of scope).
|
|
||||||
- **Architecture:** an event is `{:stream :seq :type :at :data}`; the log is an
|
|
||||||
ordered append-only vector; a projection is `(fold step seed events)`; a kv value
|
|
||||||
is `(get/put/delete key)`. Both facets sit on one injected backend
|
|
||||||
`{:append :read :kv-get :kv-put :snapshot-read :snapshot-write}`. The in-memory
|
|
||||||
backend is the test default; real backends wire in unchanged.
|
|
||||||
- **Determinism:** replay is pure — same log → same state, always. No clocks or
|
|
||||||
randomness inside projections; time lives on the event.
|
|
||||||
- **Blobs:** store the content-address/CID and metadata; never the bytes. The blob
|
|
||||||
backend is a separate injected dependency.
|
|
||||||
- **Commits:** one feature per commit. Progress log + tick boxes.
|
|
||||||
|
|
||||||
## Architecture sketch
|
|
||||||
|
|
||||||
```
|
|
||||||
Command / write Read model / value
|
|
||||||
(append stream type data) (project stream step seed)
|
|
||||||
(kv-put key value) (kv-get key)
|
|
||||||
│ ▲
|
|
||||||
▼ │
|
|
||||||
lib/persist/event.sx lib/persist/project.sx
|
|
||||||
— {:stream :seq :type :at :data} — fold step seed; incremental from snapshot
|
|
||||||
│ ▲
|
|
||||||
▼ │
|
|
||||||
lib/persist/log.sx lib/persist/kv.sx lib/persist/snapshot.sx
|
|
||||||
— append/read — get/put/delete — checkpoint; replay = snapshot + tail
|
|
||||||
— optimistic seq — current-state
|
|
||||||
│ │ ▲
|
|
||||||
└──────────────────┴── (perform → backend) ───┘
|
|
||||||
│
|
|
||||||
lib/persist/backend.sx lib/persist/api.sx
|
|
||||||
— injected protocol — (persist/append) (persist/project)
|
|
||||||
— mem | file | pg | ipfs-ref — (persist/kv-get/put) (persist/subscribe)
|
|
||||||
│
|
|
||||||
└── blobs → content-addressed store (artdag/IPFS), by reference only
|
|
||||||
```
|
|
||||||
|
|
||||||
## Phase 1 — Log + kv + in-memory backend
|
|
||||||
- [ ] `event.sx` — event record, stream/seq helpers
|
|
||||||
- [ ] `backend.sx` — injectable protocol + in-memory impl (log + kv)
|
|
||||||
- [ ] `log.sx` — `append` (optimistic seq), `read`, `read-from`
|
|
||||||
- [ ] `kv.sx` — `get`/`put`/`delete` current-state
|
|
||||||
- [ ] `api.sx` + tests + scoreboard + conformance.sh
|
|
||||||
|
|
||||||
## Phase 2 — Projections + subscriptions
|
|
||||||
- [ ] `project.sx` — `(project stream step seed)`, incremental fold
|
|
||||||
- [ ] subscription hook — projection / kv read model re-runs on append
|
|
||||||
- [ ] concurrency conflict surfaced as a real result, not a crash
|
|
||||||
|
|
||||||
## Phase 3 — Snapshots + replay
|
|
||||||
- [ ] `snapshot.sx` — checkpoint a projection; replay = snapshot + tail
|
|
||||||
- [ ] compaction policy; replay-determinism tests
|
|
||||||
|
|
||||||
## Phase 4 — Durable backends via kernel IO
|
|
||||||
- [ ] file/log backend driven through `perform` (IO-suspension boundary)
|
|
||||||
- [ ] blob backend interface (store ref/CID; bytes live in artdag/IPFS)
|
|
||||||
- [ ] crash/restart replay test (mock IO platform)
|
|
||||||
- [ ] migration notes for swapping mem → durable under a live subsystem
|
|
||||||
|
|
||||||
## Consumers (post-foundation, not in scope here)
|
|
||||||
feed/-log, flow store, mod/audit, search index, acl grants, identity sessions all
|
|
||||||
become `persist` log or kv. Track each migration in that subsystem's plan.
|
|
||||||
|
|
||||||
## Progress log
|
|
||||||
(loop fills this in)
|
|
||||||
|
|
||||||
## Blockers
|
|
||||||
(loop fills this in)
|
|
||||||
@@ -1,170 +0,0 @@
|
|||||||
# Re-implementing rose-ash on SX — migration strategy
|
|
||||||
|
|
||||||
Status: **strategy proposal** (drafted by the `radar` loop, 2026-06-07). Not a
|
|
||||||
unilateral architecture decision — a starting point for the fleet to refine. Radar's
|
|
||||||
role here is detection: the `*-on-sx` subsystems have converged into a host-agnostic
|
|
||||||
re-implementation of rose-ash's domain logic, so this doc proposes *when* and *how* to
|
|
||||||
wire them to production.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Premise: we are ~70% into a re-implementation already
|
|
||||||
|
|
||||||
The fleet of `lib/<x>` SX subsystems is not a set of experiments — it is rose-ash's
|
|
||||||
domain logic, re-expressed substrate-by-substrate, deliberately **host-agnostic**:
|
|
||||||
|
|
||||||
| SX subsystem (`lib/`) | rose-ash production domain |
|
|
||||||
|---|---|
|
|
||||||
| content-on-sx (CRDT docs, versioning, `page.sx` HTML render) | **blog** |
|
|
||||||
| commerce-on-sx (catalog, pricing, cart, order + refund sagas) | **market + cart + orders** |
|
|
||||||
| events-on-sx (calendar, ticketing, booking) | **events** |
|
|
||||||
| feed-on-sx (activity streams, AP-shaped, threading) | **federation** |
|
|
||||||
| identity-on-sx (OAuth2, sessions, grants, membership) | **account** |
|
|
||||||
| acl-on-sx (permissions) | cross-cutting authZ |
|
|
||||||
| relations / likes | **relations / likes** (internal) |
|
|
||||||
| persist-on-sx (log / kv / snapshot facets) | per-service Postgres layer |
|
|
||||||
| flow-on-sx (durable sagas) | order/refund/delivery workflows |
|
|
||||||
| mod-on-sx, search-on-sx | new capabilities |
|
|
||||||
|
|
||||||
**The architectural enabler:** every core was built with *injected seams* — `permit?`,
|
|
||||||
`send-fn`/`fetch-fn`, `transport`, `dispatch`, `backend`. That is ports-and-adapters
|
|
||||||
(hexagonal) on purpose. Evidence from the radar backlog (`plans/abstractions.md`):
|
|
||||||
W1 (7/7 federation modules inject the fed-sx transport), W4 (content/commerce/events run
|
|
||||||
live on `persist/log`), W8 (events+commerce run sagas on `lib/flow`). **The cores do not
|
|
||||||
depend on how they're hosted, persisted, or federated.**
|
|
||||||
|
|
||||||
**Corollary that makes the whole migration tractable:** because logic is separated from
|
|
||||||
rendering and storage, we can hold the **domain logic to parity** while **freely
|
|
||||||
redesigning the presentation** — the two are different layers with different rules.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. The gating insight: the cores are *ahead of the host*
|
|
||||||
|
|
||||||
The domain logic is mature. What is *not* yet production-grade is the **host trio** — and
|
|
||||||
that is the real critical path:
|
|
||||||
|
|
||||||
- **host-on-sx** — HTTP / request-response / session host (briefing exists; the OCaml SX
|
|
||||||
HTTP server already serves `sx.rose-ash.com`).
|
|
||||||
- **host-persist** — durable storage adapter (real disk/pg/ipfs) under `persist`'s
|
|
||||||
facets (content-addressed blob blocker recently closed).
|
|
||||||
- **fed-sx** — the real ActivityPub transport every core injects (well into m2).
|
|
||||||
|
|
||||||
> **So "when do we start?" answers itself: start when the host trio is production-grade,
|
|
||||||
> not when the cores are done — they mostly already are.** Prioritise the host loops over
|
|
||||||
> further domain features.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. The model: duplicate → cut over → diverge (per slice)
|
|
||||||
|
|
||||||
This is the "duplicate first, then change" approach, made precise. Each domain slice goes
|
|
||||||
through three phases independently:
|
|
||||||
|
|
||||||
**Phase A — Duplicate (hold logic to parity).** Stand the SX implementation of the slice
|
|
||||||
up *in parallel*, behind the existing edge, serving no users yet. Get its **domain/data
|
|
||||||
behaviour** to match Python (see §4 on how). Presentation can start as a rough port or an
|
|
||||||
early new design — it doesn't have to match.
|
|
||||||
|
|
||||||
**Phase B — Cut over (strangler flip).** Point the edge route for that slice at the SX
|
|
||||||
host. Python stays as instant rollback. The slice is now live on SX.
|
|
||||||
|
|
||||||
**Phase C — Diverge (change freely).** With the slice live and validated, evolve the
|
|
||||||
look/feel and functionality on the SX side. The validated domain logic underneath is
|
|
||||||
untouched, so UX/feature changes can't silently corrupt data.
|
|
||||||
|
|
||||||
You never rewrite the whole platform at once; you walk slices through A→B→C, oldest tree
|
|
||||||
strangled last.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. The two techniques, and how "we'll change things" reshapes them
|
|
||||||
|
|
||||||
### Strangler edge
|
|
||||||
The edge (Caddy) is the front door every request hits. Add routing rules so **one route
|
|
||||||
at a time** goes to the SX host while everything else still goes to Python. Properties:
|
|
||||||
the site is never half-broken; any single route flips back to Python instantly; the old
|
|
||||||
app is strangled route-by-route. (Opposite of big-bang swap, which is how these die.)
|
|
||||||
|
|
||||||
### Shadow diff — split by layer
|
|
||||||
Run the new version on real traffic in the background, discard its output, and **log how
|
|
||||||
it differs** from Python. Flip the edge only when diffs are zero/intended.
|
|
||||||
|
|
||||||
But because we *intend* to change look/feel + functionality, parity is a tool we apply
|
|
||||||
**only where we want sameness**, not a straitjacket:
|
|
||||||
|
|
||||||
| Layer | Want parity? | Oracle |
|
|
||||||
|---|---|---|
|
|
||||||
| **Domain/data** (totals, tax, permissions, what's stored, who-sees-what) | **YES — silent difference = data corruption** | shadow-diff at the *core* boundary; deterministic cores → replay real request logs through the harness and diff |
|
|
||||||
| **Presentation/UX** (HTML, layout, look, feel, flows) | **NO — this is what we're changing** | manual QA + design review; this is the Phase-C divergence |
|
|
||||||
|
|
||||||
Practical shape: shadow-diff hits the **domain core's output** (the computed order, the
|
|
||||||
visible-activity set, the permission decision) — not the rendered HTML. The deterministic,
|
|
||||||
harness-replayable cores are the single biggest advantage we have here; it's the same
|
|
||||||
parity discipline that made the A1 conformance migration safe (one reference slice, hard
|
|
||||||
parity gate, revert on mismatch).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Readiness gates (start the production migration when ALL hold)
|
|
||||||
|
|
||||||
1. **Host trio production-grade** — host-on-sx (HTTP/session), host-persist (durable
|
|
||||||
adapter), fed-sx (AP transport) — each conformance-green.
|
|
||||||
2. **Data-migration story exists** — a way to get existing production Postgres state into
|
|
||||||
`persist` event streams (event-source the current state, or dual-write during overlap).
|
|
||||||
This is the honest long-pole; it is *not* domain logic and nobody has built it yet.
|
|
||||||
3. **One vertical slice proven end-to-end** at data-parity in production — the reference
|
|
||||||
migration, the way the conformance loop migrated one subsystem before the rest.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Sequencing
|
|
||||||
|
|
||||||
1. **Host trio first** (critical path — it's behind the cores).
|
|
||||||
2. **Build the strangler edge + shadow-diff harness** as first-class tooling: edge routing
|
|
||||||
rules + a dual-run logger that diffs *core outputs* (not HTML) and stores discrepancies.
|
|
||||||
3. **First slice = lowest risk × highest readiness × cleanest data oracle.**
|
|
||||||
Recommended: **the blog read path (content-on-sx)** or **the feed read path**
|
|
||||||
— read-heavy, no money, CRDT/versioning + `page.sx` HTML already exist, and the data
|
|
||||||
oracle is clean. *Avoid cart/orders/payments first* (transactional + SumUp webhooks =
|
|
||||||
highest blast radius).
|
|
||||||
4. **Persistence-first, federation-last.** Land host-persist + migrate per-domain event
|
|
||||||
stores before any cutover. Do fed-sx federation as a *coordinated* cut near the end —
|
|
||||||
W1 shows all 7 cores light up federation together once the shared transport ships.
|
|
||||||
5. **Walk the remaining slices A→B→C**, retiring Python routes as each cuts over.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. The honest long tail (mostly host + adapters, not cores)
|
|
||||||
|
|
||||||
The cores are pure domain logic; the production *tail* is not in them yet and is most of
|
|
||||||
the remaining real effort:
|
|
||||||
|
|
||||||
- Auth: first-party cookies / Safari-ITP, CSRF, silent SSO, grant caching.
|
|
||||||
- Cross-cutting: rate limiting, observability/metrics, error pages, caching.
|
|
||||||
- Integrations: SumUp payment + webhooks, Ghost CMS sync.
|
|
||||||
- Presentation: the actual HTMX templates + CSS (this is also where the redesign happens).
|
|
||||||
- **Live data migration** — the single biggest non-core workstream.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Concrete next steps
|
|
||||||
|
|
||||||
1. Treat the **host trio** as the fleet's critical path; prioritise over more domain features.
|
|
||||||
2. Stand up the **strangler edge + core-level shadow-diff harness** as a tool.
|
|
||||||
3. Prove **one slice** (blog/content read path) end-to-end in production as the reference.
|
|
||||||
4. **Spec the Postgres → persist data migration** (the long-pole nobody has started).
|
|
||||||
5. Then walk slices through duplicate → cut over → diverge, redesigning UX in Phase C.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Why this is low-risk despite being a platform rewrite
|
|
||||||
|
|
||||||
- It's **wiring host-agnostic cores to a host**, not rewriting domain logic from scratch.
|
|
||||||
- The **strangler edge** means the site always works and any route reverts in seconds.
|
|
||||||
- **Deterministic cores** make data-parity *mechanically checkable* (replay + diff), so
|
|
||||||
correctness isn't a matter of faith.
|
|
||||||
- **Logic/presentation separation** lets us change look/feel + functionality (Phase C)
|
|
||||||
*without* re-risking the validated domain logic.
|
|
||||||
- It's the **same discipline that just shipped A1**: one reference migration, a hard
|
|
||||||
parity gate, honest exclusions, verify-before-merge.
|
|
||||||
Reference in New Issue
Block a user