Honest Early-Warning Auditor
July 16, 2026 · View on GitHub
scpn_phase_orchestrator.evaluation scores whether an early-warning detector has
real skill, not just a high detection rate. A detector that alarms on 90 % of
pre-transition events is worthless if it also alarms on 90 % of transition-free
nulls; the honest question is whether it alarms on events more often than its own
false-alarm rate explains. This subsystem answers that question for any
detector — the SCPN suite, an AR(1)/Kendall-τ trend baseline, or a black-box deep
classifier — because it reads only the per-segment score each one emits.
Why this exists: the early-warning-signals literature has repeatedly found that most indicators sit at chance once a matched false-alarm rate and a null model are imposed (Boettiger & Hastings 2012 asked for exactly this discipline; O'Brien & Clements 2023 confirmed the null result on real data). A packaged, detector-neutral harness that enforces that discipline — and seals the verdict — did not exist. That harness is the asset here, above any one detector.
What an audit reports
audit_detector takes two arrays of per-segment scores — one on genuine
pre-transition event segments, one on transition-free null segments — and
returns a [DetectorAudit][scpn_phase_orchestrator.evaluation.auditor.DetectorAudit]:
matched_threshold— the alarm threshold calibrated from the null scores so at most a target fraction of nulls alarm. It is placed just above an order statistic of the nulls, never on a fixed grid, so a detector whose nulls need a high gate is not silently clipped.-infmeans the target permitted every null to alarm (the gate is fully open).achieved_false_alarm— the false-alarm rate the threshold actually held, reported alongside the target so a detector held below (or, in a degenerate corpus, unable to hold) the target is visible rather than assumed.detection_rate— the fraction of event segments that alarmed at that threshold.p_value(the headline) — the one-sided label-permutation p-value: under the exchangeability null that events and nulls are interchangeable, the fraction of random relabellings whose event-slot alarm count reaches the observed count, add-one corrected so it is never zero. A small p-value means the events alarmed more than the matched false alarm on this corpus.
A convention flag beats_chance (p_value < alpha) is offered for convenience;
the p-value itself is the honest quantity and is always reported. Higher score
means more evidence of a transition — orient a falling statistic by negating it.
Convention alpha defaults to 0.05. audit_scoring_detector is the convenience
wrapper for a detector still expressed as a callable on a raw window: it applies
the scoring function to each event and null series, then defers to audit_detector.
A worked audit distinguishes skill from no skill
bench/honest_auditor_worked_example.py audits two detectors on one synthetic
corpus of AR(1) event windows (rising autocorrelation — the textbook
critical-slowing-down signature) and white-noise nulls. Both arms are zero-mean
with the same marginal variance, so the window mean is genuinely uninformative and
serves as an honest negative control:
$ python -m bench.honest_auditor_worked_example
lag1-autocorrelation target_fa=0.10 achieved_fa=0.100 detect=1.000 p=9.999e-05 beats_chance=True hash=d97f55721c09
window-mean-control target_fa=0.10 achieved_fa=0.050 detect=0.050 p=0.6215 beats_chance=False hash=52242831ed23
The lag-1 autocorrelation detector — a real critical-slowing signal — beats chance decisively; the window-mean control lands at chance. Both verdicts are read from scores alone, so a competitor's classifier would be judged on identical footing.
A head-to-head between two real detectors
bench/auditor_detector_head_to_head.py goes further: it audits the real SCPN
modal envelope-growth detector
(modal_growth_score) against a published
competitor — the Dakos et al. 2008 AR(1)/Kendall-τ rising-autocorrelation trend —
on two synthetic-but-honest regimes (a growing oscillatory mode, and a monotone
rising-autocorrelation slowdown). With clearly skilful detectors the permutation
p-value saturates (both beat chance), so the auditor's detection rate at the
matched false alarm is the discriminator, and it is regime-dependent:
$ python -m bench.auditor_detector_head_to_head
oscillatory scpn-modal-growth achieved_fa=0.100 detect=1.000 p=9.999e-05 beats_chance=True
oscillatory ar1-kendall-tau achieved_fa=0.100 detect=0.775 p=9.999e-05 beats_chance=True
monotone scpn-modal-growth achieved_fa=0.100 detect=0.550 p=9.999e-05 beats_chance=True
monotone ar1-kendall-tau achieved_fa=0.100 detect=0.775 p=9.999e-05 beats_chance=True
The envelope-growth detector leads on the oscillatory regime, the AR(1) competitor leads on the monotone one — the eigenvalue-regime-map finding, adjudicated without bias by one matched-false-alarm + permutation test. Real field data would replace the synthetic corpora without changing the auditor. This is the integration proof: the productised auditor plugs into actual detector code, not just toy scorers.
Sealing an audit
seal_detector_audit binds a verdict to its corpus provenance — an identifier and
a caller-supplied capture timestamp — under a SHA-256 over its canonical JSON,
reusing the same hashing path as the assurance bundle. Recomputing
the hash from the recorded fields detects any later edit, so a published audit
verdict cannot be quietly altered. A fully open (-inf) threshold is serialised to
the string "-inf" so the record stays strict JSON. The sealed record carries an
explicit disclaimer: an audit measures skill on the supplied corpus only and is
not a certification of field performance.
Auditing from the command line
spo audit-detector runs the same audit without writing Python, so a detector's
skill can be checked from a scores file. The file is a JSON object with
event_scores and null_scores arrays of per-segment scores (higher means more
evidence of a transition) and an optional detector_name:
spo audit-detector scores.json \
--target-false-alarm 0.10 \
--corpus-id grid-2026 \
--captured-at 2026-07-07T15:00:00+02:00
Without --corpus-id/--captured-at the command prints the bare verdict; supply
both (they must be given together) to seal it into a hash-addressed record.
--output also writes the JSON to a file. Score entries must be finite numbers —
a missing key, an empty list, or a non-numeric or non-finite entry is an error,
never a silently dropped score. The command reads a local file and prints JSON; it
never actuates, signs, or reaches the network.
Skill primitives
For callers composing their own harness, the detector-agnostic primitives are
public: calibrate_score_threshold (matched-false-alarm calibration),
matched_false_alarm_rate, permutation_significance_from_alarms (the
exchangeability test), and surrogate_rank_pvalue (the single-statistic
counterpart against a surrogate ensemble).
::: scpn_phase_orchestrator.evaluation.auditor
::: scpn_phase_orchestrator.evaluation.skill
::: scpn_phase_orchestrator.evaluation.record
Cross-domain meta-analysis
detector_meta_analysis reads the committed detector-evidence aggregates under
examples/real_data/*/, normalises each detector's performance into a common
table, ranks detectors per domain and overall, and renders a Markdown ranking
report with a refinement backlog.
::: scpn_phase_orchestrator.evaluation.detector_meta_analysis
Cross-domain transfer audit
cross_domain_transfer asks the harder question the meta-analysis cannot: does a
detector trained on one domain carry real skill when applied to another?
audit_cross_domain_transfer reuses audit_detector for three arms at the same
matched false alarm — the transferred detector on the target, a target-trained
detector on the target (the detectability ceiling), and a shuffled-source floor —
and returns one of transfer_positive, transfer_null, or transfer_negative. A
transfer that beats its own null but not the scrambled floor is reported
transfer_null, never upgraded; a target detectable within-domain whose transfer
arm shows no skill is a decisive transfer_negative — the honest verdict the
recorded CHB-MIT cross-subject null (AUC ≈ 0.50) demands.
::: scpn_phase_orchestrator.evaluation.cross_domain_transfer