mod: Ext 12 — temporal burst detection, 292/292
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Has been cancelled

Reports gain an :at tick (deterministic, supplied). mod/decide-temporal counts
reports about a subject within [now-window, now], asserts burst_count/2, and a
(:burst-at-least K) rule fires only on a real burst. 3 reports at 10/11/12 → hide;
3 at 1/2/12 (window 5) → keep, while the plain count rule escalates both. Fifth
report field threaded through rebuild helpers, non-breaking. +15 tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-06 19:00:51 +00:00
parent 8292607e38
commit 92addf5146
8 changed files with 285 additions and 12 deletions

View File

@@ -20,6 +20,7 @@ PRELOADS=(
lib/mod/trace.sx
lib/mod/whatif.sx
lib/mod/batch.sx
lib/mod/temporal.sx
lib/mod/lifecycle.sx
lib/mod/audit.sx
lib/mod/api.sx
@@ -42,4 +43,5 @@ SUITES=(
"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!)"
)

View File

@@ -49,6 +49,8 @@
;; (:reporters-at-least 2) → report(Id, _, Sr), setof(Br, report(_, Br, Sr), Bsr),
;; length(Bsr, Nr), Nr >= 2 (distinct reporters;
;; needs the quorum engine which asserts every report)
;; (:burst-at-least 3) → report(Id, _, Sb), burst_count(Sb, Nb), Nb >= 3
;; (reports in a time window; needs the temporal engine)
(define
mod/cond->goal
@@ -88,6 +90,12 @@
", _, 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

View File

@@ -1,14 +1,15 @@
;; lib/mod/schema.sx — report representation + Prolog fact generation.
;;
;; A report is a dict {:id :by :about :reason :evidence :attrs :signals}.
;; 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 :reason reason}))
(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)))
@@ -27,6 +28,10 @@
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)))
@@ -35,25 +40,51 @@
(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) {:attrs attrs :id (mod/report-id r) :signals sigs :by (mod/report-by r) :evidence evs :about (mod/report-about r) :reason (mod/report-reason r)}))
(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*
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*
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*
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

View File

@@ -1,8 +1,8 @@
{
"lang": "mod",
"total_passed": 277,
"total_passed": 292,
"total_failed": 0,
"total": 277,
"total": 292,
"suites": [
{"name":"decide","passed":31,"failed":0,"total":31},
{"name":"audit","passed":29,"failed":0,"total":29},
@@ -16,7 +16,8 @@
{"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":"batch","passed":17,"failed":0,"total":17},
{"name":"temporal","passed":15,"failed":0,"total":15}
],
"generated": "2026-06-06T18:55:47+00:00"
"generated": "2026-06-06T19:00:19+00:00"
}

View File

@@ -1,6 +1,6 @@
# mod scoreboard
**277 / 277 passing** (0 failure(s)).
**292 / 292 passing** (0 failure(s)).
| Suite | Passed | Total | Status |
|-------|--------|-------|--------|
@@ -17,3 +17,4 @@
| trace | 15 | 15 | ok |
| whatif | 13 | 13 | ok |
| batch | 17 | 17 | ok |
| temporal | 15 | 15 | ok |

62
lib/mod/temporal.sx Normal file
View 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"})))))))))

156
lib/mod/tests/temporal.sx Normal file
View 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}))

View File

@@ -16,7 +16,7 @@ federation extension.
## Status (rolling)
`bash lib/mod/conformance.sh`**277/277** (roadmap + 11 extensions complete)
`bash lib/mod/conformance.sh`**292/292** (roadmap + 12 extensions complete)
## Ground rules
@@ -147,6 +147,12 @@ lib/mod/fed.sx
derivation goal-by-goal with `[proved]`/`[unproved]` marks and unification
bindings. E.g. `Report rc: escalate (rule: repeated-escalate)` … `[proved]
report(rc, B, S), report_count(S, N), N >= 3 {B=ann, N=3, S=dave}`.
- [x] **Ext 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
@@ -196,6 +202,12 @@ lib/mod/fed.sx
## Progress log
- **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