diff --git a/docs/adr/ADR-151-room-calibration-specialist-training.md b/docs/adr/ADR-151-room-calibration-specialist-training.md index 554d6d2f..2e636ec0 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-09):** Stages 1–5 implemented with *statistical* specialists (threshold/prototype/autocorrelation). 54 unit tests (incl. multistatic), 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 one live runtime signal (breathing ~16–31 BPM via `room-watch`) used the *stateless* breathing head, **not** a trained bank; a clean empty-room `enroll → train-room → room-watch` loop has **not** yet run on-target — the headline remaining gap. 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). 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. 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 d637a387..8a9aa298 100644 --- a/docs/integration/calibration-appliance-integration.md +++ b/docs/integration/calibration-appliance-integration.md @@ -214,20 +214,21 @@ exchange bank/model deltas, never raw CSI. Hardening already in place: ## 7. Status & validation -- **Implemented:** all 5 stages + multistatic fusion; CLI + Stage-1 HTTP API (auth + path-traversal hardened). **54 tests** (35 calibration + 19 CLI), all passing under qemu-aarch64. +- **Implemented:** all 5 stages + multistatic fusion; CLI + Stage-1 HTTP API (auth + path-traversal hardened). **55 tests** (35 calibration unit + 1 full-loop integration + 19 CLI), all passing under qemu-aarch64. **Precise validation matrix (don't overstate this — no clean full calibration has run on-target yet):** -| Stage | Pi-5 (real nexmon→`0xC5110001`, 6,813 frames) | ESP32-S3 (COM8, `edge_tier=0`) | qemu / unit | +| Stage | Pi-5 (real nexmon→`0xC5110001`, 6,813 frames) | ESP32-S3 (COM8, `edge_tier=0`) | qemu / unit / integration | |---|---|---|---| -| baseline capture + HTTP API + **auth gate** | ✅ | ✅ (120-frame) | — | -| **clean** empty-room baseline | ❌ `motion_flagged` (artifact) | ❌ (occupied) | — | -| enroll → train-room | ❌ | ❌ (needs operator poses) | logic ✅ | -| runtime infer | ❌ on-target | ◐ single-node breathing ~16–31 BPM via the **stateless** head (not a trained bank) + node-id fusion | veto/STALE/fusion ✅ | +| baseline capture + HTTP API + **auth gate** | ✅ | ✅ (120-frame) | full-loop ✅ | +| **clean** empty-room baseline | ❌ `motion_flagged` (artifact) | ❌ (occupied) | full-loop ✅ (synthetic, zero motion flags) | +| enroll → train-room | ❌ | ❌ (needs operator poses) | full-loop ✅ (8/8 anchors, 6 specialists, JSON round-trip) | +| runtime infer | ❌ on-target | ◐ single-node breathing ~16–31 BPM via the **stateless** head (not a trained bank) + node-id fusion | full-loop ✅ (trained bank: 18±2 BPM positive, absent negative, foreign-baseline STALE) | -So capture + API + auth are proven on real CSI (both boxes); a **clean-baseline `enroll → train-room → room-watch`** loop is **not yet proven on-target** — that's the headline gap to close on the appliance. +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); a clean empty-room enroll→train→infer on-target; 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. +- **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. **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/tests/full_loop.rs b/v2/crates/wifi-densepose-calibration/tests/full_loop.rs new file mode 100644 index 00000000..88bf34b6 --- /dev/null +++ b/v2/crates/wifi-densepose-calibration/tests/full_loop.rs @@ -0,0 +1,412 @@ +//! Full-loop integration test for the ADR-151 calibration pipeline (software half +//! of the §7 validation gap): a clean empty-room **baseline → enroll → extract → +//! train → infer** loop, driven end-to-end through the crates' public API in the +//! exact order the CLI (`calibrate` → `enroll` → `train-room` → `room-watch`) +//! wires the stages. +//! +//! CSI is synthetic but physically plausible: +//! - **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`); +//! - **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); +//! - **small movement**: per-frame amplitude jitter + a phase wobble that swings +//! past the π/6 drift threshold. +//! +//! Deterministic (xorshift32, fixed seeds), no I/O, no hardware. What remains +//! hardware-only is the on-target run with real ESP32 CSI and a live operator. + +use std::f32::consts::PI; + +use ndarray::Array2; +use num_complex::Complex64; +use wifi_densepose_calibration::extract::Features; +use wifi_densepose_calibration::{ + AnchorFeature, AnchorLabel, AnchorQualityGate, AnchorRecorder, EnrollmentEvent, + EnrollmentSession, MixtureOfSpecialists, SpecialistBank, SpecialistKind, +}; +use wifi_densepose_core::types::{AntennaConfig, CsiFrame, CsiMetadata, DeviceId, FrequencyBand}; +use wifi_densepose_signal::{BaselineCalibration, CalibrationConfig, CalibrationRecorder}; + +// --------------------------------------------------------------------------- +// Deterministic PRNG (xorshift32 + Box-Muller) — same pattern as +// wifi-densepose-signal/tests/calibration_roundtrip.rs. +// --------------------------------------------------------------------------- + +struct Rng(u32); + +impl Rng { + fn new(seed: u32) -> Self { + assert_ne!(seed, 0, "xorshift seed must be non-zero"); + Self(seed) + } + fn next_u32(&mut self) -> u32 { + let mut x = self.0; + x ^= x << 13; + x ^= x >> 17; + x ^= x << 5; + self.0 = x; + x + } + fn next_normal(&mut self) -> f32 { + let u1 = (self.next_u32() as f32 + 1.0) / (u32::MAX as f32 + 2.0); + let u2 = (self.next_u32() as f32 + 1.0) / (u32::MAX as f32 + 2.0); + (-2.0 * u1.ln()).sqrt() * (2.0 * PI * u2).cos() + } +} + +// --------------------------------------------------------------------------- +// Synthetic room (HT20: 52 active subcarriers @ 20 Hz) +// --------------------------------------------------------------------------- + +const N_SC: usize = 52; +const FS_HZ: f32 = 20.0; +/// Complex-noise std per quadrature ⇒ amplitude noise std ≈ NOISE_STD. +const NOISE_STD: f32 = 0.01; +/// Capture length per enrollment anchor (20 s @ 20 Hz; gate needs ≥ 60). +const ANCHOR_FRAMES: usize = 400; +/// Baseline / runtime window length (30 s @ 20 Hz; recorder needs ≥ 600). +const WINDOW_FRAMES: usize = 600; + +/// What the person in the room is doing (None ⇒ empty room). +#[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. + presence_z: f32, + /// Per-frame common amplitude jitter (body sway / fidgeting), in NOISE_STD. + sway_z: f32, + /// Respiration rate (Hz); 0 = no modulation. + breathing_hz: f32, + /// Relative amplitude-modulation depth on every 4th subcarrier. + breathing_depth: f32, + /// Constant phase shift from the body's multipath (radians). + phase_shift: f32, + /// Phase-wobble amplitude (radians) at 1.5 Hz — drives the motion flag. + phase_wobble: f32, +} + +/// Deterministic CSI source for one room. Time advances one frame per call. +struct RoomSim { + rng: Rng, + /// Static per-subcarrier amplitude fingerprint. + amp: Vec, + /// Static per-subcarrier phase fingerprint. + phase: Vec, + /// Frame counter (continuous room clock). + t: u64, +} + +impl RoomSim { + fn new(seed: u32) -> Self { + // Same HT20 fingerprint as the ADR-135 roundtrip test. + let amp = (0..N_SC) + .map(|k| 0.3 + 0.7 * (k as f32 * PI / N_SC as f32).sin().abs()) + .collect(); + let phase = (0..N_SC) + .map(|k| (k as f32 * 0.1).rem_euclid(2.0 * PI) - PI) + .collect(); + Self { rng: Rng::new(seed), amp, phase, t: 0 } + } + + /// Generate the next CSI frame for the given occupancy. + fn frame(&mut self, person: Option<&Person>) -> CsiFrame { + let secs = self.t as f32 / FS_HZ; + let (offset, wobble) = match person { + Some(p) => { + let sway = p.sway_z * NOISE_STD * self.rng.next_normal(); + ( + p.presence_z * NOISE_STD + sway, + p.phase_shift + p.phase_wobble * (2.0 * PI * 1.5 * secs).sin(), + ) + } + None => (0.0, 0.0), + }; + + let mut data = Array2::::zeros((1, N_SC)); + for k in 0..N_SC { + let mut a = self.amp[k] + offset; + if let Some(p) = person { + if p.breathing_hz > 0.0 && k % 4 == 0 { + a *= 1.0 + p.breathing_depth * (2.0 * PI * p.breathing_hz * secs).sin(); + } + } + let th = self.phase[k] + wobble; + let re = a * th.cos() + NOISE_STD * self.rng.next_normal(); + let im = a * th.sin() + NOISE_STD * self.rng.next_normal(); + data[(0, k)] = Complex64::new(re as f64, im as f64); + } + + let mut meta = + CsiMetadata::new(DeviceId::new("full-loop-test"), FrequencyBand::Band2_4GHz, 6); + meta.bandwidth_mhz = 20; + meta.antenna_config = AntennaConfig::new(1, 1); + self.t += 1; + CsiFrame::new(meta, data) + } +} + +/// Per-frame scalar — mean amplitude across subcarriers/streams, the same +/// carrier the CLI's `frame_scalar` feeds into `Features::from_series`. +fn frame_scalar(frame: &CsiFrame) -> f32 { + frame.mean_amplitude() as f32 +} + +/// Synthetic occupancy for each guided anchor in the canonical sequence. +fn anchor_person(label: AnchorLabel) -> Option { + let p = match label { + AnchorLabel::Empty => return None, + AnchorLabel::StandStill => Person { + presence_z: 1.8, 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() + }, + AnchorLabel::LieDown => Person { + presence_z: 1.6, sway_z: 0.25, phase_shift: 0.06, ..Default::default() + }, + AnchorLabel::BreatheSlow => Person { + presence_z: 1.7, sway_z: 0.2, breathing_hz: 0.125, breathing_depth: 0.03, + phase_shift: 0.08, ..Default::default() + }, + AnchorLabel::BreatheNormal => Person { + presence_z: 1.7, sway_z: 0.2, breathing_hz: 0.25, breathing_depth: 0.03, + phase_shift: 0.08, ..Default::default() + }, + AnchorLabel::SmallMove => Person { + presence_z: 1.7, sway_z: 1.0, phase_shift: 0.10, phase_wobble: 1.0, + ..Default::default() + }, + AnchorLabel::SleepPosture => Person { + presence_z: 1.6, sway_z: 0.2, breathing_hz: 0.2, breathing_depth: 0.03, + phase_shift: 0.06, ..Default::default() + }, + }; + Some(p) +} + +/// Capture one anchor exactly as the CLI's `enroll` does: per-frame deviation +/// into the `AnchorRecorder`, scalar series for feature extraction, then the +/// quality-gate verdict. +fn capture_anchor( + sim: &mut RoomSim, + baseline: &BaselineCalibration, + gate: &AnchorQualityGate, + label: AnchorLabel, + room_id: &str, + at_unix_s: i64, +) -> (Option, wifi_densepose_calibration::Anchor, Option) { + let person = anchor_person(label); + let mut recorder = AnchorRecorder::new(label); + let mut series = Vec::with_capacity(ANCHOR_FRAMES); + for _ in 0..ANCHOR_FRAMES { + let frame = sim.frame(person.as_ref()); + recorder.record_frame(baseline, &frame); + series.push(frame_scalar(&frame)); + } + let (anchor, reason) = recorder.finalize(gate, at_unix_s); + let feature = anchor + .quality + .accepted + .then(|| AnchorFeature::from_series(room_id, label, &series, FS_HZ)); + (feature, anchor, reason) +} + +/// Generate a live feature window (Stage-5 runtime input). +fn live_window(sim: &mut RoomSim, person: Option<&Person>) -> Features { + let series: Vec = (0..WINDOW_FRAMES) + .map(|_| frame_scalar(&sim.frame(person))) + .collect(); + Features::from_series(&series, FS_HZ) +} + +// --------------------------------------------------------------------------- +// The full loop +// --------------------------------------------------------------------------- + +#[test] +fn full_loop_baseline_enroll_extract_train_infer() { + let room_id = "living-room"; + let mut sim = RoomSim::new(42); + + // -- Stage 1: clean empty-room baseline capture (ADR-135) ---------------- + let mut recorder = CalibrationRecorder::new(CalibrationConfig::ht20()); + let mut flagged_after_warmup = 0u32; + for i in 0..WINDOW_FRAMES { + let frame = sim.frame(None); + let score = recorder.record(&frame).expect("baseline record"); + // Welford stats need a short warmup before the partial z is meaningful. + if i >= 100 && score.motion_flagged { + flagged_after_warmup += 1; + } + } + assert_eq!(recorder.frames_recorded(), WINDOW_FRAMES as u32); + assert_eq!( + flagged_after_warmup, 0, + "a static empty room must never be motion-flagged after warmup" + ); + let baseline = recorder.finalize().expect("baseline finalize"); + assert_eq!(baseline.subcarriers.len(), N_SC); + let baseline_id = baseline.calibration_uuid().to_string(); + + // A fresh empty frame deviates negligibly from its own baseline. + let check = baseline.deviation(&sim.frame(None)).expect("deviation"); + assert!(!check.motion_flagged, "empty frame flagged: {check:?}"); + assert!( + check.amplitude_z_median < 1.0, + "empty frame z {} should be < 1.0", + check.amplitude_z_median + ); + + // -- Stage 2: guided-anchor enrollment with the quality gate ------------- + let gate = AnchorQualityGate::default(); + let mut session = EnrollmentSession::new(room_id, &baseline_id, 1_700_000_000); + let mut features: Vec = Vec::new(); + + for (i, label) in AnchorLabel::SEQUENCE.into_iter().enumerate() { + let at = 1_700_000_000 + (i as i64 + 1) * 30; + let (feat, anchor, reason) = + capture_anchor(&mut sim, &baseline, &gate, label, room_id, at); + assert!( + anchor.quality.accepted, + "anchor {} rejected: {} (presence_z={:.2} motion={:.0}% frames={})", + label.as_str(), + reason.unwrap_or_default(), + anchor.quality.presence_z, + anchor.quality.motion_rate * 100.0, + anchor.quality.frames, + ); + match label { + AnchorLabel::Empty => assert!( + anchor.quality.presence_z < 1.0, + "empty room must read empty, got z {}", + anchor.quality.presence_z + ), + AnchorLabel::SmallMove => assert!( + anchor.quality.motion_rate >= 0.3, + "small-move motion {} too low", + anchor.quality.motion_rate + ), + _ => assert!( + anchor.quality.presence_z >= 1.5, + "{} presence_z {} below gate", + label.as_str(), + anchor.quality.presence_z + ), + } + features.push(feat.expect("accepted anchor yields a feature")); + session.apply(EnrollmentEvent::AnchorAccepted { anchor }); + } + assert!(session.is_complete(), "missing anchors: {:?}", session.missing()); + assert_eq!(session.progress(), (8, 8)); + session.apply(EnrollmentEvent::Completed { at: 1_700_000_300 }); + + // -- Stage 3: feature extraction sanity ---------------------------------- + assert_eq!(features.len(), 8); + let by_label = |l: AnchorLabel| { + features + .iter() + .find(|f| f.label == l) + .unwrap_or_else(|| panic!("no feature for {}", l.as_str())) + }; + let breathe = by_label(AnchorLabel::BreatheNormal); + assert!( + (breathe.features.breathing_hz - 0.25).abs() < 0.04, + "normal breathing extracted at {} Hz, injected 0.25 Hz", + breathe.features.breathing_hz + ); + assert!( + breathe.features.breathing_score > 0.25, + "breathing score {} too weak", + breathe.features.breathing_score + ); + let slow = by_label(AnchorLabel::BreatheSlow); + assert!( + (slow.features.breathing_hz - 0.125).abs() < 0.04, + "slow breathing extracted at {} Hz, injected 0.125 Hz", + slow.features.breathing_hz + ); + let empty = by_label(AnchorLabel::Empty); + assert!( + empty.features.variance < breathe.features.variance, + "empty variance {} should be below occupied {}", + empty.features.variance, + breathe.features.variance + ); + + // -- Stage 4: train the specialist bank + JSON persistence round-trip ---- + let bank = SpecialistBank::train(room_id, &baseline_id, &features, 1_700_000_400) + .expect("bank training"); + assert_eq!(bank.room_id, room_id); + assert_eq!(bank.anchor_count, 8); + let kinds = bank.trained_kinds(); + for kind in [ + SpecialistKind::Presence, + SpecialistKind::Posture, + SpecialistKind::Breathing, + SpecialistKind::Heartbeat, + SpecialistKind::Restlessness, + SpecialistKind::Anomaly, + ] { + assert!(kinds.contains(&kind), "bank missing {kind:?} (got {kinds:?})"); + } + + // Persist and reload (JSON today) — the runtime below uses the *reloaded* + // bank, so the round-trip is proven inside the loop, not as a side check. + let json = bank.to_json().expect("bank to_json"); + let reloaded = SpecialistBank::from_json(&json).expect("bank from_json"); + assert_eq!(reloaded.room_id, bank.room_id); + assert_eq!(reloaded.baseline_id, bank.baseline_id); + assert_eq!(reloaded.anchor_count, bank.anchor_count); + assert_eq!( + reloaded.presence.as_ref().map(|p| p.threshold), + bank.presence.as_ref().map(|p| p.threshold), + "presence threshold must survive persistence" + ); + + // -- Stage 5: runtime inference through the mixture ---------------------- + let mix = MixtureOfSpecialists::new(reloaded); + + // Positive case: a person breathing at a KNOWN 0.30 Hz (18 BPM) — a rate + // never used during enrollment. + let occupied = Person { + presence_z: 1.7, + sway_z: 0.25, + breathing_hz: 0.30, + breathing_depth: 0.04, + phase_shift: 0.08, + ..Default::default() + }; + let f = live_window(&mut sim, Some(&occupied)); + let state = mix.infer(&f, &baseline_id); + assert!(!state.stale, "bank trained against this baseline must be fresh"); + assert!(!state.vetoed, "plausible occupied window must not be vetoed"); + let presence = state.presence.expect("presence specialist trained"); + assert_eq!(presence.value, 1.0, "person in the room must be detected"); + let breathing = state.breathing.expect("breathing must be reported when present"); + assert!( + (breathing.value - 18.0).abs() <= 2.0, + "breathing {} BPM, injected 18 BPM", + breathing.value + ); + assert!(state.restlessness.is_some(), "restlessness specialist trained"); + + // Negative case: a fresh empty-room window must NOT report presence, + // breathing, heartbeat, or posture. + let f_empty = live_window(&mut sim, None); + let state = mix.infer(&f_empty, &baseline_id); + let presence = state.presence.expect("presence specialist trained"); + assert_eq!(presence.value, 0.0, "empty room must read absent"); + assert!(state.breathing.is_none(), "no breathing in an empty room"); + assert!(state.heartbeat.is_none(), "no heartbeat in an empty room"); + assert!(state.posture.is_none(), "no posture in an empty room"); + + // Honest degradation: a drifted baseline flags the bank STALE. + let state = mix.infer(&f, "some-other-baseline"); + assert!(state.stale, "baseline drift must mark readings STALE"); +}