Benchmark · v1 · null calibration

Measured false-alarm rates of common drift-detection configurations at canary scale

How often does each configuration alert when nothing happened — and how does that compare with what it states or implies? 500 seeded stable-agent histories per configuration, two scales, Wilson 95% intervals on every rate, one command to regenerate every number on this page.

measured 2026-08-09 dedrift 0.4.0 evidently 0.7.21 seeds 0–499 shipped

The question, and what this is not

Every drift tool implicitly promises: when I alert, something happened. Agent behavioral monitoring runs at canary scale — tens of rows per comparison window, by economic necessity — while most general-purpose drift machinery was designed and calibrated for production tables with thousands of rows. This page measures what six common configurations actually do on data with no drift and no config change, ever: the probability of at least one false alarm per check, and per 50-cycle monitored history, because that is how monitoring is used.

This is a validity-scale study, not a tool ranking. Tools built for thousand-row production tables are not wrong for being miscalibrated at 60-row windows — they are being used outside their domain of validity, and nothing warns the user. dedrift is included under identical conditions and reported with the same scrutiny, including its own uncalibrated diagnostic channel and the fixed-sample cumulative number that motivated its anytime-valid path.

Results — default suite scale (18 canaries × 7 repetitions)

126 records per cycle; reference window 378 rows (3 golden cycles), current window 126 rows. Rates are binomial over 500 independent seeded runs; brackets are Wilson 95% intervals.

ConfigurationDefault setting usedNominal / stated≥ 1 false alarm, per check≥ 1 false alarm, 50-cycle history
General-purpose configurations
PSI, folk threshold 0.110 reference-quantile bins; flag at PSI ≥ 0.1none stated500/500 = 100.0% [99.2, 100.0]
PSI, folk threshold 0.25same metric; flag at PSI ≥ 0.25 (“major”)none stated500/500 = 100.0% [99.2, 100.0]
Evidently — pooled table, ≥ 1 drifted columnDataDriftPreset 0.7.21, all defaultsnone stated (per-column tests at 0.05)356/500 = 71.2% [67.1, 75.0]
Evidently — pooled table, dataset verdictdefaults; drifted-column share ≥ 0.5none stated2/500 = 0.4% [0.1, 1.4]
Naive two-sample KS batteryα = 0.05 per (family × signature), no control5% per test only250/500 = 50.0% [45.6, 54.4]
dedrift, measured under the same protocol
dedrift PSI + validity guardidentical metric; refused where E[PSI] > 0.05none stated (diagnostic)0/500 = 0.0% [0.0, 0.8]
dedrift fixed-sample path (alerts)BH-FDR q = 0.05 + materiality, dual baselinesFDR q = 0.05 per check8/500 = 1.6% [0.8, 3.1]358/500 = 71.6% [67.5, 75.4]
dedrift flag channel (Page–Hinkley / guarded PSI)diagnostic flags, deliberately uncalibratednone stated — published limitation372/500 = 74.4% [70.4, 78.0]
dedrift anytime-valid pathe-processes + e-BH, golden baseline, twosample rates≤ 0.05 lifetime per epoch0/500 = 0.0% [0.0, 0.8]

k/n = runs with at least one false alarm over 500 seeded stable histories; brackets are Wilson 95% intervals. “—”: the configuration produces no such estimand. The dedrift flag channel and the PSI rows are diagnostics, not alerts; they are shown because hiding them would break the symmetry this table exists for. The anytime-valid path has no per-check cell: it makes no per-check claim, and its per-fold rate is over 25,000 folds against every other row's 500 runs, so printing it in that column would compare different estimands. Per-run and per-comparison raw rows: benchmark/results/.

Results — small scale (12 canaries × 5 repetitions)

60 records per cycle; reference 180 rows, current 60. This is the scale of dedrift's CI null-calibration gate, included so the scale-dependence is visible rather than asserted.

ConfigurationDefault setting usedNominal / stated≥ 1 false alarm, per check≥ 1 false alarm, 50-cycle history
General-purpose configurations
PSI, folk threshold 0.110 reference-quantile bins; flag at PSI ≥ 0.1none stated500/500 = 100.0% [99.2, 100.0]
PSI, folk threshold 0.25same metric; flag at PSI ≥ 0.25 (“major”)none stated500/500 = 100.0% [99.2, 100.0]
Evidently — pooled table, ≥ 1 drifted columnDataDriftPreset 0.7.21, all defaultsnone stated (per-column tests at 0.05)329/500 = 65.8% [61.5, 69.8]
Evidently — pooled table, dataset verdictdefaults; drifted-column share ≥ 0.5none stated0/500 = 0.0% [0.0, 0.8]
Naive two-sample KS batteryα = 0.05 per (family × signature), no control5% per test only268/500 = 53.6% [49.2, 57.9]
dedrift, measured under the same protocol
dedrift PSI + validity guardidentical metric; refused where E[PSI] > 0.05none stated (diagnostic)0/500 = 0.0% [0.0, 0.8]
dedrift fixed-sample path (alerts)BH-FDR q = 0.05 + materiality, dual baselinesFDR q = 0.05 per check16/500 = 3.2% [2.0, 5.1]420/500 = 84.0% [80.5, 87.0]
dedrift flag channel (Page–Hinkley / guarded PSI)diagnostic flags, deliberately uncalibratednone stated — published limitation349/500 = 69.8% [65.6, 73.7]
dedrift anytime-valid pathe-processes + e-BH, golden baseline, twosample rates≤ 0.05 lifetime per epoch1/500 = 0.2% [0.0, 1.1]

k/n = runs with at least one false alarm over 500 seeded stable histories; brackets are Wilson 95% intervals. “—”: the configuration produces no such estimand. The dedrift flag channel and the PSI rows are diagnostics, not alerts; they are shown because hiding them would break the symmetry this table exists for. The anytime-valid path has no per-check cell: it makes no per-check claim, and its per-fold rate is over 25,000 folds against every other row's 500 runs, so printing it in that column would compare different estimands. Per-run and per-comparison raw rows: benchmark/results/.

Why this happens — the arithmetic

None of the failure modes above are mysterious once the sampling arithmetic is written down. Each measured row has a closed-form prediction beside it.

PSI cannot say "stable" at small n

E[PSI] ≈ (B − 1) · (1/n + 1/m) — B bins, window sizes n, m (Yurdakul & Naranjo-style first-order null)

With the standard 10 bins at per-family canary scale (n = 63 reference rows, m = 21 current rows at suite scale; 30 vs 10 at small scale) the null expectation is 0.57 and 1.2 respectively — both multiples of the 0.25 "major shift" folk threshold, from sampling noise alone, on unchanged data. PSI is a large-sample index; below its validity scale the thresholds are tripwires laid inside the noise floor.

Uncontrolled per-column testing multiplies

P(≥ 1 false flag) = 1 − (1 − α)k — k independent per-column tests at level α

At α = 0.05 and k = 12 columns the independence prediction is 1 − 0.951246% per report; at k = 72 (family × signature) it is 97.5%. Independence overstates or understates once tests correlate or go conservative on discrete columns — which is why we measure rather than extrapolate. The per-column breakdown identifies exactly which columns break the arithmetic: low-cardinality integer channels (tool_call_count, steps, tool_order_inversions) are routed by the auto-selection heuristic to a chi-square test whose own small-cell validity condition is violated at these window sizes (the 0.7.21 implementation is scipy.stats.chisquare on the raw contingency counts — no merging, no exact fallback), and reject at 28–47% each on stable data while the well-behaved columns sit at 2–5%. Measured per column, pooled granularity:

Signature columnEvidently auto-selected testNull flag rate, suite scaleNull flag rate, small scale
args_schema_ok_allZ-test p_value4.0%2.0%
format_validZ-test p_value5.4%5.0%
had_errorZ-test p_value0.0%0.0%
latency_msK-S p_value5.4%3.6%
output_charsK-S p_value3.2%3.0%
output_wordsK-S p_value2.2%3.0%
refusalZ-test p_value6.2%4.6%
retriesZ-test p_value0.0%0.0%
stepschi-square p_value31.2%28.4%
tokens_outK-S p_value2.8%3.2%
tool_call_countchi-square p_value32.2%29.8%
tool_order_inversionschi-square p_value46.8%46.6%

Per-column false-flag rate over 500 stable runs, pooled granularity, Evidently DataDriftPreset 0.7.21 defaults. The three chi-square-routed columns are the low-cardinality integer channels; had_error and retries are structurally all-zero on the null, so their 0% is degenerate, not calibrated.

Per-check guarantees are not trajectory guarantees

P(≥ 1 alert by cycle T) = 1 − (1 − p)T ≈ 1 − e−pT — p per-check, T cycles

A perfectly calibrated per-check rate of 3% compounds to ≈ 78% over 50 checks. Any procedure whose control is per check — including dedrift's own fixed-sample path — accumulates false alerts without bound as monitoring continues. That arithmetic is the entire motivation for the anytime-valid path, whose claim is about the whole trajectory (ever-alert ≤ α), and why its row in the table is the only one whose nominal rate is a lifetime bound.

Methodology

Complete enough to reproduce without reading the code; the code is linked below for everything else.

Reproduction

Right of reply

Maintainers of measured tools are invited to respond; responses will be linked here, unedited, beside the table they address. Everything needed to check us first is public: the scripts, the raw per-run results, the seed list, and the pinned versions (linked above). If we misconfigured something, the table gets a correction entry in the changelog — not a quiet edit.

Changelog