From b4b626fad37a0678fbba0b840756ae965ff59a4d Mon Sep 17 00:00:00 2001 From: ruv Date: Wed, 10 Jun 2026 15:20:38 -0400 Subject: [PATCH] fix(calibration): close all four ADR-152 behavioral findings pre-hardware-session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The full-loop integration test surfaced three findings; fixing the third exposed a fourth. All four are fixed and regression-guarded: 1. z-band squeeze (enrollment.rs) — anchor motion is now measured from frame-to-frame deltas of the deviation series (|Δz| > Z_DELTA_MOTION 0.5 ∨ |Δφ| > π/6), not from the absolute motion_flagged, which fires at amplitude_z_median > 2.0 vs the EMPTY baseline and so conflated presence strength with motion. A strongly-reflecting still person (z = 3.0 — every frame flagged by the old heuristic) now enrolls. The old unit tests mocked (z=3.0, motion=false), a combination the real deviation() can never emit — which is exactly how the squeeze hid; tests now derive the flag from z the way the producer does. 2. variance-only presence (specialist.rs) — PresenceSpecialist gains a mean-shift channel: present when variance > threshold OR |mean − empty_mean| > mean_dist_threshold (trained at half the empty→occupied mean distance, None when the means don't separate). Detects the motionless person whose body raises the scalar mean but not its variance. Old persisted banks deserialize with the channel inert (serde default None) — variance-only behavior preserved, proven by a fixture test against pre-change JSON. 3. ungated hz embedding (extract.rs) — Features::embedding() zeroes breathing_hz/heart_hz below EMBED_MIN_SCORE (0.25), keeping the random in-band peaks of noise windows out of the posture/anomaly prototype space. Raw fields stay ungated (specialists have their own stricter gates). 4. heart-band lag-floor leakage (extract.rs, found while fixing 3) — a pure 0.30 Hz breathing signal scored 0.67 in the heart band at 3.33 Hz: out-of-band rhythm leaks as a monotonic slope whose max sits at the band's lag floor, so score gating alone cannot stop it. autocorr_dominant now requires the winning lag to be an interior local maximum; band-edge "peaks" are rejected, true in-band peaks (interior by definition) are preserved. full_loop.rs strengthened to drive the fixes end-to-end: the StandStill anchor is now a z=3.0 strong reflector (unenrollable pre-fix), and a new motionless-person runtime case proves mean-channel detection at empty- level variance. Validation: 41 calibration unit + 1 full-loop integration + 23 CLI tests green; cargo test --workspace --no-default-features exit 0. Co-Authored-By: RuFlo --- ...51-room-calibration-specialist-training.md | 2 +- .../calibration-appliance-integration.md | 2 +- .../src/enrollment.rs | 113 ++++++++++++++--- .../wifi-densepose-calibration/src/extract.rs | 100 ++++++++++++++- .../src/specialist.rs | 117 ++++++++++++++++-- .../tests/full_loop.rs | 37 +++++- 6 files changed, 327 insertions(+), 44 deletions(-) diff --git a/docs/adr/ADR-151-room-calibration-specialist-training.md b/docs/adr/ADR-151-room-calibration-specialist-training.md index 2e636ec0..6af4e8cb 100644 --- a/docs/adr/ADR-151-room-calibration-specialist-training.md +++ b/docs/adr/ADR-151-room-calibration-specialist-training.md @@ -247,7 +247,7 @@ wifi-densepose room-status --room living-room | **P5** | heartbeat + restlessness + anomaly specialists; `runtime.rs` mixture + veto + confidence gating | End-to-end RoomState on hardware; anomaly veto verified | ✅ Done (`runtime.rs`, CLI `room-watch`; breathing read live on COM8 ESP32) | | **P6** | Baseline-drift `STALE` invalidation; SONA online adaptation; optional ADR-105 federation; HF teacher–student distillation | Drift marks bank STALE; AetherArena entry | ◐ Partial (STALE done; SONA/federation/HF-backbone = follow-ups) | -**Current status (2026-06-10):** Stages 1–5 implemented with *statistical* specialists (threshold/prototype/autocorrelation). 55 tests (35 unit incl. multistatic + 1 full-loop integration + 19 CLI), all passing under qemu-aarch64. **Validation scope is precise:** baseline capture + HTTP API + auth are proven on real CSI (Pi-5 nexmon, 6,813 frames; and an ESP32-S3). The complete `baseline → enroll → train-room → infer` loop is now **proven in-process** on deterministic synthetic CSI (`tests/full_loop.rs`: clean baseline with zero motion flags, 8/8 anchors through the quality gate, 6 specialists trained, JSON bank round-trip, trained-bank inference 18±2 BPM positive / absent negative / foreign-baseline STALE; seed-robust). The one live runtime signal (breathing ~16–31 BPM via `room-watch`) used the *stateless* breathing head, **not** a trained bank; the clean empty-room loop has **not** yet run on-target — the remaining gap is strictly the hardware session (empty room + operator anchors). See the integration doc §7 for three behavioral findings from the full-loop test (z-band squeeze, variance-only presence, ungated hz embedding) worth fixing before that session. SOTA-intake decisions affecting this system (geometry conditioning, checkerboard alignment) are recorded in ADR-152. Open refinements: `--source-format adr018v6` (drive from the Pi's own nexmon), phase-based breathing carrier, RVF/HNSW storage, and the ADR-150 frozen HF backbone the specialists would distill from. +**Current status (2026-06-10):** Stages 1–5 implemented with *statistical* specialists (threshold/prototype/autocorrelation). 55 tests (35 unit incl. multistatic + 1 full-loop integration + 19 CLI), all passing under qemu-aarch64. **Validation scope is precise:** baseline capture + HTTP API + auth are proven on real CSI (Pi-5 nexmon, 6,813 frames; and an ESP32-S3). The complete `baseline → enroll → train-room → infer` loop is now **proven in-process** on deterministic synthetic CSI (`tests/full_loop.rs`: clean baseline with zero motion flags, 8/8 anchors through the quality gate, 6 specialists trained, JSON bank round-trip, trained-bank inference 18±2 BPM positive / absent negative / foreign-baseline STALE; seed-robust). The one live runtime signal (breathing ~16–31 BPM via `room-watch`) used the *stateless* breathing head, **not** a trained bank; the clean empty-room loop has **not** yet run on-target — the remaining gap is strictly the hardware session (empty room + operator anchors). The four behavioral findings from the full-loop test (z-band squeeze, variance-only presence, ungated hz embedding, heart-band lag-floor leakage) are FIXED and regression-guarded — see the integration doc §7. SOTA-intake decisions affecting this system (geometry conditioning, checkerboard alignment) are recorded in ADR-152. Open refinements: `--source-format adr018v6` (drive from the Pi's own nexmon), phase-based breathing carrier, RVF/HNSW storage, and the ADR-150 frozen HF backbone the specialists would distill from. Validation per CLAUDE.md: `cargo test --workspace --no-default-features` green; hardware verification on the ESP32-S3 (currently COM8) before any release; witness bundle regenerated if the proof surface changes. diff --git a/docs/integration/calibration-appliance-integration.md b/docs/integration/calibration-appliance-integration.md index 8a9aa298..625e67a6 100644 --- a/docs/integration/calibration-appliance-integration.md +++ b/docs/integration/calibration-appliance-integration.md @@ -228,7 +228,7 @@ exchange bank/model deltas, never raw CSI. Hardening already in place: The complete `baseline → enroll → train-room → infer` loop is now **proven in-process** on deterministic synthetic CSI (`wifi-densepose-calibration/tests/full_loop.rs` — drives the CLI's exact stage order through the public API, seed-robust across 5 seeds, runs with and without default features). Capture + API + auth are proven on real CSI (both boxes). What remains is strictly the **on-target** run: real CSI, a physically empty room for baseline, and an operator performing the 8 guided anchors — that hardware session is the last open item. - **Known follow-ups (appliance backlog):** `--source-format adr018v6` to drive calibration from the Pi's own nexmon (no ESP32/transcoder); the on-target clean-room enroll→train→infer session (above); phase-based (vs mean-amplitude) breathing carrier; RVF/HNSW persistence (currently JSON); enroll/train HTTP endpoints (live `/room/state` already added); ADR-150 Hailo backbone; true 2-node multistatic; ADR-105 federation. -- **Behavioral findings from the full-loop test (pre-hardware-session fixes worth considering):** (1) *z-band squeeze*: `BaselineCalibration::deviation` flags motion at `amplitude_z_median > 2.0` while the still-anchor gate needs `presence_z ≥ 1.5` — a strongly-reflecting still person can be rejected as "moving"; presence strength and motion are conflated. Most likely on-hardware enroll failure mode. (2) `PresenceSpecialist` is variance-only — a motionless person raises the scalar *mean* but not variance, so a quiet subject can read "absent" at runtime even though enroll accepted them; adding mean/`presence_z` to the presence decision would close it. (3) `Features::from_series` emits a best-in-band `breathing_hz`/`heart_hz` even at negligible score, injecting random in-band frequencies into the prototype embeddings for noise windows; gating the hz fields on score would tighten posture/anomaly classification. +- **Behavioral findings from the full-loop test — all four FIXED pre-hardware-session:** (1) *z-band squeeze* — anchor motion is now measured from frame-to-frame deltas of the deviation series (`|Δz| > 0.5 ∨ |Δφ| > π/6`), not from the absolute `motion_flagged` (which conflated presence strength with motion); a strongly-reflecting still person (z = 3.0, every frame flagged by the old heuristic) now enrolls — regression-guarded in the full-loop test's `StandStill` anchor and `enrollment::tests`. (2) *Variance-only presence* — `PresenceSpecialist` gained a mean-shift channel (|mean − empty mean| vs a trained threshold); a motionless person is detected via the mean even at empty-level variance — regression-guarded in the full-loop motionless-person case; old persisted banks deserialize with the channel inert (variance-only behavior preserved). (3) *Ungated hz embedding* — `Features::embedding()` zeroes `breathing_hz`/`heart_hz` below `EMBED_MIN_SCORE` (0.25), keeping noise-window random frequencies out of the prototype space. (4) *Heart-band leakage* (found while fixing 3): a strong breathing rhythm's autocorrelation leaks into the HR band as a high-score lag-floor edge value (e.g. score 0.67 at 3.33 Hz from a pure 0.30 Hz breath); `autocorr_dominant` now requires the winning lag to be an interior local maximum, rejecting band-edge leakage while preserving true in-band peaks. **Reference:** ADR-151 (`docs/adr/ADR-151-room-calibration-specialist-training.md`), ADR-135 (baseline), ADR-029 (multistatic), ADR-150 (RF Foundation Encoder), ADR-105 (federation), ADR-147 (OccWorld/Hailo). diff --git a/v2/crates/wifi-densepose-calibration/src/enrollment.rs b/v2/crates/wifi-densepose-calibration/src/enrollment.rs index 1f118264..813762d4 100644 --- a/v2/crates/wifi-densepose-calibration/src/enrollment.rs +++ b/v2/crates/wifi-densepose-calibration/src/enrollment.rs @@ -9,7 +9,16 @@ //! Quality is measured against the ADR-135 empty-room baseline via //! [`wifi_densepose_signal::BaselineCalibration::deviation`], whose //! `CalibrationDeviationScore` gives a per-frame amplitude z-score (presence -//! strength) and a motion flag — exactly the two signals the gate needs. +//! strength). +//! +//! **Motion is NOT taken from the score's `motion_flagged`** (ADR-152 finding, +//! "z-band squeeze"): that flag fires on `amplitude_z_median > 2.0` — deviation +//! from the *empty* baseline — which conflates presence strength with motion. A +//! strongly-reflecting person standing perfectly still (z > 2 on every frame) +//! would be rejected as "too much motion". Instead the recorder derives motion +//! from the frame-to-frame *change* in the deviation series (|Δz| and |Δφ|), +//! which is presence-independent: a still strong reflector has high z but a +//! flat z-series; a moving person has a jittery one. use wifi_densepose_core::types::CsiFrame; use wifi_densepose_signal::{BaselineCalibration, CalibrationDeviationScore}; @@ -99,12 +108,30 @@ impl AnchorQualityGate { } } +/// Frame-to-frame amplitude-z change above which a frame counts as motion. +/// +/// Presence-independent by construction: a still person shifts the z *level* +/// but not its frame-to-frame delta (only noise-scale jitter survives), while +/// body movement modulates the reflected paths every frame. Sized well above +/// the delta the baseline's own noise floor produces (≲0.3σ) and well below +/// the delta even small limb movements produce (≳1σ). See ADR-152. +pub const Z_DELTA_MOTION: f32 = 0.5; + +/// Frame-to-frame phase-drift change above which a frame counts as motion. +/// Same constant family as the absolute π/6 drift bound in +/// `CalibrationDeviationScore`, applied to the delta (static body phase shift +/// cancels out). +pub const PHASE_DELTA_MOTION: f32 = std::f32::consts::PI / 6.0; + /// Accumulates per-frame deviation statistics for a single anchor capture. pub struct AnchorRecorder { label: AnchorLabel, z_sum: f64, motion_count: u32, frames: u32, + /// Previous frame's (amplitude_z_median, phase_drift_median) for the + /// delta-based motion measure (ADR-152 z-band-squeeze fix). + prev: Option<(f32, f32)>, } impl AnchorRecorder { @@ -115,6 +142,7 @@ impl AnchorRecorder { z_sum: 0.0, motion_count: 0, frames: 0, + prev: None, } } @@ -129,11 +157,21 @@ impl AnchorRecorder { } /// Record a pre-computed deviation score (caller runs `baseline.deviation`). + /// + /// Motion is derived from the frame-to-frame change of the deviation + /// series, NOT from `score.motion_flagged` — the flag conflates presence + /// strength with motion (z-band squeeze, see module docs / ADR-152). The + /// first frame of a capture is never motion (no predecessor). pub fn record_score(&mut self, score: &CalibrationDeviationScore) { - self.z_sum += score.amplitude_z_median as f64; - if score.motion_flagged { - self.motion_count += 1; + let z = score.amplitude_z_median; + let phase = score.phase_drift_median; + if let Some((pz, pp)) = self.prev { + if (z - pz).abs() > Z_DELTA_MOTION || (phase - pp).abs() > PHASE_DELTA_MOTION { + self.motion_count += 1; + } } + self.prev = Some((z, phase)); + self.z_sum += z as f64; self.frames += 1; } @@ -187,65 +225,102 @@ impl AnchorRecorder { mod tests { use super::*; - fn score(z: f32, motion: bool) -> CalibrationDeviationScore { + /// Build a score the way `BaselineCalibration::deviation` actually would: + /// `motion_flagged` is DERIVED from z (z > 2.0 ⇒ flagged), never free. + /// The old tests mocked `(z=3.0, motion=false)` — a combination the real + /// producer can never emit, which is exactly how the z-band squeeze hid. + fn score(z: f32) -> CalibrationDeviationScore { CalibrationDeviationScore { amplitude_z_median: z, amplitude_z_max: z + 1.0, phase_drift_median: 0.05, - motion_flagged: motion, + motion_flagged: z > 2.0, } } - fn run(label: AnchorLabel, z: f32, motion: bool, n: u32) -> (Anchor, Option) { + /// Record a z-series and finalize against the default gate. + fn run_series(label: AnchorLabel, zs: &[f32]) -> (Anchor, Option) { let mut r = AnchorRecorder::new(label); - for _ in 0..n { - r.record_score(&score(z, motion)); + for &z in zs { + r.record_score(&score(z)); } r.finalize(&AnchorQualityGate::default(), 100) } + /// Constant z (a perfectly still capture at the given presence strength). + fn run_still(label: AnchorLabel, z: f32, n: usize) -> (Anchor, Option) { + run_series(label, &vec![z; n]) + } + + /// Alternating z (every frame's |Δz| exceeds Z_DELTA_MOTION ⇒ all motion). + fn run_jittery(label: AnchorLabel, z: f32, n: usize) -> (Anchor, Option) { + let zs: Vec = (0..n) + .map(|i| if i % 2 == 0 { z } else { z + 2.0 * Z_DELTA_MOTION }) + .collect(); + run_series(label, &zs) + } + + /// ADR-152 z-band-squeeze regression: a STRONGLY-reflecting still person + /// (z = 3.0, so every frame is motion_flagged by the baseline heuristic) + /// must still pass a still anchor — presence strength is not motion. #[test] - fn still_anchor_with_present_still_person_accepts() { - let (a, reason) = run(AnchorLabel::StandStill, 3.0, false, 400); - assert!(a.quality.accepted, "reason: {reason:?}"); + fn still_anchor_with_strong_still_person_accepts() { + let (a, reason) = run_still(AnchorLabel::StandStill, 3.0, 400); + assert!(a.quality.accepted, "z-band squeeze is back: {reason:?}"); assert!(reason.is_none()); + assert!(a.quality.motion_rate < 0.05, "flat z-series must read still"); } #[test] fn still_anchor_rejects_when_no_presence() { - let (a, reason) = run(AnchorLabel::Sit, 0.4, false, 400); + let (a, reason) = run_still(AnchorLabel::Sit, 0.4, 400); assert!(!a.quality.accepted); assert!(reason.unwrap().contains("no person")); } #[test] fn still_anchor_rejects_on_motion() { - let (a, reason) = run(AnchorLabel::LieDown, 3.0, true, 400); + let (a, reason) = run_jittery(AnchorLabel::LieDown, 3.0, 400); assert!(!a.quality.accepted); assert!(reason.unwrap().contains("motion")); } #[test] fn move_anchor_requires_motion() { - let (still, r1) = run(AnchorLabel::SmallMove, 3.0, false, 400); + let (still, r1) = run_still(AnchorLabel::SmallMove, 3.0, 400); assert!(!still.quality.accepted); assert!(r1.unwrap().contains("not enough motion")); - let (moving, r2) = run(AnchorLabel::SmallMove, 3.0, true, 400); + let (moving, r2) = run_jittery(AnchorLabel::SmallMove, 3.0, 400); assert!(moving.quality.accepted, "reason: {r2:?}"); } + #[test] + fn phase_delta_also_counts_as_motion() { + // Constant z but a phase-drift series that swings past PHASE_DELTA_MOTION + // every frame — motion must be detected from the phase channel alone. + let mut r = AnchorRecorder::new(AnchorLabel::LieDown); + for i in 0..400 { + let mut s = score(1.8); + s.phase_drift_median = if i % 2 == 0 { 0.0 } else { PHASE_DELTA_MOTION * 1.5 }; + r.record_score(&s); + } + let (a, reason) = r.finalize(&AnchorQualityGate::default(), 100); + assert!(!a.quality.accepted); + assert!(reason.unwrap().contains("motion")); + } + #[test] fn empty_anchor_rejects_when_occupied() { - let (occupied, reason) = run(AnchorLabel::Empty, 3.0, true, 400); + let (occupied, reason) = run_still(AnchorLabel::Empty, 3.0, 400); assert!(!occupied.quality.accepted); assert!(reason.unwrap().contains("not empty")); - let (empty, _) = run(AnchorLabel::Empty, 0.3, false, 400); + let (empty, _) = run_still(AnchorLabel::Empty, 0.3, 400); assert!(empty.quality.accepted); } #[test] fn too_few_frames_rejected() { - let (a, reason) = run(AnchorLabel::Sit, 3.0, false, 10); + let (a, reason) = run_still(AnchorLabel::Sit, 3.0, 10); assert!(!a.quality.accepted); assert!(reason.unwrap().contains("frames")); } diff --git a/v2/crates/wifi-densepose-calibration/src/extract.rs b/v2/crates/wifi-densepose-calibration/src/extract.rs index bd18a283..9b6d5d70 100644 --- a/v2/crates/wifi-densepose-calibration/src/extract.rs +++ b/v2/crates/wifi-densepose-calibration/src/extract.rs @@ -33,10 +33,32 @@ pub struct Features { pub heart_hz: f32, } +/// Minimum periodicity score for a band's frequency to enter the prototype +/// embedding. Below it `autocorr_dominant` still reports its best in-band +/// peak, but for noise windows that peak is a *random* in-band frequency — +/// letting it into the embedding makes posture/anomaly prototype distances +/// noisy (ADR-152 finding, "ungated hz embedding"). The raw `breathing_hz` / +/// `heart_hz` fields stay un-gated: the breathing/heartbeat specialists apply +/// their own (stricter) `min_score` gates. +pub const EMBED_MIN_SCORE: f32 = 0.25; + impl Features { /// A fixed-length numeric embedding for nearest-prototype classifiers. + /// + /// The hz components are zeroed unless their periodicity score clears + /// [`EMBED_MIN_SCORE`] — see the constant's docs. pub fn embedding(&self) -> [f32; 5] { - [self.mean, self.variance, self.motion, self.breathing_hz, self.heart_hz] + let breathing_hz = if self.breathing_score >= EMBED_MIN_SCORE { + self.breathing_hz + } else { + 0.0 + }; + let heart_hz = if self.heart_score >= EMBED_MIN_SCORE { + self.heart_hz + } else { + 0.0 + }; + [self.mean, self.variance, self.motion, breathing_hz, heart_hz] } /// Squared Euclidean distance between two embeddings. @@ -117,6 +139,14 @@ impl AnchorFeature { /// Dominant frequency in `[lo_hz, hi_hz]` via autocorrelation, with a normalized /// peak score in `[0, 1]`. Returns `(0, 0)` if no confident peak. +/// +/// The winning lag must be an **interior local maximum** of the in-band +/// autocorrelation, not a band-edge value (ADR-152 finding, "heart-band +/// leakage"): a strong out-of-band rhythm — breathing bleeding into the HR +/// band — produces a monotonic slope whose largest in-band value sits at the +/// lag floor (pinning `heart_hz` near the band's top frequency with a high +/// score). A genuine in-band periodicity peaks *inside* the band; an edge +/// maximum is leakage and is rejected. pub fn autocorr_dominant(sig: &[f32], fs: f32, lo_hz: f32, hi_hz: f32) -> (f32, f32) { let n = sig.len(); if n < 16 || fs <= 0.0 || hi_hz <= lo_hz { @@ -133,15 +163,25 @@ pub fn autocorr_dominant(sig: &[f32], fs: f32, lo_hz: f32, hi_hz: f32) -> (f32, return (0.0, 0.0); } + // Autocorrelation over the band, extended one lag on each side so the + // band edges have real neighbors for the local-max test. + let ext_min = lag_min.saturating_sub(1).max(1); + let ext_max = (lag_max + 1).min(n - 1); + let acc: Vec = (ext_min..=ext_max) + .map(|lag| (0..(n - lag)).map(|i| sig[i] * sig[i + lag]).sum()) + .collect(); + let mut best = 0.0f32; let mut best_lag = 0usize; for lag in lag_min..=lag_max { - let mut acc = 0.0f32; - for i in 0..(n - lag) { - acc += sig[i] * sig[i + lag]; + let idx = lag - ext_min; + if idx == 0 || idx + 1 >= acc.len() { + continue; // no neighbor on one side — cannot prove a local max } - if acc > best { - best = acc; + let v = acc[idx]; + // Interior local maximum (ties to the left tolerated for plateaus). + if v >= acc[idx - 1] && v > acc[idx + 1] && v > best { + best = v; best_lag = lag; } } @@ -204,4 +244,52 @@ mod tests { assert_eq!(f.mean, 0.0); assert_eq!(f.breathing_hz, 0.0); } + + /// ADR-152 "heart-band leakage" regression: a strong breathing rhythm must + /// NOT register as a heart-band periodicity — its in-band autocorr maximum + /// sits at the band edge (monotonic leak), not an interior peak. + #[test] + fn heart_band_rejects_breathing_leakage() { + let fs = 20.0; + // Pure 0.30 Hz breathing, no heart component at all. + let s = sine(0.30, fs, (fs * 30.0) as usize); + let (hz, score) = autocorr_dominant(&s, fs, 0.8, 3.0); + assert!( + score < 0.25, + "breathing-only signal scored {score} in the heart band (hz {hz}) — \ + the lag-floor leak is back" + ); + // The breathing band itself must still find the true rate. + let (bhz, bscore) = autocorr_dominant(&s, fs, 0.1, 0.6); + assert!((bhz - 0.30).abs() < 0.05, "breathing band got {bhz}"); + assert!(bscore > 0.5); + } + + /// ADR-152 "ungated hz embedding" regression: a low-score in-band peak + /// (noise) must NOT leak its random frequency into the prototype + /// embedding, while a confident peak must pass through unchanged. + #[test] + fn embedding_gates_hz_on_score() { + let noisy = Features { + mean: 1.0, + variance: 2.0, + motion: 0.3, + breathing_score: EMBED_MIN_SCORE - 0.05, + breathing_hz: 0.42, // random in-band peak from a noise window + heart_score: EMBED_MIN_SCORE - 0.05, + heart_hz: 3.3, // breathing leakage pinned at the lag floor + }; + let e = noisy.embedding(); + assert_eq!(e[3], 0.0, "low-score breathing_hz must be gated out"); + assert_eq!(e[4], 0.0, "low-score heart_hz must be gated out"); + + let confident = Features { + breathing_score: EMBED_MIN_SCORE + 0.3, + heart_score: EMBED_MIN_SCORE + 0.3, + ..noisy + }; + let e = confident.embedding(); + assert_eq!(e[3], 0.42, "confident breathing_hz must pass through"); + assert_eq!(e[4], 3.3, "confident heart_hz must pass through"); + } } diff --git a/v2/crates/wifi-densepose-calibration/src/specialist.rs b/v2/crates/wifi-densepose-calibration/src/specialist.rs index 8a719632..686bf324 100644 --- a/v2/crates/wifi-densepose-calibration/src/specialist.rs +++ b/v2/crates/wifi-densepose-calibration/src/specialist.rs @@ -57,33 +57,61 @@ pub trait Specialist { // Presence // --------------------------------------------------------------------------- -/// Binary presence gate: variance threshold learned from empty vs occupied anchors. +/// Binary presence gate learned from empty vs occupied anchors. +/// +/// Two complementary signals (ADR-152 finding, "variance-only presence"): +/// - **variance** — motion/occupancy energy; catches a moving person but is +/// blind to a *motionless* one, whose body raises the scalar *mean* (extra +/// multipath energy) while barely raising variance; +/// - **mean shift** — |mean − empty-room mean|; catches the motionless person +/// the variance channel misses. Symmetric (abs) because a body can shadow +/// paths and *lower* the mean too. +/// +/// Present when EITHER channel fires. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PresenceSpecialist { /// Decision threshold on series variance. pub threshold: f32, /// Occupied-anchor mean variance (for confidence scaling). pub occupied_var: f32, + /// Empty-room mean of the scalar series (mean-shift reference). + #[serde(default)] + pub empty_mean: f32, + /// |mean − empty_mean| beyond which the mean alone indicates presence. + /// `None` disables the channel — both for banks persisted before the + /// channel existed (serde default) and for rooms where the empty/occupied + /// means don't separate at train time. + #[serde(default)] + pub mean_dist_threshold: Option, } impl PresenceSpecialist { - /// Fit from anchors: midpoint between the empty variance and the mean - /// occupied variance. + /// Fit from anchors: variance threshold at the midpoint between the empty + /// variance and the mean occupied variance; mean-shift threshold at half + /// the empty→occupied mean distance (inert when the means don't separate). pub fn train(anchors: &[AnchorFeature]) -> Option { let empty = anchors.iter().find(|a| a.label == AnchorLabel::Empty)?; - let occ: Vec = anchors + let occ: Vec<&Features> = anchors .iter() .filter(|a| a.label.expects_presence()) - .map(|a| a.features.variance) + .map(|a| &a.features) .collect(); if occ.is_empty() { return None; } - let occ_mean = occ.iter().sum::() / occ.len() as f32; + let occ_var = occ.iter().map(|f| f.variance).sum::() / occ.len() as f32; + let occ_mean = occ.iter().map(|f| f.mean).sum::() / occ.len() as f32; let empty_var = empty.features.variance; + let empty_mean = empty.features.mean; + + let mean_dist = (occ_mean - empty_mean).abs(); + let mean_dist_threshold = (mean_dist > 1e-4).then(|| 0.5 * mean_dist); + Some(Self { - threshold: 0.5 * (empty_var + occ_mean), - occupied_var: occ_mean.max(empty_var + 1e-3), + threshold: 0.5 * (empty_var + occ_var), + occupied_var: occ_var.max(empty_var + 1e-3), + empty_mean, + mean_dist_threshold, }) } } @@ -93,9 +121,22 @@ impl Specialist for PresenceSpecialist { SpecialistKind::Presence } fn infer(&self, f: &Features) -> Option { - let present = f.variance > self.threshold; - let span = (self.occupied_var - self.threshold).max(1e-3); - let confidence = ((f.variance - self.threshold).abs() / span).clamp(0.0, 1.0); + let by_variance = f.variance > self.threshold; + let mean_dist = (f.mean - self.empty_mean).abs(); + let by_mean = self + .mean_dist_threshold + .is_some_and(|thr| mean_dist > thr); + let present = by_variance || by_mean; + + // Confidence: strongest margin among the channels that are enabled. + let var_span = (self.occupied_var - self.threshold).max(1e-3); + let var_conf = ((f.variance - self.threshold).abs() / var_span).clamp(0.0, 1.0); + let mean_conf = self + .mean_dist_threshold + .map(|thr| ((mean_dist - thr).abs() / thr.max(1e-3)).clamp(0.0, 1.0)) + .unwrap_or(0.0); + let confidence = var_conf.max(mean_conf); + Some(SpecialistReading { kind: SpecialistKind::Presence, value: if present { 1.0 } else { 0.0 }, @@ -371,6 +412,27 @@ mod tests { } } + /// Like `feat` but with an explicit series mean (the presence mean-gate input). + fn feat_mean(mean: f32, variance: f32, motion: f32) -> Features { + Features { + mean, + variance, + motion, + breathing_score: 0.0, + breathing_hz: 0.0, + heart_score: 0.0, + heart_hz: 0.0, + } + } + + fn af_mean(label: AnchorLabel, mean: f32, variance: f32, motion: f32) -> AnchorFeature { + AnchorFeature { + room_id: "r".into(), + label, + features: feat_mean(mean, variance, motion), + } + } + #[test] fn presence_learns_threshold_and_classifies() { let anchors = vec![ @@ -382,6 +444,39 @@ mod tests { assert!(p.infer(&feat(1.0, 0.1, 0.0, 0.0)).unwrap().value == 0.0); } + /// ADR-152 "variance-only presence" regression: a MOTIONLESS person raises + /// the scalar mean (extra multipath energy) but barely the variance — the + /// mean channel must still detect them, and a window matching the empty + /// room on BOTH channels must still read absent. + #[test] + fn presence_detects_motionless_person_via_mean_shift() { + let anchors = vec![ + af_mean(AnchorLabel::Empty, 1.0, 1.0, 0.1), + af_mean(AnchorLabel::StandStill, 1.6, 10.0, 0.2), + af_mean(AnchorLabel::LieDown, 1.5, 8.0, 0.15), + ]; + let p = PresenceSpecialist::train(&anchors).unwrap(); + // Motionless person: variance at the empty level, mean shifted. + let r = p.infer(&feat_mean(1.55, 1.0, 0.05)).unwrap(); + assert_eq!(r.value, 1.0, "motionless person must read present"); + // Truly empty window: both channels quiet. + let r = p.infer(&feat_mean(1.0, 1.0, 0.05)).unwrap(); + assert_eq!(r.value, 0.0, "empty room must still read absent"); + } + + /// Banks persisted BEFORE the mean gate existed must deserialize to the + /// inert (+∞) gate and keep their original variance-only behavior. + #[test] + fn presence_old_bank_json_stays_variance_only() { + let old_json = r#"{"threshold":5.5,"occupied_var":10.0}"#; + let p: PresenceSpecialist = serde_json::from_str(old_json).unwrap(); + assert!(p.mean_dist_threshold.is_none()); + // Mean wildly shifted but variance below threshold → still absent + // (old behavior preserved; the mean channel is disabled). + let r = p.infer(&feat_mean(99.0, 1.0, 0.05)).unwrap(); + assert_eq!(r.value, 0.0); + } + #[test] fn posture_nearest_prototype() { let anchors = vec![ diff --git a/v2/crates/wifi-densepose-calibration/tests/full_loop.rs b/v2/crates/wifi-densepose-calibration/tests/full_loop.rs index 88bf34b6..e518ddaa 100644 --- a/v2/crates/wifi-densepose-calibration/tests/full_loop.rs +++ b/v2/crates/wifi-densepose-calibration/tests/full_loop.rs @@ -8,9 +8,10 @@ //! - **empty room**: stable per-subcarrier amplitudes + small complex Gaussian //! noise (the ADR-135 roundtrip-test fingerprint) — never motion-flagged; //! - **person present**: a common amplitude offset (extra multipath energy), -//! small body sway, and a constant phase shift. The offset is sized inside the -//! z band (1.5, 2.0) the deviation heuristic leaves between "present" -//! (`presence_z ≥ 1.5`) and "moving" (`amplitude_z_median > 2.0`); +//! small body sway, and a constant phase shift. Presence strength is free to +//! exceed z = 2.0 — since the ADR-152 z-band-squeeze fix, anchor motion is +//! measured from frame-to-frame deltas, not from the absolute deviation, so +//! a strongly-reflecting *still* person is no longer misread as "moving"; //! - **breathing**: a few-percent periodic amplitude modulation (0.125–0.3 Hz) //! on a subset of subcarriers — visible in the mean-amplitude scalar the CLI //! uses, invisible to the per-frame *median* z (so still anchors stay still); @@ -76,8 +77,9 @@ const WINDOW_FRAMES: usize = 600; #[derive(Clone, Copy, Default)] struct Person { /// Common amplitude offset in units of NOISE_STD (presence strength). - /// Must stay inside (1.5, 2.0): below it the gate sees no one, above it - /// every frame is motion-flagged. + /// Anything ≥ 1.5 reads as present; values above 2.0 are explicitly + /// exercised to guard the ADR-152 z-band-squeeze fix (presence strength + /// must not read as motion). presence_z: f32, /// Per-frame common amplitude jitter (body sway / fidgeting), in NOISE_STD. sway_z: f32, @@ -161,8 +163,11 @@ fn frame_scalar(frame: &CsiFrame) -> f32 { fn anchor_person(label: AnchorLabel) -> Option { let p = match label { AnchorLabel::Empty => return None, + // Strong reflector at z = 3.0 — every frame exceeds the baseline's + // absolute motion threshold (z > 2.0). Pre-ADR-152 this anchor was + // unenrollable ("too much motion"); the delta-based gate must accept it. AnchorLabel::StandStill => Person { - presence_z: 1.8, sway_z: 0.25, phase_shift: 0.10, ..Default::default() + presence_z: 3.0, sway_z: 0.25, phase_shift: 0.10, ..Default::default() }, AnchorLabel::Sit => Person { presence_z: 1.65, sway_z: 0.25, phase_shift: 0.08, ..Default::default() @@ -396,6 +401,26 @@ fn full_loop_baseline_enroll_extract_train_infer() { ); assert!(state.restlessness.is_some(), "restlessness specialist trained"); + // Motionless-person case (ADR-152 "variance-only presence" regression): + // a strong reflector standing perfectly still — variance stays at the + // empty-room level, only the scalar MEAN shifts. The mean channel of the + // presence specialist must still detect them. + let motionless = Person { + presence_z: 3.0, + sway_z: 0.05, + phase_shift: 0.10, + ..Default::default() + }; + let f_still = live_window(&mut sim, Some(&motionless)); + let state = mix.infer(&f_still, &baseline_id); + let presence = state.presence.expect("presence specialist trained"); + assert_eq!( + presence.value, 1.0, + "motionless person must be detected via the mean-shift channel \ + (variance {:.2e} vs empty-level)", + f_still.variance + ); + // Negative case: a fresh empty-room window must NOT report presence, // breathing, heartbeat, or posture. let f_empty = live_window(&mut sim, None);