wifi-densepose/docs/adr/ADR-104-per-subcarrier-drif...

7.6 KiB
Raw Blame History

ADR-104 — Per-Subcarrier Drift Presence Channel + NBVI FP-Rate Validation

Status: Accepted Date: 2026-05-17 Scope: v2/crates/wifi-densepose-sensing-server/src/main.rs (AMP_BASELINE_PER_SUB, AMP_DRIFT, amp_drift_for_node, amp_drift_max, amp_node_level, amp_classify_from_latest, nbvi_select_top_k Step 3), scripts/record-baseline.py (per_subcarrier_mean already saved).

Context

After ADR-103 the classifier triggers present_still only when the broadband mean of the NBVI-selected subset drops by ≥ 25 % from the loaded baseline. This works when the operator's body crosses the line of sight between AP and sensor — direct-component attenuation dominates. But:

  1. Off-axis presence: the operator sitting at a desk to the side of the AP-sensor line modulates only a handful of subcarriers (the ones whose Fresnel zone happens to brush their body). The broadband mean barely shifts; ADR-103 says absent even though someone is clearly in the room.
  2. NBVI Step 3: Pace's full NBVI pipeline picks top-K by raw NBVI score, then validates each candidate K by counting false positives the motion detector would produce on the calibration buffer, and keeps the K with the lowest FP rate. We were taking the raw top-12 without validation — fragile if one of the chosen subcarriers happens to overlap a noise source.

Decisions

D1 — Spectral drift score as a second presence channel

amp_presence_override per node now also computes a spectral drift score:

drift_k = (current_amp[k] - baseline_amp[k]).abs() / baseline_amp[k]    for baseline[k] > 1.0
drift   = mean(drift_k) across kept subcarriers

current_amp[k] = mean of the recent AMP_SHORT_WIN (90) frames' amplitude at subcarrier k. baseline_amp[k] = the per_subcarrier_mean vector saved by ADR-103's recording script.

Per-node drift is stashed in AMP_DRIFT: HashMap<u8, f64> so amp_node_level (per-node) and amp_classify_from_latest (global) can use it. Threshold AMP_DRIFT_PRESENCE_THRESH = 0.10 (10 % average per-subcarrier deviation) is empirical and consistent with the broadband-ratio trigger (drop ≥ 25 %, drift ≥ 10 %).

D2 — Trigger order in classifier

Per node (amp_node_snapshot):

1. CV ≥ 6× baseline_cv  → active
2. CV ≥ 3× baseline_cv  → present_moving
3. drift ≥ 10 %         → present_still   ← ADR-104 (off-axis)
4. mean / baseline < 0.75 → present_still ← ADR-101 (in-path)
5. otherwise            → absent

Global (amp_classify_from_latest) uses MAX CV / MAX drift / ANY baseline-drop across nodes. Either drop OR drift fires present_still.

D3 — Opportunistic loading

per_subcarrier_mean was already being written by scripts/record-baseline.py (line ~132, written as a list of ~56 floats per node) but the server ignored it. Now load_baseline_file parses it and populates AMP_BASELINE_PER_SUB. If absent (older baseline.json from before this ADR) → drift stays 0.0 → no behaviour change. Re-trigger calibration via the ADR-107 REST endpoint or auto- recalibrate to populate the field and activate the drift channel.

D4 — NBVI FP-rate validation (Step 3 of Pace's spec)

nbvi_select_top_k no longer returns the literal top-K. After ranking by NBVI score (Steps 1+2), it evaluates each candidate K ∈ {6, 8, 10, 12, 16, 20} clamped to the available subcarrier pool:

  • For each K: compute per-frame broadband mean over the top-K subset across the quiet window.
  • Slide a sub-window (length AMP_SHORT_WIN/3 ≈ 30 samples, stride sub_window/2) and count windows where rolling CV exceeds the moving-gate threshold (0.10).
  • Pick the K with the smallest FP count. Ties broken by smallest total NBVI score (less noisy subset wins).

Result: a subset that's stable AND non-FP-producing on the calibration window. If a top-12 NBVI candidate sneaks in a subcarrier overlapping a noise source, the FP count surfaces it and a smaller K wins instead.

Files Touched

v2/crates/wifi-densepose-sensing-server/src/main.rs
  - statics: AMP_BASELINE_PER_SUB, AMP_DRIFT
  - helpers: amp_baseline_per_sub_init, amp_drift_init,
             amp_drift_for_node, amp_drift_max
  - load_baseline_file: parse per_subcarrier_mean → AMP_BASELINE_PER_SUB
  - amp_presence_override: drift computation + stash
  - amp_node_level: drift trigger (uses MAX for cross-node)
  - amp_node_snapshot: per-node drift trigger (overrides MAX)
  - amp_classify_from_latest: any-node drift trigger in global fusion
  - nbvi_select_top_k: Step 3 FP-rate validation
docs/adr/ADR-104-per-subcarrier-drift-presence.md  (this)

Implementation commit: 6212b17e.

Verified Acceptance

Server boot log (using existing v1 baseline.json without per_subcarrier_mean):

baseline: loaded 2 node overrides from data/baseline.json
          (node1=27.04, node2=14.72; node1_cv=2.62%, node2_cv=3.65%)

Without per_subcarrier_mean in the file, drift is identically 0 and the classifier behaves exactly as ADR-103. To activate the drift channel: re-record via the ADR-107 REST endpoint or wait for auto-recalibrate; new baseline.json carries the per_subcarrier_mean vector and drift becomes live.

NBVI Step 3 validation runs on every refresh tick. With K=12 being the "safe" default that always passes (clean low-CV window in the operator's deployment) and smaller Ks not improving FP=0, the picker keeps K=12 in steady state. Defends against future drift in channel conditions where a previously-clean subcarrier picks up interference.

Open Items

(none — see Closed below)

Closed

  • Phase-domain driftscripts/record-baseline.py and the in-process capture_baseline_to_disk now emit per-subcarrier per_subcarrier_phase_mean + per_subcarrier_phase_var (circular mean + variance) when the WS stream carries phases (ADR-106). The server loads them into PHASE_BASELINE_PER_SUB, phase_drift_update computes a per-tick circular-distance score over subcarriers whose baseline variance is below PHASE_BASELINE_VAR_MAX = 0.30. Score surfaces in PerNodeFeatureInfo.phase_drift_score (skip-if-none). Falls back gracefully — legacy baselines without phase fields keep amplitude-only behaviour.

  • Per-subcarrier baseline AGE checkbaseline_staleness_watch background task warns when on-disk baseline is older than --baseline-stale-age-sec (default 4 h) AND per-sub drift exceeds 1.5× presence threshold for ≥3 consecutive 5-min ticks while the classifier reports absent. Rate-limited via --baseline-stale-warn-cooldown-sec (default 1 h). Independent from auto_recalibrate_task: that path needs a quiet room; this one fires when the operator is in the room while the channel itself has shifted. (commit eec3ca6c)

  • Per-subcarrier delta in UIraw.html now shows a per-node drift sparkline below the RSSI/broadband trace, fixed Y range [0, 0.30] with dashed presence (0.10) and warning (0.15) thresholds. Numeric "drift" stat pill in the per-node header. Backed by a new drift_score: Option<f64> field on PerNodeFeatureInfo (skip-if-none — distinguishes "no per-sub baseline loaded" from "loaded and stable at 0.0"). (commit eec3ca6c)

References

  • ADR-101 — broadband classifier; this ADR adds a parallel channel.
  • ADR-102 — NBVI; this ADR adds Step 3 validation per Pace's spec.
  • ADR-103 — persistent baseline; per_subcarrier_mean already written.
  • ADR-107 — REST calibrate endpoint; how the operator refreshes the per-sub vector on demand.
  • docs/references/espectre-techniques.md §1.Step 3.