180 lines
7.6 KiB
Markdown
180 lines
7.6 KiB
Markdown
# 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 drift** — `scripts/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 check** — `baseline_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 UI** — `raw.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`](../references/espectre-techniques.md)
|
||
§1.Step 3.
|