Alerts that grade their own promises.
Predicts an incident seconds before it happens, then checks whether it was right. This product extrapolates short-horizon kinematic trajectories to warn of an incident before it occurs, ships a counterfactual explanation with every alert (for example, "this would not have fired if the primary track were stationary"), and grades every prediction fulfilled or falsified against what actually happened afterward. Two literatures usually stop short of each other on a live stream: anticipation models predict and stop at a risk score, and causal explanation systems only explain a clip after it already happened. This product fuses both on one observation stream, so the causal claim is either true of an executed replay or it is not made at all.
battery_200.jsonl, scenario_battery_rows.jsonl,
scenario_battery_summary.json), not a live compute demo.PYTHONPATH=src .venv/bin/python -m pytest tests/ -q printed
24 passed in 4.72s, and re-running
experiments/scenario_battery.py --seed 0 produced a rows file and a summary file
that are byte-identical (verified with diff, no output) to the committed evidence
used on this page.Picture an operator watching a bank of camera feeds with a predictive alerting layer running underneath. The system's whole value proposition is lead time: instead of telling the operator "someone just entered the restricted zone," it says "at this rate, someone will enter the restricted zone in about six seconds." That extra six seconds is the entire point, it is the difference between a warning and an incident report.
But a system that only ever makes claims and never checks them is not trustworthy, it is just loud. If a "hazard in six seconds" warning turns out wrong three times out of four for one kind of alert and right most of the time for another, and the operator has no way to tell which kind of warning they are looking at, they will do exactly what any person does with an alarm that keeps crying wolf: they tune it out, on every kind, including the one that was actually reliable. At that point the predictive layer has made things worse, not better, because operators now discount real warnings along with the noisy ones.
The fix this product commits to is not a claim that the underlying prediction is perfect. Its
kinematic forecaster is a straightforward constant-velocity rollout, and it says so plainly. The
fix is a bookkeeping discipline: every alert is filed as a checkable promise the moment it fires,
that promise is later graded against what actually happened, and the resulting precision is
disclosed per alert kind, including the kinds where the number is bad. An operator (or an
evaluator) can then decide how much to trust a zone_entry alert versus a
crowd_buildup alert, because the two have different, disclosed track records, instead
of one blended "the system is X% accurate" number that hides which parts are actually worth
tuning-in for.
Three mechanisms, in order: a kinematic model raises predictive alerts before the hazard occurs, each alert carries a counterfactual explanation backed by an executed replay (not narration), and a falsification ledger grades every prediction against what actually happened. The diagram below follows one alert through the full loop.
WorldState (src/precausal/state.py) keeps a bounded, robust-smoothed
per-track observation history: the median of pairwise finite differences, so one glitchy detection
cannot spike the forecast. Predictor.step (src/precausal/predict.py)
rolls each track forward at constant velocity to raise three checkable alert kinds:
zone_entry (the forecast crosses into a restricted polygon within the horizon),
convergence (two tracks' time-to-closest-approach falls under a distance within the
horizon), and crowd_buildup (a zone's occupancy trend crosses its threshold within the
horizon, computed only while occupancy is still below threshold: once the threshold is hit, that is
reactive alerting's job, not prediction's). Learned forecasters, trajectory nets, relation
forecasting over an E3-style scene graph, plug in behind the exact same Predictor.step
contract at the GPU phase; the kinematic core stays as the auditable baseline any learned model has
to beat.
explain() (src/precausal/counterfactual.py) is not narration. For a
fired alert, it takes the same retained observation history the alert was computed from
(WorldState.snapshot()) and re-runs a fresh, forked predictor over it with one
candidate cause surgically removed: either presence ablation (delete a track's
observations, "would this fire if they weren't there?") or motion ablation
(freeze a track at its first-seen position, displacement removed, everything else kept, "is it
their presence or their approach?"). "Would not have fired if X" is claimed only when that
ablation's replay actually fails to reproduce the same predicted event. No generative world model,
no clip narration: every sentence the engine outputs corresponds to an executed replay you could
rerun yourself.
An alerting system that never gets graded is just an opinion. FalsificationLedger
(src/precausal/grounding.py) is the product's honesty metric. Every
PredictedAlert lands in the ledger as a pending prediction with an objective
fulfillment condition (the track actually enters the zone; the pair actually closes to convergence
distance; the zone actually crosses its crowd threshold) and a deadline (predicted event time plus
tolerance). As real observations keep flowing, the ledger resolves each pending prediction to
fulfilled (recording the actual, not predicted, lead time) or falsified
(deadline passed, it did not happen). stats() rolls this up into per-alert-kind
precision and mean actual lead time, the number a config plane should watch when tuning
thresholds, and the number that stops a predictive alerting system from degenerating into an
unchecked fear generator. Worked numbers for all three mechanisms are in Measured results below.
Every one of the 210 rows below is a real output of
experiments/scenario_battery.py --seed 0, embedded verbatim as JSON (trimmed to the
fields this viewer draws). Pick a kind, pick a scenario, and press play to watch the predictor's
own timeline: when it raised the alert, how far ahead it claimed the incident would occur, and
what the ledger decided once time actually passed.
Three sets of numbers, all reproducible from the commands shown: how the falsification ledger graded a prior battery of alerts, precision and recall per alert kind on the current 210-scenario battery, and how often counterfactual claims held up on replay.
Over an earlier 200-scenario battery, 150 alerts fired (75% of scenarios), and each was later marked fulfilled or falsified against the actual outcome. The falsified count is published as prominently as the fulfilled count.
Source: evidence/battery_200.jsonl, trailing summary row
(n_alerts_fired=150, ledger_verdict_counts), reconciled arithmetically
against all 200 individual rows: 87 + 63 = 150, 87 + 63 + 50 = 200.
Recall is 1.0 for every kind in this battery: the system never misses a true incident here. The cost of that is precision below 1.0, and it varies a lot by scenario kind. It is reported per kind rather than averaged into one flattering number.
| kind | n | TP | FP | precision | recall | mean abs lead error (s) |
|---|---|---|---|---|---|---|
| zone_entry | 45 | 30 | 15 | 0.667 | 1.0 | 0.403 |
| convergence | 35 | 23 | 12 | 0.657 | 1.0 | 1.349 |
| convergence_multi | 10 | 5 | 5 | 0.500 | 1.0 | 0.430 |
| crowd_buildup | 45 | 27 | 18 | 0.600 | 1.0 | 0.962 |
Source: evidence/scenario_battery_summary.json,
per_kind_metrics, and BATTERY_RESULTS.md, "Per-alert-kind precision /
recall / lead-time error." Produced by
PYTHONPATH=src .venv/bin/python experiments/scenario_battery.py --seed 0 --out
evidence/scenario_battery_rows.jsonl --summary-out evidence/scenario_battery_summary.json.
crowd_buildup is honestly still the weakest alert kind (precision 0.600, the lowest of the four kinds), up from a prior 0.356 after a fix to Predictor._crowd() (a minimum occupancy-history time span plus consecutive confirmation ticks before an alert locks in). Of its 18 remaining falsified predictions, all are now the "never crowded" case (occupancy growth capped below threshold for a reason the trend has no way to see in advance), not the previous over-optimistic-lead-time failure mode, which the fix eliminated entirely. This is a real, structural improvement, not a scenario-generation artifact. convergence (0.657) and zone_entry (0.667) still sit above it, consistent with kinematic constant-velocity forecasting over individual tracks being more predictable than trend-slope extrapolation over a bounded, external-cap occupancy process. This is stated as-is in the repo's own BATTERY_RESULTS.md and reproduced, not softened, here.
75 of the 210 scenarios are designed to have no genuine hazard at all: a wandering track with no
designed target (no_incident), a track starting just outside a zone and heading
directly away (departing), a near-zero-speed track parked near a zone boundary
(stationary), and a track genuinely approaching but staying beyond the 10 s horizon
for the whole observed window (out_of_horizon). Any alert fired on these is, by
construction, a false positive.
| kind | n | false positives | rate |
|---|---|---|---|
| no_incident | 20 | 0 | 0.000 |
| departing | 20 | 0 | 0.000 |
| stationary | 20 | 4 | 0.200 |
| out_of_horizon | 15 | 2 | 0.133 |
| overall | 75 | 6 | 0.080 |
Source: BATTERY_RESULTS.md, "False-positive rate on the no-alert
scenarios," and evidence/scenario_battery_summary.json,
false_positive_rate.by_kind.
The false positives concentrate entirely in stationary and
out_of_horizon, both scenarios placed close to a zone boundary. The repo's own
root-cause analysis traces all 6 to the same mechanism: at high observation noise relative to a
short sample interval (for example noise_std_m=0.4 at dt_s=0.2),
WorldState's median-of-consecutive-differences velocity estimate, computed from only
the 3 warm-up observations required by min_history, can read an apparent speed several
times the track's true speed purely from jitter (observed cases: true speed 0.61 to 0.65 m/s read
back as 1.8 to 2.6 m/s). That apparent speed is enough to cross min_speed_ms and
produce a genuine, if spurious, zone_entry forecast. This is a legitimate robustness
limitation of the kinematic smoothing under high-noise, low-sample-rate conditions, not a harness
bug, and it is reported as-is.
Every alert carries an ablation-replay counterfactual, implemented in
src/precausal/counterfactual.py. A fresh WorldState and a forked
predictor replay the alert's own retained observation history with a candidate cause surgically
removed (presence ablation) or frozen at its first position (motion ablation), through the real
production Predictor.step, no reimplementation. A cause only lands in
would_not_fire_if when its ablation actually killed the alert on replay:
would_not_fire_if: [primary track removed]
still_fires_despite: [secondary track removed]
| kind | n explained | ablations checked | presence-removal necessary | motion-freeze necessary |
|---|---|---|---|---|
| zone_entry | 45 | 45 | 100.0% | 100.0% |
| convergence | 35 | 70 (2 entities x 35) | 100.0% | 98.6% |
| convergence_multi | 10 | 20 (2 entities x 10) | 100.0% | 100.0% |
| crowd_buildup | 45 | 267 (per-occupant) | 37.5% | n/a (presence only) |
Source: BATTERY_RESULTS.md, "Counterfactual stability
(ablation-replay necessity)," and evidence/scenario_battery_summary.json,
counterfactual_stability.
The repo's own read, reproduced numerically here: a crowd buildup with margin above its threshold genuinely does not depend on any single occupant, so the engine correctly reports necessity of 37.5% rather than inflating per-person blame, up from 21.7% pre-fix because the confirmation-tick requirement means the alert that finally fires is, on average, closer to its threshold (more accumulated occupancy) than the pre-fix alert that fired on the very first eligible window, and buildups closer to threshold are exactly the regime where individual occupants are more likely to be necessary; that is consistent with the pre-existing unit test test_crowd_counterfactual_no_single_person_necessary. Convergence's one asymmetric case (98.6%, not 100%, motion-freeze) is a real, explainable geometric property: in 1 of 70 checks, freezing the slower of two converging tracks did not kill the alert on replay, because the faster track's own continued approach was sufficient to close the gap on its own. explain() correctly reported this as still_fires_despite rather than manufacturing a false necessity claim.
Predictor.step contract, but that replacement
has not happened in this code.stationary and out_of_horizon in Measured results above).crowd_buildup's precision (0.600) improved from a prior 0.356 after a fix requiring a minimum occupancy-history time span and consecutive confirmation ticks before an alert locks in (see BATTERY_RESULTS.md, "crowd_buildup precision fix"). It is still the lowest of the four kinds, and all 18 remaining false positives are the "never crowded" case (a real, structural limit of trend-only forecasting against a process with an unobservable external cap), not the previous over-optimistic-lead-time failure mode, which the fix eliminated entirely.convergence_multi (3 tracks, n=10, precision 0.500) is too small a sample to
trust on its own. It exists mainly to confirm the predictor correctly ignores a decoy's pair
combinations rather than to estimate a stable precision; a wider sweep of that specific kind is
a natural follow-up before citing its number standalone.zone_entry start position that
could land inside its own target zone, and an out_of_horizon generator that measured
time-to-zone-center instead of time-to-zone-boundary) before any aggregate number here was
trusted. Both are documented in BATTERY_RESULTS.md, "Harness bugs found and fixed
while building this battery," in the same spirit the ledger enforces on the product itself.tests/test_precausal.py covering kinematics, all three alert kinds, both ablation modes, both ledger outcomes, and the crowd_buildup fix regressions; 4 in tests/test_scenario_battery.py covering battery determinism), not a broad suite.210 seeded scenarios generated by experiments/scenario_battery.py (seed 0), driven
through the real production code (WorldState, Predictor,
FalsificationLedger, counterfactual.explain), plus an earlier,
narrower 200-scenario battery (evidence/battery_200.jsonl) used for the ledger
illustration above. Every row is a JSON object with the scenario's parameters, the alert's
prediction, the ground-truth outcome, and the ledger's verdict. Full rows are embedded in the
interactive demo on this page and in the companion dataset linked in the footer.
Synthetic. Every scenario is a procedurally generated kinematic trajectory (speed, heading, zone geometry, observation noise, and sample rate all swept by a seeded random generator), not footage from a real camera or a real incident. That is stated plainly in the disclaimer above and in the Honest limitations section: real-world camera and tracker validation has not been performed yet.
The engine's input contract is a plain observation stream, JSONL rows of
{t, track_id, x, y[, class]} in a metric ground frame, so any tracker that can emit
that (or be adapted to emit it) is a valid input. Pair it with a ground-projection product such as
fixed-camera-3d to turn pixel tracks into a metric frame, or feed it any consistent
planar frame already available. The code itself is proprietary and not publicly released; see
"How do I get access" below.
The source is proprietary and is not published anywhere public. The public artifacts are the evidence, the benchmark dataset, and this page. For access or a partnership inquiry, contact dhi-tech.com.
Nothing is hidden or softened. Every PredictedAlert is filed with an objective
fulfillment condition and a deadline. If real observations show the deadline passed without the
condition becoming true, the ledger resolves that prediction to falsified, and
stats() counts it against that alert kind's precision exactly as it would count a
fulfilled prediction in its favor. Falsified counts are published as prominently as
fulfilled counts everywhere on this page (see the 87-falsified, 63-fulfilled ledger figures above),
and no aggregate number on this page is computed by excluding or downweighting falsified rows.
Because a single blended number would hide exactly the information an operator needs: which
kinds of alerts to trust more. Precision ranges from 0.500
(convergence_multi) to 0.667
(zone_entry) across the four kinds with confusion-matrix metrics in this battery. A
blended average across all of them would land somewhere in the middle and tell an operator
nothing about which specific alert kind is unreliable. The Problem section above describes exactly
this failure mode: alarm fatigue happens when operators cannot tell reliable warnings from
unreliable ones, and a single flattering aggregate number recreates that problem instead of solving
it.