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);