scripts/record-baseline.py and capture_baseline_to_disk now
compute per-subcarrier circular mean + variance of phases when the
WS stream carries them (ADR-106). Saved as per_subcarrier_phase_mean
+ per_subcarrier_phase_var in baseline.json.
Server loads them into PHASE_BASELINE_PER_SUB; phase_drift_update
computes a per-tick score (mean circular distance / π over
subcarriers with baseline variance < 0.30) and stores it in
PHASE_DRIFT. Surfaces as PerNodeFeatureInfo.phase_drift_score
(skip-if-none). Honesty contract: emits None below
PHASE_DRIFT_MIN_USABLE = 16 usable subcarriers.
Legacy baselines without phase fields fall back to amplitude-only
behaviour with no change.
Co-Authored-By: claude-flow <ruv@ruv.net>
Problem from ADR-103 v1: persisted NBVI-subset mean (19.86 in operator's
recording) drifted out of comparability after server restart because
NBVI re-selected a different top-12 subset, yielding a different mean
from the same channel. classifier saw current/baseline ratio > 1 even
in clearly empty room.
Fix:
1. Separate FULL-broadband mean (all non-zero subcarriers) from
NBVI-subset mean in amp_presence_override. NBVI subset still drives
CV / motion sensitivity. FULL is what gets compared to the
persistent baseline — stable across NBVI re-selection.
2. baseline.json schema v2: full_broadband_{mean,p50,p95,std,cv_pct}
replaces NBVI-only p95_amp/mean_amp. Loader prefers full_*; falls
back to legacy fields for backward compat.
3. NBVI Step 1 quiet-window finder (ESPectre): nbvi_select_top_k now
slides a window across the calibration history, picks the lowest-CV
sub-window, and ranks subcarriers using only that. Robust to brief
motion during the calibration buffer.
4. scripts/record-baseline.py v2: emits v2 schema, computes
full-broadband stats per node, trims head/tail transients, picks
cleanest 30-s sub-window, also saves per_subcarrier_mean for future
subcarrier-level comparison.
Operator workflow now: step out → run script → restart server →
forget about the empty-room ritual forever.