From 92addf51464ff5aa6436f3d469f1fa2c1de1299e Mon Sep 17 00:00:00 2001 From: giles Date: Sat, 6 Jun 2026 19:00:51 +0000 Subject: [PATCH] =?UTF-8?q?mod:=20Ext=2012=20=E2=80=94=20temporal=20burst?= =?UTF-8?q?=20detection,=20292/292?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- lib/mod/conformance.conf | 2 + lib/mod/policy.sx | 8 ++ lib/mod/schema.sx | 43 +++++++++-- lib/mod/scoreboard.json | 9 ++- lib/mod/scoreboard.md | 3 +- lib/mod/temporal.sx | 62 +++++++++++++++ lib/mod/tests/temporal.sx | 156 ++++++++++++++++++++++++++++++++++++++ plans/mod-on-sx.md | 14 +++- 8 files changed, 285 insertions(+), 12 deletions(-) create mode 100644 lib/mod/temporal.sx create mode 100644 lib/mod/tests/temporal.sx diff --git a/lib/mod/conformance.conf b/lib/mod/conformance.conf index 154e11b2..9f348d59 100644 --- a/lib/mod/conformance.conf +++ b/lib/mod/conformance.conf @@ -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!)" ) diff --git a/lib/mod/policy.sx b/lib/mod/policy.sx index 3ac585c8..758e2cae 100644 --- a/lib/mod/policy.sx +++ b/lib/mod/policy.sx @@ -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 diff --git a/lib/mod/schema.sx b/lib/mod/schema.sx index 32d907c6..fc32c133 100644 --- a/lib/mod/schema.sx +++ b/lib/mod/schema.sx @@ -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 diff --git a/lib/mod/scoreboard.json b/lib/mod/scoreboard.json index ebcd4326..40cf2c07 100644 --- a/lib/mod/scoreboard.json +++ b/lib/mod/scoreboard.json @@ -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" } diff --git a/lib/mod/scoreboard.md b/lib/mod/scoreboard.md index eb159e6f..f66956bc 100644 --- a/lib/mod/scoreboard.md +++ b/lib/mod/scoreboard.md @@ -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 | diff --git a/lib/mod/temporal.sx b/lib/mod/temporal.sx new file mode 100644 index 00000000..a286c652 --- /dev/null +++ b/lib/mod/temporal.sx @@ -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"}))))))))) diff --git a/lib/mod/tests/temporal.sx b/lib/mod/tests/temporal.sx new file mode 100644 index 00000000..287992e5 --- /dev/null +++ b/lib/mod/tests/temporal.sx @@ -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})) diff --git a/plans/mod-on-sx.md b/plans/mod-on-sx.md index 684e4eda..0d394230 100644 --- a/plans/mod-on-sx.md +++ b/plans/mod-on-sx.md @@ -16,7 +16,7 @@ federation extension. ## Status (rolling) -`bash lib/mod/conformance.sh` → **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