test(calibration): full-loop integration test — baseline→enroll→train→infer proven in-process (ADR-151 §7 gap, software half)

Closes the software half of PR #989's headline validation gap: the
complete calibration loop had never run end-to-end anywhere, even
in-process. tests/full_loop.rs (412 lines, deterministic xorshift32
room simulator, HT20/52-subcarrier/20Hz, same fingerprint family as
the ADR-135 roundtrip test) now drives the CLI's exact stage order
through the public API:

  1. baseline  — 600 static frames, zero motion flags post-warmup,
                 calibration_uuid() exactly as the CLI derives it
  2. enroll    — all 8 AnchorLabel::SEQUENCE anchors through
                 AnchorQualityGate::default(), session is_complete()
  3. extract   — AnchorFeature::from_series recovers injected 0.25Hz
                 and 0.125Hz breathing within ±0.04Hz
  4. train     — SpecialistBank::train fits all 6 specialists; JSON
                 round-trip and the runtime consumes the RELOADED bank
  5. infer     — positive: never-enrolled 0.30Hz subject reads present,
                 18±2 BPM; negative: empty window reads absent;
                 degradation: foreign baseline_id flags STALE

Seed-robust (5 seeds), passes with and without default features:
36 unit + 1 integration green.

Validation docs updated (ADR-151 §7 + integration doc §7 matrix): what
remains is strictly the on-target hardware session (real CSI, physically
empty room, operator performing the guided anchors). Three behavioral
findings from building the test are recorded for pre-session triage:
z-band squeeze between baseline motion flagging (z>2.0) and the still-
anchor gate (presence_z≥1.5) — likeliest on-hardware enroll failure;
variance-only PresenceSpecialist missing motionless-person mean shift;
ungated breathing_hz/heart_hz in noise-window embeddings.

Co-Authored-By: RuFlo <ruv@ruv.net>
This commit is contained in:
ruv
2026-06-10 14:50:55 -04:00
parent 0b6c29d36d
commit 0043613c6d
3 changed files with 422 additions and 9 deletions
@@ -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 teacherstudent distillation | Drift marks bank STALE; AetherArena entry | ◐ Partial (STALE done; SONA/federation/HF-backbone = follow-ups) |
**Current status (2026-06-09):** Stages 15 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 ~1631 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 15 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 ~1631 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.
@@ -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 ~1631 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 ~1631 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).
@@ -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.1250.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<f32>,
/// Static per-subcarrier phase fingerprint.
phase: Vec<f32>,
/// 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::<Complex64>::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<Person> {
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<AnchorFeature>, wifi_densepose_calibration::Anchor, Option<String>) {
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<f32> = (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<AnchorFeature> = 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");
}