From 7a0158c44d8fc26cfd7ef84c2ee5b25a2e306dc0 Mon Sep 17 00:00:00 2001 From: ruv Date: Tue, 9 Jun 2026 12:05:05 -0400 Subject: [PATCH] =?UTF-8?q?feat(calibration):=20ADR-151=20Stages=202?= =?UTF-8?q?=E2=80=935=20=E2=80=94=20enrollment,=20extraction,=20specialist?= =?UTF-8?q?=20bank,=20runtime?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New crate wifi-densepose-calibration implementing the per-room pipeline beyond Stage-1 baseline: - anchor.rs: guided-anchor sequence + event-sourced EnrollmentSession (Stage 2) - enrollment.rs: AnchorQualityGate + AnchorRecorder — gates anchors against the ADR-135 baseline deviation (presence/motion), re-prompts bad captures - extract.rs: Features + AnchorFeature — autocorrelation periodicity (breathing/ HR bands), variance/motion (Stage 3) - specialist.rs: 6 small room-calibrated models — presence (learned threshold), posture (nearest-prototype), breathing/heartbeat (band periodicity), restlessness (calm/active normalization), anomaly (novelty vs anchors) (Stage 4) - bank.rs: SpecialistBank — train/persist + baseline-drift STALE invalidation - runtime.rs: MixtureOfSpecialists — presence short-circuit + anomaly veto + stale flagging (Stage 5) Statistical heads make the pipeline runnable/validatable today; the ADR-150 HF RF Foundation Encoder backbone is the documented upgrade path. 29 unit tests pass. Co-Authored-By: claude-flow --- v2/Cargo.toml | 1 + .../wifi-densepose-calibration/Cargo.toml | 21 + .../wifi-densepose-calibration/src/anchor.rs | 336 ++++++++++++++ .../wifi-densepose-calibration/src/bank.rs | 188 ++++++++ .../src/enrollment.rs | 252 ++++++++++ .../wifi-densepose-calibration/src/error.rs | 49 ++ .../wifi-densepose-calibration/src/extract.rs | 207 +++++++++ .../wifi-densepose-calibration/src/lib.rs | 35 ++ .../wifi-densepose-calibration/src/runtime.rs | 178 ++++++++ .../src/specialist.rs | 430 ++++++++++++++++++ 10 files changed, 1697 insertions(+) create mode 100644 v2/crates/wifi-densepose-calibration/Cargo.toml create mode 100644 v2/crates/wifi-densepose-calibration/src/anchor.rs create mode 100644 v2/crates/wifi-densepose-calibration/src/bank.rs create mode 100644 v2/crates/wifi-densepose-calibration/src/enrollment.rs create mode 100644 v2/crates/wifi-densepose-calibration/src/error.rs create mode 100644 v2/crates/wifi-densepose-calibration/src/extract.rs create mode 100644 v2/crates/wifi-densepose-calibration/src/lib.rs create mode 100644 v2/crates/wifi-densepose-calibration/src/runtime.rs create mode 100644 v2/crates/wifi-densepose-calibration/src/specialist.rs diff --git a/v2/Cargo.toml b/v2/Cargo.toml index 5e70aca1..26fdee2c 100644 --- a/v2/Cargo.toml +++ b/v2/Cargo.toml @@ -28,6 +28,7 @@ members = [ "crates/wifi-densepose-geo", "crates/wifi-densepose-worldgraph", # ADR-139 — WorldGraph environmental digital twin "crates/wifi-densepose-engine", # ADR-135..146 integration/composition layer + "crates/wifi-densepose-calibration", # ADR-151 — per-room calibration & specialist training "crates/nvsim", "crates/nvsim-server", "crates/homecore", # ADR-127 — HOMECORE state machine diff --git a/v2/crates/wifi-densepose-calibration/Cargo.toml b/v2/crates/wifi-densepose-calibration/Cargo.toml new file mode 100644 index 00000000..80832f58 --- /dev/null +++ b/v2/crates/wifi-densepose-calibration/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "wifi-densepose-calibration" +version.workspace = true +edition.workspace = true +description = "ADR-151 per-room calibration & specialized model training (baseline → enroll → extract → train)" +authors.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +wifi-densepose-core = { workspace = true } +wifi-densepose-signal = { version = "0.3.0", path = "../wifi-densepose-signal", default-features = false } + +serde = { workspace = true } +serde_json = "1.0" +thiserror = { workspace = true } +uuid = { version = "1.6", features = ["v4", "serde"] } + +[dev-dependencies] +ndarray = { workspace = true } +num-complex = { workspace = true } diff --git a/v2/crates/wifi-densepose-calibration/src/anchor.rs b/v2/crates/wifi-densepose-calibration/src/anchor.rs new file mode 100644 index 00000000..868fc8e8 --- /dev/null +++ b/v2/crates/wifi-densepose-calibration/src/anchor.rs @@ -0,0 +1,336 @@ +//! Guided anchors + event-sourced enrollment session (ADR-151 Stage 2). +//! +//! Enrollment teaches the room a small set of *clean anchors* — not hours of +//! data. Each anchor is a short labelled capture (stand / sit / lie / breathe / +//! move / sleep) layered on top of the ADR-135 empty-room baseline. The session +//! is event-sourced so re-enrollment is incremental and auditable (per CLAUDE.md +//! state rules). + +use serde::{Deserialize, Serialize}; + +/// Coarse posture an anchor establishes. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum Posture { + /// Standing. + Standing, + /// Sitting. + Sitting, + /// Lying down. + Lying, +} + +/// The fixed guided-anchor sequence (ADR-151 §2.2). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum AnchorLabel { + /// Empty room reference (reuses the ADR-135 baseline). + Empty, + /// Person standing still, in view of the sensor. + StandStill, + /// Person sitting. + Sit, + /// Person lying down. + LieDown, + /// Slow respiration (~0.1–0.15 Hz). + BreatheSlow, + /// Normal respiration (~0.2–0.3 Hz). + BreatheNormal, + /// Small limb movement. + SmallMove, + /// Quiescent sleep posture (lying, still). + SleepPosture, +} + +impl AnchorLabel { + /// The canonical enrollment order. + pub const SEQUENCE: [AnchorLabel; 8] = [ + AnchorLabel::Empty, + AnchorLabel::StandStill, + AnchorLabel::Sit, + AnchorLabel::LieDown, + AnchorLabel::BreatheSlow, + AnchorLabel::BreatheNormal, + AnchorLabel::SmallMove, + AnchorLabel::SleepPosture, + ]; + + /// Stable string id (used in persistence / API). + pub fn as_str(&self) -> &'static str { + match self { + AnchorLabel::Empty => "empty", + AnchorLabel::StandStill => "stand_still", + AnchorLabel::Sit => "sit", + AnchorLabel::LieDown => "lie_down", + AnchorLabel::BreatheSlow => "breathe_slow", + AnchorLabel::BreatheNormal => "breathe_normal", + AnchorLabel::SmallMove => "small_move", + AnchorLabel::SleepPosture => "sleep_posture", + } + } + + /// Parse from the stable string id. + pub fn from_str(s: &str) -> Option { + AnchorLabel::SEQUENCE + .iter() + .copied() + .find(|a| a.as_str() == s) + } + + /// Operator-facing prompt shown by the CLI / UI. + pub fn prompt(&self) -> &'static str { + match self { + AnchorLabel::Empty => "Leave the room empty and still…", + AnchorLabel::StandStill => "Stand still, in view of the sensor…", + AnchorLabel::Sit => "Sit down and stay still…", + AnchorLabel::LieDown => "Lie down and stay still…", + AnchorLabel::BreatheSlow => "Lie or sit still and breathe slowly…", + AnchorLabel::BreatheNormal => "Stay still and breathe normally…", + AnchorLabel::SmallMove => "Make small movements (wave a hand, shift)…", + AnchorLabel::SleepPosture => "Lie in your sleep posture and relax…", + } + } + + /// Suggested capture duration (seconds). + pub fn duration_s(&self) -> u32 { + match self { + AnchorLabel::BreatheSlow + | AnchorLabel::BreatheNormal + | AnchorLabel::SleepPosture => 30, + _ => 20, + } + } + + /// Whether a person is expected to be present for this anchor. + pub fn expects_presence(&self) -> bool { + !matches!(self, AnchorLabel::Empty) + } + + /// Whether the subject is expected to be (largely) still. + pub fn expects_still(&self) -> bool { + !matches!(self, AnchorLabel::SmallMove) + } + + /// Posture this anchor establishes, if any. + pub fn posture(&self) -> Option { + match self { + AnchorLabel::StandStill => Some(Posture::Standing), + AnchorLabel::Sit => Some(Posture::Sitting), + AnchorLabel::LieDown | AnchorLabel::SleepPosture => Some(Posture::Lying), + _ => None, + } + } +} + +/// Quality assessment of a captured anchor (from the enrollment quality gate). +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +pub struct AnchorQuality { + /// Median amplitude z-score vs the empty-room baseline (presence strength). + pub presence_z: f32, + /// Fraction of frames flagged as motion. + pub motion_rate: f32, + /// Number of frames captured. + pub frames: u32, + /// Whether the anchor passed the gate. + pub accepted: bool, +} + +/// A captured, accepted anchor. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Anchor { + /// Which anchor in the sequence. + pub label: AnchorLabel, + /// Capture time (unix seconds). + pub captured_at_unix_s: i64, + /// Quality metrics. + pub quality: AnchorQuality, +} + +/// Event log entry for an enrollment session (event sourcing). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum EnrollmentEvent { + /// Session opened. + Started { + /// Room scope. + room_id: String, + /// Baseline id the enrollment layers on. + baseline_id: String, + /// Unix seconds. + at: i64, + }, + /// An anchor passed the gate and was accepted. + AnchorAccepted { + /// The accepted anchor. + anchor: Anchor, + }, + /// An anchor failed the gate (re-prompt). + AnchorRejected { + /// Which anchor. + label: AnchorLabel, + /// Human-readable reason. + reason: String, + /// Unix seconds. + at: i64, + }, + /// All required anchors accepted. + Completed { + /// Unix seconds. + at: i64, + }, +} + +/// Event-sourced enrollment session for one room. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EnrollmentSession { + /// Room scope. + pub room_id: String, + /// Baseline id this session layers on. + pub baseline_id: String, + /// Append-only event log. + pub events: Vec, +} + +impl EnrollmentSession { + /// Open a new session. + pub fn new(room_id: impl Into, baseline_id: impl Into, at: i64) -> Self { + let room_id = room_id.into(); + let baseline_id = baseline_id.into(); + let mut s = Self { + room_id: room_id.clone(), + baseline_id: baseline_id.clone(), + events: Vec::new(), + }; + s.events.push(EnrollmentEvent::Started { + room_id, + baseline_id, + at, + }); + s + } + + /// Append an event (event sourcing — state is derived, never mutated in place). + pub fn apply(&mut self, event: EnrollmentEvent) { + self.events.push(event); + } + + /// The set of accepted anchors (latest acceptance per label wins). + pub fn accepted_anchors(&self) -> Vec { + let mut out: Vec = Vec::new(); + for ev in &self.events { + if let EnrollmentEvent::AnchorAccepted { anchor } = ev { + if let Some(slot) = out.iter_mut().find(|a| a.label == anchor.label) { + *slot = anchor.clone(); + } else { + out.push(anchor.clone()); + } + } + } + out + } + + /// The next anchor in the canonical sequence not yet accepted, if any. + pub fn next_anchor(&self) -> Option { + let accepted = self.accepted_anchors(); + AnchorLabel::SEQUENCE + .iter() + .copied() + .find(|label| !accepted.iter().any(|a| a.label == *label)) + } + + /// `(accepted, total)` progress. + pub fn progress(&self) -> (usize, usize) { + ( + self.accepted_anchors().len(), + AnchorLabel::SEQUENCE.len(), + ) + } + + /// Whether every anchor in the sequence has been accepted. + pub fn is_complete(&self) -> bool { + self.next_anchor().is_none() + } + + /// Labels still required. + pub fn missing(&self) -> Vec { + let accepted = self.accepted_anchors(); + AnchorLabel::SEQUENCE + .iter() + .copied() + .filter(|label| !accepted.iter().any(|a| a.label == *label)) + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn anchor(label: AnchorLabel) -> Anchor { + Anchor { + label, + captured_at_unix_s: 1, + quality: AnchorQuality { + presence_z: 3.0, + motion_rate: 0.1, + frames: 400, + accepted: true, + }, + } + } + + #[test] + fn label_roundtrip() { + for l in AnchorLabel::SEQUENCE { + assert_eq!(AnchorLabel::from_str(l.as_str()), Some(l)); + } + assert_eq!(AnchorLabel::from_str("nope"), None); + } + + #[test] + fn sequence_order_and_next() { + let mut s = EnrollmentSession::new("living-room", "base-1", 0); + assert_eq!(s.next_anchor(), Some(AnchorLabel::Empty)); + s.apply(EnrollmentEvent::AnchorAccepted { + anchor: anchor(AnchorLabel::Empty), + }); + assert_eq!(s.next_anchor(), Some(AnchorLabel::StandStill)); + assert_eq!(s.progress(), (1, 8)); + assert!(!s.is_complete()); + } + + #[test] + fn completion_and_missing() { + let mut s = EnrollmentSession::new("r", "b", 0); + for l in AnchorLabel::SEQUENCE { + s.apply(EnrollmentEvent::AnchorAccepted { anchor: anchor(l) }); + } + assert!(s.is_complete()); + assert!(s.missing().is_empty()); + assert_eq!(s.progress(), (8, 8)); + } + + #[test] + fn reaccept_replaces_not_duplicates() { + let mut s = EnrollmentSession::new("r", "b", 0); + s.apply(EnrollmentEvent::AnchorAccepted { + anchor: anchor(AnchorLabel::Sit), + }); + s.apply(EnrollmentEvent::AnchorAccepted { + anchor: anchor(AnchorLabel::Sit), + }); + assert_eq!( + s.accepted_anchors() + .iter() + .filter(|a| a.label == AnchorLabel::Sit) + .count(), + 1 + ); + } + + #[test] + fn posture_mapping() { + assert_eq!(AnchorLabel::StandStill.posture(), Some(Posture::Standing)); + assert_eq!(AnchorLabel::LieDown.posture(), Some(Posture::Lying)); + assert_eq!(AnchorLabel::SmallMove.posture(), None); + assert!(!AnchorLabel::SmallMove.expects_still()); + assert!(!AnchorLabel::Empty.expects_presence()); + } +} diff --git a/v2/crates/wifi-densepose-calibration/src/bank.rs b/v2/crates/wifi-densepose-calibration/src/bank.rs new file mode 100644 index 00000000..fc64c98e --- /dev/null +++ b/v2/crates/wifi-densepose-calibration/src/bank.rs @@ -0,0 +1,188 @@ +//! The per-room specialist bank (ADR-151 Stage 4). +//! +//! A versioned collection of small models scoped to one `room_id`, fit from the +//! enrollment anchors and tied to the ADR-135 baseline it was trained against. +//! When the baseline drifts (room rearranged, AP moved), the bank is marked +//! STALE rather than emitting confident-but-wrong readings — the calibration +//! analogue of the firmware's honest `DEGRADED` flag. + +use serde::{Deserialize, Serialize}; + +use crate::error::{CalibrationError, Result}; +use crate::extract::AnchorFeature; +use crate::specialist::{ + AnomalySpecialist, BreathingSpecialist, HeartbeatSpecialist, PostureSpecialist, + PresenceSpecialist, RestlessnessSpecialist, SpecialistKind, +}; + +/// A versioned bank of room-calibrated specialists. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SpecialistBank { + /// Room scope. + pub room_id: String, + /// ADR-135 baseline id this bank was trained against (drift → STALE). + pub baseline_id: String, + /// Training time (unix seconds). + pub trained_at_unix_s: i64, + /// Number of anchors used. + pub anchor_count: usize, + + /// Presence gate (requires the `empty` + an occupied anchor). + pub presence: Option, + /// Posture classifier (requires posture anchors). + pub posture: Option, + /// Breathing (band-limited periodicity; stateless). + pub breathing: BreathingSpecialist, + /// Heartbeat (band-limited periodicity; stateless). + pub heartbeat: HeartbeatSpecialist, + /// Restlessness (requires calm + active anchors). + pub restlessness: Option, + /// Anomaly novelty detector (requires ≥2 anchors). + pub anomaly: Option, +} + +impl SpecialistBank { + /// Train a bank from enrollment anchor features. + /// + /// Requires at least one anchor; specialists whose prerequisite anchors are + /// missing are simply left `None` (a partial bank still works for the + /// signals it could fit). + pub fn train( + room_id: impl Into, + baseline_id: impl Into, + anchors: &[AnchorFeature], + at_unix_s: i64, + ) -> Result { + if anchors.is_empty() { + return Err(CalibrationError::InsufficientSamples { + kind: "bank".into(), + have: 0, + need: 1, + }); + } + Ok(Self { + room_id: room_id.into(), + baseline_id: baseline_id.into(), + trained_at_unix_s: at_unix_s, + anchor_count: anchors.len(), + presence: PresenceSpecialist::train(anchors), + posture: PostureSpecialist::train(anchors), + breathing: BreathingSpecialist::default(), + heartbeat: HeartbeatSpecialist::default(), + restlessness: RestlessnessSpecialist::train(anchors), + anomaly: AnomalySpecialist::train(anchors), + }) + } + + /// `true` if the bank was trained against a different baseline (it is STALE). + pub fn is_stale(&self, current_baseline_id: &str) -> bool { + self.baseline_id != current_baseline_id + } + + /// Error out if stale. + pub fn check_fresh(&self, current_baseline_id: &str) -> Result<()> { + if self.is_stale(current_baseline_id) { + Err(CalibrationError::StaleBaseline { + trained: self.baseline_id.clone(), + current: current_baseline_id.to_string(), + }) + } else { + Ok(()) + } + } + + /// Which specialists were successfully fit. + pub fn trained_kinds(&self) -> Vec { + let mut v = vec![SpecialistKind::Breathing, SpecialistKind::Heartbeat]; + if self.presence.is_some() { + v.push(SpecialistKind::Presence); + } + if self.posture.is_some() { + v.push(SpecialistKind::Posture); + } + if self.restlessness.is_some() { + v.push(SpecialistKind::Restlessness); + } + if self.anomaly.is_some() { + v.push(SpecialistKind::Anomaly); + } + v + } + + /// Serialize to JSON. + pub fn to_json(&self) -> Result { + serde_json::to_string_pretty(self).map_err(|e| CalibrationError::Serde(e.to_string())) + } + + /// Deserialize from JSON. + pub fn from_json(s: &str) -> Result { + serde_json::from_str(s).map_err(|e| CalibrationError::Serde(e.to_string())) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::anchor::AnchorLabel; + use crate::extract::Features; + + fn af(label: AnchorLabel, variance: f32, motion: f32) -> AnchorFeature { + AnchorFeature { + room_id: "living-room".into(), + label, + features: Features { + mean: 1.0, + variance, + motion, + breathing_score: 0.0, + breathing_hz: 0.0, + heart_score: 0.0, + heart_hz: 0.0, + }, + } + } + + fn full_anchors() -> Vec { + vec![ + af(AnchorLabel::Empty, 1.0, 0.1), + af(AnchorLabel::StandStill, 10.0, 0.2), + af(AnchorLabel::Sit, 6.0, 0.2), + af(AnchorLabel::LieDown, 3.0, 0.2), + af(AnchorLabel::SmallMove, 4.0, 1.2), + af(AnchorLabel::SleepPosture, 3.0, 0.1), + ] + } + + #[test] + fn train_full_bank() { + let bank = SpecialistBank::train("living-room", "base-1", &full_anchors(), 1000).unwrap(); + let kinds = bank.trained_kinds(); + assert!(kinds.contains(&SpecialistKind::Presence)); + assert!(kinds.contains(&SpecialistKind::Posture)); + assert!(kinds.contains(&SpecialistKind::Restlessness)); + assert!(kinds.contains(&SpecialistKind::Anomaly)); + assert_eq!(bank.anchor_count, 6); + } + + #[test] + fn empty_anchors_error() { + assert!(SpecialistBank::train("r", "b", &[], 0).is_err()); + } + + #[test] + fn json_roundtrip() { + let bank = SpecialistBank::train("r", "base-1", &full_anchors(), 1000).unwrap(); + let json = bank.to_json().unwrap(); + let back = SpecialistBank::from_json(&json).unwrap(); + assert_eq!(back.room_id, "r"); + assert_eq!(back.anchor_count, 6); + } + + #[test] + fn staleness() { + let bank = SpecialistBank::train("r", "base-1", &full_anchors(), 1000).unwrap(); + assert!(!bank.is_stale("base-1")); + assert!(bank.is_stale("base-2")); + assert!(bank.check_fresh("base-2").is_err()); + } +} diff --git a/v2/crates/wifi-densepose-calibration/src/enrollment.rs b/v2/crates/wifi-densepose-calibration/src/enrollment.rs new file mode 100644 index 00000000..1f118264 --- /dev/null +++ b/v2/crates/wifi-densepose-calibration/src/enrollment.rs @@ -0,0 +1,252 @@ +//! Enrollment protocol — per-anchor capture with an adaptive quality gate +//! (ADR-151 Stage 2). +//! +//! Bad anchors poison small calibrated models far more than large ones, so an +//! anchor is only *accepted* when its captured statistics match what the anchor +//! is supposed to teach: a person present (or absent for `empty`), and the +//! expected stillness/motion. Failed anchors are re-prompted, not silently kept. +//! +//! 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. + +use wifi_densepose_core::types::CsiFrame; +use wifi_densepose_signal::{BaselineCalibration, CalibrationDeviationScore}; + +use crate::anchor::{Anchor, AnchorLabel, AnchorQuality}; + +/// Thresholds for accepting an anchor. +#[derive(Debug, Clone, Copy)] +pub struct AnchorQualityGate { + /// Minimum mean amplitude z-score to consider a person present. + pub min_presence_z: f32, + /// For `empty`: maximum mean z-score to consider the room truly empty. + pub empty_max_z: f32, + /// For "still" anchors: maximum motion-flag rate tolerated. + pub max_still_motion: f32, + /// For the "move" anchor: minimum motion-flag rate required. + pub min_move_motion: f32, + /// Minimum frames required to evaluate an anchor. + pub min_frames: u32, +} + +impl Default for AnchorQualityGate { + fn default() -> Self { + Self { + min_presence_z: 1.5, + empty_max_z: 1.0, + max_still_motion: 0.6, + min_move_motion: 0.3, + min_frames: 60, + } + } +} + +impl AnchorQualityGate { + /// Evaluate accumulated stats for `label`, returning the quality verdict + /// and (on rejection) a human-readable reason. + pub fn evaluate( + &self, + label: AnchorLabel, + presence_z: f32, + motion_rate: f32, + frames: u32, + ) -> (AnchorQuality, Option) { + let mut reason: Option = None; + + if frames < self.min_frames { + reason = Some(format!( + "only {frames} frames (need ≥{}); is the ESP32 streaming?", + self.min_frames + )); + } else if label.expects_presence() { + if presence_z < self.min_presence_z { + reason = Some(format!( + "no person detected (presence_z {presence_z:.2} < {:.2}) — move closer / face the sensor", + self.min_presence_z + )); + } else if label.expects_still() && motion_rate > self.max_still_motion { + reason = Some(format!( + "too much motion ({:.0}% > {:.0}%) for a still anchor — hold still", + motion_rate * 100.0, + self.max_still_motion * 100.0 + )); + } else if !label.expects_still() && motion_rate < self.min_move_motion { + reason = Some(format!( + "not enough motion ({:.0}% < {:.0}%) — move a bit more", + motion_rate * 100.0, + self.min_move_motion * 100.0 + )); + } + } else { + // `empty` anchor: the room must actually be empty. + if presence_z > self.empty_max_z { + reason = Some(format!( + "room not empty (presence_z {presence_z:.2} > {:.2}) — clear the room", + self.empty_max_z + )); + } + } + + let quality = AnchorQuality { + presence_z, + motion_rate, + frames, + accepted: reason.is_none(), + }; + (quality, reason) + } +} + +/// Accumulates per-frame deviation statistics for a single anchor capture. +pub struct AnchorRecorder { + label: AnchorLabel, + z_sum: f64, + motion_count: u32, + frames: u32, +} + +impl AnchorRecorder { + /// Start recording the given anchor. + pub fn new(label: AnchorLabel) -> Self { + Self { + label, + z_sum: 0.0, + motion_count: 0, + frames: 0, + } + } + + /// The anchor being recorded. + pub fn label(&self) -> AnchorLabel { + self.label + } + + /// Frames recorded so far. + pub fn frames(&self) -> u32 { + self.frames + } + + /// Record a pre-computed deviation score (caller runs `baseline.deviation`). + 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; + } + self.frames += 1; + } + + /// Convenience: record a CSI frame directly against a baseline. + /// Frames that fail baseline geometry checks are skipped (not counted). + pub fn record_frame(&mut self, baseline: &BaselineCalibration, frame: &CsiFrame) { + if let Ok(score) = baseline.deviation(frame) { + self.record_score(&score); + } + } + + /// Mean presence z-score over the capture. + pub fn presence_z(&self) -> f32 { + if self.frames == 0 { + 0.0 + } else { + (self.z_sum / self.frames as f64) as f32 + } + } + + /// Fraction of frames flagged as motion. + pub fn motion_rate(&self) -> f32 { + if self.frames == 0 { + 0.0 + } else { + self.motion_count as f32 / self.frames as f32 + } + } + + /// Evaluate the capture against the gate and produce an `Anchor` (accepted + /// or not) plus a rejection reason. + pub fn finalize( + &self, + gate: &AnchorQualityGate, + at_unix_s: i64, + ) -> (Anchor, Option) { + let (quality, reason) = + gate.evaluate(self.label, self.presence_z(), self.motion_rate(), self.frames); + ( + Anchor { + label: self.label, + captured_at_unix_s: at_unix_s, + quality, + }, + reason, + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn score(z: f32, motion: bool) -> CalibrationDeviationScore { + CalibrationDeviationScore { + amplitude_z_median: z, + amplitude_z_max: z + 1.0, + phase_drift_median: 0.05, + motion_flagged: motion, + } + } + + fn run(label: AnchorLabel, z: f32, motion: bool, n: u32) -> (Anchor, Option) { + let mut r = AnchorRecorder::new(label); + for _ in 0..n { + r.record_score(&score(z, motion)); + } + r.finalize(&AnchorQualityGate::default(), 100) + } + + #[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:?}"); + assert!(reason.is_none()); + } + + #[test] + fn still_anchor_rejects_when_no_presence() { + let (a, reason) = run(AnchorLabel::Sit, 0.4, false, 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); + 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); + assert!(!still.quality.accepted); + assert!(r1.unwrap().contains("not enough motion")); + let (moving, r2) = run(AnchorLabel::SmallMove, 3.0, true, 400); + assert!(moving.quality.accepted, "reason: {r2:?}"); + } + + #[test] + fn empty_anchor_rejects_when_occupied() { + let (occupied, reason) = run(AnchorLabel::Empty, 3.0, true, 400); + assert!(!occupied.quality.accepted); + assert!(reason.unwrap().contains("not empty")); + let (empty, _) = run(AnchorLabel::Empty, 0.3, false, 400); + assert!(empty.quality.accepted); + } + + #[test] + fn too_few_frames_rejected() { + let (a, reason) = run(AnchorLabel::Sit, 3.0, false, 10); + assert!(!a.quality.accepted); + assert!(reason.unwrap().contains("frames")); + } +} diff --git a/v2/crates/wifi-densepose-calibration/src/error.rs b/v2/crates/wifi-densepose-calibration/src/error.rs new file mode 100644 index 00000000..197b9d76 --- /dev/null +++ b/v2/crates/wifi-densepose-calibration/src/error.rs @@ -0,0 +1,49 @@ +//! Error types for the calibration pipeline. + +use thiserror::Error; + +/// Errors surfaced by the per-room calibration & training pipeline (ADR-151). +#[derive(Debug, Error)] +pub enum CalibrationError { + /// An anchor was recorded with zero frames. + #[error("anchor '{0}' captured no frames")] + EmptyAnchor(String), + + /// The enrollment session is missing anchors required to train a specialist. + #[error("enrollment incomplete: missing anchors {missing:?}")] + IncompleteEnrollment { + /// Labels still required. + missing: Vec, + }, + + /// A frame did not match the expected tier geometry. + #[error("frame geometry mismatch: {0}")] + Geometry(String), + + /// Not enough samples to fit a specialist. + #[error("insufficient samples for '{kind}': have {have}, need {need}")] + InsufficientSamples { + /// Specialist kind. + kind: String, + /// Samples available. + have: usize, + /// Samples required. + need: usize, + }, + + /// Serialization / persistence failure. + #[error("serialization error: {0}")] + Serde(String), + + /// The specialist bank was trained against a different baseline and is stale. + #[error("bank is STALE: trained against baseline {trained}, current is {current}")] + StaleBaseline { + /// Baseline id the bank was trained against. + trained: String, + /// Current baseline id. + current: String, + }, +} + +/// Convenience result alias. +pub type Result = std::result::Result; diff --git a/v2/crates/wifi-densepose-calibration/src/extract.rs b/v2/crates/wifi-densepose-calibration/src/extract.rs new file mode 100644 index 00000000..bd18a283 --- /dev/null +++ b/v2/crates/wifi-densepose-calibration/src/extract.rs @@ -0,0 +1,207 @@ +//! Feature extraction (ADR-151 Stage 3). +//! +//! Turns an anchor capture — a per-frame scalar series derived from the +//! baseline-subtracted CSI (mean amplitude or dominant-subcarrier phase) — into +//! a compact [`Features`] vector the small specialists consume. No giant model: +//! the useful signal (variance, motion, periodicity, dominant rhythm) is cheap +//! to compute and is exactly what breathing/heartbeat/posture/presence need. +//! +//! Heartbeat and breathing are tiny *repeating* disturbances in the RF field, so +//! periodicity is estimated by autocorrelation over the relevant band — the same +//! technique that fixed the firmware HR estimator (#987). + +use serde::{Deserialize, Serialize}; + +use crate::anchor::AnchorLabel; + +/// Compact per-capture (or per-window) feature vector. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Features { + /// Mean of the scalar series (presence / static load). + pub mean: f32, + /// Variance of the series (motion / occupancy energy). + pub variance: f32, + /// Mean absolute first difference (instantaneous motion proxy). + pub motion: f32, + /// Dominant periodicity score in the breathing band [0, 1]. + pub breathing_score: f32, + /// Dominant breathing frequency (Hz), 0 if none. + pub breathing_hz: f32, + /// Dominant periodicity score in the heart-rate band [0, 1]. + pub heart_score: f32, + /// Dominant heart-rate frequency (Hz), 0 if none. + pub heart_hz: f32, +} + +impl Features { + /// A fixed-length numeric embedding for nearest-prototype classifiers. + pub fn embedding(&self) -> [f32; 5] { + [self.mean, self.variance, self.motion, self.breathing_hz, self.heart_hz] + } + + /// Squared Euclidean distance between two embeddings. + pub fn distance2(&self, other: &Features) -> f32 { + self.embedding() + .iter() + .zip(other.embedding().iter()) + .map(|(a, b)| (a - b) * (a - b)) + .sum() + } + + /// Extract features from a per-frame scalar series sampled at `fs` Hz. + pub fn from_series(series: &[f32], fs: f32) -> Features { + let n = series.len(); + if n == 0 { + return Features { + mean: 0.0, + variance: 0.0, + motion: 0.0, + breathing_score: 0.0, + breathing_hz: 0.0, + heart_score: 0.0, + heart_hz: 0.0, + }; + } + let mean = series.iter().copied().sum::() / n as f32; + let variance = + series.iter().map(|v| (v - mean) * (v - mean)).sum::() / n as f32; + let motion = if n > 1 { + series.windows(2).map(|w| (w[1] - w[0]).abs()).sum::() / (n - 1) as f32 + } else { + 0.0 + }; + + // De-mean before periodicity search. + let centered: Vec = series.iter().map(|v| v - mean).collect(); + let (breathing_hz, breathing_score) = autocorr_dominant(¢ered, fs, 0.1, 0.6); + let (heart_hz, heart_score) = autocorr_dominant(¢ered, fs, 0.8, 3.0); + + Features { + mean, + variance, + motion, + breathing_score, + breathing_hz, + heart_score, + heart_hz, + } + } +} + +/// A labelled feature record from an enrollment anchor (ADR-151 Stage 3). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct AnchorFeature { + /// Room scope. + pub room_id: String, + /// Which anchor this came from. + pub label: AnchorLabel, + /// The extracted features. + pub features: Features, +} + +impl AnchorFeature { + /// Build from a per-frame scalar series. + pub fn from_series( + room_id: impl Into, + label: AnchorLabel, + series: &[f32], + fs: f32, + ) -> AnchorFeature { + AnchorFeature { + room_id: room_id.into(), + label, + features: Features::from_series(series, fs), + } + } +} + +/// Dominant frequency in `[lo_hz, hi_hz]` via autocorrelation, with a normalized +/// peak score in `[0, 1]`. Returns `(0, 0)` if no confident peak. +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 { + return (0.0, 0.0); + } + let lag_min = ((fs / hi_hz).floor() as usize).max(1); + let lag_max = ((fs / lo_hz).ceil() as usize).min(n - 1); + if lag_max <= lag_min + 1 { + return (0.0, 0.0); + } + + let r0: f32 = sig.iter().map(|v| v * v).sum(); + if r0 <= 1e-6 { + return (0.0, 0.0); + } + + 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]; + } + if acc > best { + best = acc; + best_lag = lag; + } + } + if best_lag == 0 { + return (0.0, 0.0); + } + let score = (best / r0).clamp(0.0, 1.0); + (fs / best_lag as f32, score) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::f32::consts::PI; + + fn sine(freq_hz: f32, fs: f32, n: usize) -> Vec { + (0..n) + .map(|i| (2.0 * PI * freq_hz * i as f32 / fs).sin()) + .collect() + } + + #[test] + fn autocorr_finds_breathing_freq() { + // 0.25 Hz (15 BPM) breathing, sampled at 15 Hz for 20 s. + let fs = 15.0; + let s = sine(0.25, fs, (fs * 20.0) as usize); + let (hz, score) = autocorr_dominant(&s, fs, 0.1, 0.6); + assert!((hz - 0.25).abs() < 0.05, "got {hz}"); + assert!(score > 0.5, "score {score}"); + } + + #[test] + fn autocorr_finds_heart_freq() { + // 1.45 Hz (~87 BPM), sampled at 15 Hz. + let fs = 15.0; + let s = sine(1.45, fs, (fs * 20.0) as usize); + let (hz, _) = autocorr_dominant(&s, fs, 0.8, 3.0); + assert!((hz * 60.0 - 87.0).abs() < 12.0, "got {} bpm", hz * 60.0); + } + + #[test] + fn features_capture_breathing() { + let fs = 15.0; + let s = sine(0.3, fs, 300); + let f = Features::from_series(&s, fs); + assert!(f.breathing_score > 0.4); + assert!((f.breathing_hz - 0.3).abs() < 0.06); + } + + #[test] + fn motion_distinguishes_still_from_noisy() { + let still = vec![1.0f32; 200]; + let noisy: Vec = (0..200).map(|i| if i % 2 == 0 { 0.0 } else { 5.0 }).collect(); + assert!(Features::from_series(&still, 15.0).motion < Features::from_series(&noisy, 15.0).motion); + } + + #[test] + fn empty_series_is_safe() { + let f = Features::from_series(&[], 15.0); + assert_eq!(f.mean, 0.0); + assert_eq!(f.breathing_hz, 0.0); + } +} diff --git a/v2/crates/wifi-densepose-calibration/src/lib.rs b/v2/crates/wifi-densepose-calibration/src/lib.rs new file mode 100644 index 00000000..4e8f21aa --- /dev/null +++ b/v2/crates/wifi-densepose-calibration/src/lib.rs @@ -0,0 +1,35 @@ +//! # wifi-densepose-calibration — ADR-151 per-room calibration & specialist training +//! +//! "Teach the room before you teach the model." A local-first pipeline that turns +//! a few minutes of clean human anchors — layered on the ADR-135 empty-room +//! baseline — into a versioned bank of small, specialised models for breathing, +//! heartbeat, restlessness, posture, presence, and anomaly. +//! +//! Stages (ADR-151 §1.3): +//! 1. **baseline** — empty-room environmental fingerprint (ADR-135; consumed here). +//! 2. **enroll** — guided anchors with an adaptive quality gate ([`anchor`], [`enrollment`]). +//! 3. **extract** — labelled feature records from anchor captures ([`extract`]). +//! 4. **train** — a bank of small specialist models ([`specialist`], [`bank`]) and a +//! confidence-gated mixture runtime ([`runtime`]). +//! +//! Invariants: specialisation over scale; local-first; honest `STALE` degradation +//! when the baseline drifts. + +#![forbid(unsafe_code)] +#![warn(missing_docs)] + +pub mod anchor; +pub mod enrollment; +pub mod error; +pub mod extract; +pub mod specialist; +pub mod bank; +pub mod runtime; + +pub use anchor::{Anchor, AnchorLabel, AnchorQuality, EnrollmentEvent, EnrollmentSession, Posture}; +pub use bank::SpecialistBank; +pub use enrollment::{AnchorQualityGate, AnchorRecorder}; +pub use error::{CalibrationError, Result}; +pub use extract::AnchorFeature; +pub use runtime::{MixtureOfSpecialists, RoomState}; +pub use specialist::{Specialist, SpecialistKind, SpecialistReading}; diff --git a/v2/crates/wifi-densepose-calibration/src/runtime.rs b/v2/crates/wifi-densepose-calibration/src/runtime.rs new file mode 100644 index 00000000..6b4b25fe --- /dev/null +++ b/v2/crates/wifi-densepose-calibration/src/runtime.rs @@ -0,0 +1,178 @@ +//! Mixture-of-specialists runtime (ADR-151 §2.5). +//! +//! Every specialist consumes the same live feature window and emits a +//! `{value, confidence}`. Fusion rules keep the output honest: +//! - the **anomaly** specialist holds a veto — a physically-implausible window +//! suppresses positive vitals/posture rather than propagating a hallucination; +//! - **presence = absent** short-circuits breathing/heartbeat/posture to `None` +//! (you cannot have a respiration rate in an empty room); +//! - a **STALE** bank (baseline drift) flags every reading. + +use serde::{Deserialize, Serialize}; + +use crate::bank::SpecialistBank; +use crate::extract::Features; +use crate::specialist::{Specialist, SpecialistReading}; + +/// Fused room state for one feature window. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct RoomState { + /// Presence reading. + pub presence: Option, + /// Posture reading. + pub posture: Option, + /// Breathing reading (BPM). + pub breathing: Option, + /// Heartbeat reading (BPM). + pub heartbeat: Option, + /// Restlessness reading [0, 1]. + pub restlessness: Option, + /// Anomaly reading [0, 1]. + pub anomaly: Option, + /// Anomaly veto fired — vitals/posture suppressed. + pub vetoed: bool, + /// Bank is stale (baseline drift) — readings are not trustworthy. + pub stale: bool, +} + +/// Confidence-gated mixture over a [`SpecialistBank`]. +pub struct MixtureOfSpecialists { + bank: SpecialistBank, + /// Anomaly score above which vitals/posture are vetoed. + pub veto_threshold: f32, +} + +impl MixtureOfSpecialists { + /// Wrap a bank with the default veto threshold (0.5). + pub fn new(bank: SpecialistBank) -> Self { + Self { + bank, + veto_threshold: 0.5, + } + } + + /// The underlying bank. + pub fn bank(&self) -> &SpecialistBank { + &self.bank + } + + /// Infer fused room state, marking `stale` if the bank was trained against a + /// different baseline than `current_baseline_id`. + pub fn infer(&self, f: &Features, current_baseline_id: &str) -> RoomState { + let mut state = RoomState { + stale: self.bank.is_stale(current_baseline_id), + ..Default::default() + }; + + // Anomaly first — it can veto everything else. + state.anomaly = self.bank.anomaly.as_ref().and_then(|a| a.infer(f)); + let vetoed = state + .anomaly + .as_ref() + .map(|r| r.value >= self.veto_threshold) + .unwrap_or(false); + state.vetoed = vetoed; + + // Presence gate. + state.presence = self.bank.presence.as_ref().and_then(|p| p.infer(f)); + let present = state + .presence + .as_ref() + .map(|r| r.value > 0.5) + // No presence specialist → assume present so vitals still run. + .unwrap_or(true); + + // Restlessness is reported regardless of presence (movement implies presence). + state.restlessness = self.bank.restlessness.as_ref().and_then(|r| r.infer(f)); + + // Vitals + posture only when present and not vetoed. + if present && !vetoed { + state.posture = self.bank.posture.as_ref().and_then(|p| p.infer(f)); + state.breathing = self.bank.breathing.infer(f); + state.heartbeat = self.bank.heartbeat.infer(f); + } + + state + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::anchor::AnchorLabel; + use crate::extract::{AnchorFeature, Features}; + + fn af(label: AnchorLabel, variance: f32, motion: f32) -> AnchorFeature { + AnchorFeature { + room_id: "r".into(), + label, + features: Features { + mean: 1.0, + variance, + motion, + breathing_score: 0.0, + breathing_hz: 0.0, + heart_score: 0.0, + heart_hz: 0.0, + }, + } + } + + fn bank() -> SpecialistBank { + let anchors = vec![ + af(AnchorLabel::Empty, 1.0, 0.1), + af(AnchorLabel::StandStill, 10.0, 0.2), + af(AnchorLabel::Sit, 6.0, 0.2), + af(AnchorLabel::LieDown, 3.0, 0.2), + af(AnchorLabel::SmallMove, 4.0, 1.2), + af(AnchorLabel::SleepPosture, 3.0, 0.1), + ]; + SpecialistBank::train("r", "base-1", &anchors, 1000).unwrap() + } + + fn live(variance: f32, motion: f32, br_hz: f32, br_score: f32) -> Features { + Features { + mean: 1.0, + variance, + motion, + breathing_score: br_score, + breathing_hz: br_hz, + heart_score: 0.0, + heart_hz: 0.0, + } + } + + #[test] + fn empty_room_suppresses_vitals() { + let mix = MixtureOfSpecialists::new(bank()); + let s = mix.infer(&live(1.0, 0.1, 0.3, 0.9), "base-1"); + assert_eq!(s.presence.unwrap().value, 0.0); + assert!(s.breathing.is_none(), "no breathing in an empty room"); + assert!(s.posture.is_none()); + } + + #[test] + fn present_room_reports_breathing() { + let mix = MixtureOfSpecialists::new(bank()); + let s = mix.infer(&live(10.0, 0.2, 0.3, 0.9), "base-1"); + assert_eq!(s.presence.unwrap().value, 1.0); + let br = s.breathing.unwrap(); + assert!((br.value - 18.0).abs() < 0.2); + } + + #[test] + fn anomaly_vetoes_vitals() { + let mix = MixtureOfSpecialists::new(bank()); + // Wildly out-of-distribution window → anomaly veto. + let s = mix.infer(&live(5000.0, 200.0, 0.3, 0.9), "base-1"); + assert!(s.vetoed); + assert!(s.breathing.is_none()); + } + + #[test] + fn stale_bank_flagged() { + let mix = MixtureOfSpecialists::new(bank()); + let s = mix.infer(&live(10.0, 0.2, 0.3, 0.9), "base-2"); + assert!(s.stale); + } +} diff --git a/v2/crates/wifi-densepose-calibration/src/specialist.rs b/v2/crates/wifi-densepose-calibration/src/specialist.rs new file mode 100644 index 00000000..8a719632 --- /dev/null +++ b/v2/crates/wifi-densepose-calibration/src/specialist.rs @@ -0,0 +1,430 @@ +//! Specialist models (ADR-151 Stage 4). +//! +//! One small, room-calibrated model per biological signal — *specialisation over +//! scale*. Each is fit from the labelled enrollment anchors and is tiny: a +//! threshold, a handful of nearest-prototype vectors, or a band-limited +//! periodicity read. Faster, cheaper, more private, and — because it is tuned to +//! this room's fingerprint — often better than one oversized general model. +//! +//! (ADR-151's frozen Hugging-Face RF Foundation Encoder backbone is the planned +//! upgrade path: these heads would then sit over a shared embedding. The +//! statistical heads here make the pipeline runnable and validatable today.) + +use serde::{Deserialize, Serialize}; + +use crate::anchor::{AnchorLabel, Posture}; +use crate::extract::{AnchorFeature, Features}; + +/// Which biological signal a specialist estimates. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum SpecialistKind { + /// Respiration rate. + Breathing, + /// Heart rate (experimental on commodity CSI). + Heartbeat, + /// Sleep restlessness / movement intensity. + Restlessness, + /// Body posture (standing / sitting / lying). + Posture, + /// Presence (room occupied or not). + Presence, + /// Physically-implausible / out-of-distribution signal. + Anomaly, +} + +/// A single specialist's output. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SpecialistReading { + /// Which specialist. + pub kind: SpecialistKind, + /// Numeric value (BPM, score, or class index — see [`SpecialistReading::label`]). + pub value: f32, + /// Confidence in `[0, 1]`. + pub confidence: f32, + /// Optional human-readable label (e.g. posture class). + pub label: Option, +} + +/// Common specialist behaviour. +pub trait Specialist { + /// Which signal this estimates. + fn kind(&self) -> SpecialistKind; + /// Infer from a live feature window; `None` when not applicable / no confidence. + fn infer(&self, f: &Features) -> Option; +} + +// --------------------------------------------------------------------------- +// Presence +// --------------------------------------------------------------------------- + +/// Binary presence gate: variance threshold learned from empty vs occupied anchors. +#[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, +} + +impl PresenceSpecialist { + /// Fit from anchors: midpoint between the empty variance and the mean + /// occupied variance. + pub fn train(anchors: &[AnchorFeature]) -> Option { + let empty = anchors.iter().find(|a| a.label == AnchorLabel::Empty)?; + let occ: Vec = anchors + .iter() + .filter(|a| a.label.expects_presence()) + .map(|a| a.features.variance) + .collect(); + if occ.is_empty() { + return None; + } + let occ_mean = occ.iter().sum::() / occ.len() as f32; + let empty_var = empty.features.variance; + Some(Self { + threshold: 0.5 * (empty_var + occ_mean), + occupied_var: occ_mean.max(empty_var + 1e-3), + }) + } +} + +impl Specialist for PresenceSpecialist { + fn kind(&self) -> SpecialistKind { + 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); + Some(SpecialistReading { + kind: SpecialistKind::Presence, + value: if present { 1.0 } else { 0.0 }, + confidence, + label: Some(if present { "present" } else { "absent" }.into()), + }) + } +} + +// --------------------------------------------------------------------------- +// Posture (nearest-prototype) +// --------------------------------------------------------------------------- + +/// Posture classifier: nearest prototype over the feature embedding. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PostureSpecialist { + /// `(posture, embedding)` prototypes from the posture anchors. + pub prototypes: Vec<(Posture, [f32; 5])>, +} + +impl PostureSpecialist { + /// Fit prototypes from any anchor that establishes a posture. + pub fn train(anchors: &[AnchorFeature]) -> Option { + let prototypes: Vec<(Posture, [f32; 5])> = anchors + .iter() + .filter_map(|a| a.label.posture().map(|p| (p, a.features.embedding()))) + .collect(); + if prototypes.is_empty() { + None + } else { + Some(Self { prototypes }) + } + } + + fn posture_str(p: Posture) -> &'static str { + match p { + Posture::Standing => "standing", + Posture::Sitting => "sitting", + Posture::Lying => "lying", + } + } +} + +impl Specialist for PostureSpecialist { + fn kind(&self) -> SpecialistKind { + SpecialistKind::Posture + } + fn infer(&self, f: &Features) -> Option { + let emb = f.embedding(); + let mut best = (f32::MAX, Posture::Standing); + let mut second = f32::MAX; + for (p, proto) in &self.prototypes { + let d: f32 = emb.iter().zip(proto).map(|(a, b)| (a - b) * (a - b)).sum(); + if d < best.0 { + second = best.0; + best = (d, *p); + } else if d < second { + second = d; + } + } + // Confidence from the margin between nearest and runner-up. + let confidence = if second.is_finite() && (best.0 + second) > 1e-6 { + ((second - best.0) / (second + best.0)).clamp(0.0, 1.0) + } else { + 0.5 + }; + Some(SpecialistReading { + kind: SpecialistKind::Posture, + value: best.1 as u8 as f32, + confidence, + label: Some(Self::posture_str(best.1).into()), + }) + } +} + +// --------------------------------------------------------------------------- +// Breathing / Heartbeat (band-limited periodicity) +// --------------------------------------------------------------------------- + +/// Respiration-rate read from the breathing-band periodicity. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct BreathingSpecialist { + /// Minimum periodicity score to report a rate. + pub min_score: f32, +} + +impl Specialist for BreathingSpecialist { + fn kind(&self) -> SpecialistKind { + SpecialistKind::Breathing + } + fn infer(&self, f: &Features) -> Option { + let min = if self.min_score > 0.0 { self.min_score } else { 0.25 }; + if f.breathing_score < min || f.breathing_hz <= 0.0 { + return None; + } + Some(SpecialistReading { + kind: SpecialistKind::Breathing, + value: f.breathing_hz * 60.0, + confidence: f.breathing_score, + label: None, + }) + } +} + +/// Heart-rate read from the HR-band periodicity (experimental on CSI). +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct HeartbeatSpecialist { + /// Minimum periodicity score to report a rate. + pub min_score: f32, +} + +impl Specialist for HeartbeatSpecialist { + fn kind(&self) -> SpecialistKind { + SpecialistKind::Heartbeat + } + fn infer(&self, f: &Features) -> Option { + let min = if self.min_score > 0.0 { self.min_score } else { 0.3 }; + if f.heart_score < min || f.heart_hz <= 0.0 { + return None; + } + Some(SpecialistReading { + kind: SpecialistKind::Heartbeat, + value: f.heart_hz * 60.0, + confidence: f.heart_score, + label: None, + }) + } +} + +// --------------------------------------------------------------------------- +// Restlessness +// --------------------------------------------------------------------------- + +/// Restlessness: live motion normalized between the calm (sleep) and active +/// (small-move) anchors. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RestlessnessSpecialist { + /// Motion at rest (sleep posture). + pub calm_motion: f32, + /// Motion when actively moving. + pub active_motion: f32, +} + +impl RestlessnessSpecialist { + /// Fit from the sleep-posture (calm) and small-move (active) anchors. + pub fn train(anchors: &[AnchorFeature]) -> Option { + let calm = anchors + .iter() + .find(|a| a.label == AnchorLabel::SleepPosture) + .or_else(|| anchors.iter().find(|a| a.label == AnchorLabel::LieDown))? + .features + .motion; + let active = anchors + .iter() + .find(|a| a.label == AnchorLabel::SmallMove)? + .features + .motion; + if active <= calm { + return None; + } + Some(Self { + calm_motion: calm, + active_motion: active, + }) + } +} + +impl Specialist for RestlessnessSpecialist { + fn kind(&self) -> SpecialistKind { + SpecialistKind::Restlessness + } + fn infer(&self, f: &Features) -> Option { + let span = (self.active_motion - self.calm_motion).max(1e-3); + let r = ((f.motion - self.calm_motion) / span).clamp(0.0, 1.0); + Some(SpecialistReading { + kind: SpecialistKind::Restlessness, + value: r, + confidence: 0.7, + label: None, + }) + } +} + +// --------------------------------------------------------------------------- +// Anomaly (novelty vs anchor prototypes) +// --------------------------------------------------------------------------- + +/// Anomaly detector: distance from the manifold of enrolled anchors. A live +/// window far from every anchor prototype is out-of-distribution. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AnomalySpecialist { + /// Anchor embeddings (the in-distribution manifold). + pub prototypes: Vec<[f32; 5]>, + /// Distance scale (typical inter-anchor spread) for normalization. + pub scale: f32, +} + +impl AnomalySpecialist { + /// Fit from all anchor embeddings. + pub fn train(anchors: &[AnchorFeature]) -> Option { + if anchors.len() < 2 { + return None; + } + let prototypes: Vec<[f32; 5]> = anchors.iter().map(|a| a.features.embedding()).collect(); + // Scale = mean nearest-neighbour distance among prototypes. + let mut nn_sum = 0.0f32; + for (i, p) in prototypes.iter().enumerate() { + let mut best = f32::MAX; + for (j, q) in prototypes.iter().enumerate() { + if i == j { + continue; + } + let d: f32 = p.iter().zip(q).map(|(a, b)| (a - b) * (a - b)).sum(); + best = best.min(d); + } + if best.is_finite() { + nn_sum += best.sqrt(); + } + } + let scale = (nn_sum / prototypes.len() as f32).max(1e-3); + Some(Self { prototypes, scale }) + } +} + +impl Specialist for AnomalySpecialist { + fn kind(&self) -> SpecialistKind { + SpecialistKind::Anomaly + } + fn infer(&self, f: &Features) -> Option { + let emb = f.embedding(); + let mut best = f32::MAX; + for proto in &self.prototypes { + let d: f32 = emb + .iter() + .zip(proto) + .map(|(a, b)| (a - b) * (a - b)) + .sum::() + .sqrt(); + best = best.min(d); + } + // >2× the typical spread → anomalous. + let score = (best / (2.0 * self.scale)).clamp(0.0, 1.0); + Some(SpecialistReading { + kind: SpecialistKind::Anomaly, + value: score, + confidence: 0.6, + label: Some(if score > 0.5 { "anomalous" } else { "normal" }.into()), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn feat(variance: f32, motion: f32, br_hz: f32, br_score: f32) -> Features { + Features { + mean: 1.0, + variance, + motion, + breathing_score: br_score, + breathing_hz: br_hz, + heart_score: 0.0, + heart_hz: 0.0, + } + } + + fn af(label: AnchorLabel, variance: f32, motion: f32) -> AnchorFeature { + AnchorFeature { + room_id: "r".into(), + label, + features: feat(variance, motion, 0.0, 0.0), + } + } + + #[test] + fn presence_learns_threshold_and_classifies() { + let anchors = vec![ + af(AnchorLabel::Empty, 1.0, 0.1), + af(AnchorLabel::StandStill, 10.0, 0.2), + ]; + let p = PresenceSpecialist::train(&anchors).unwrap(); + assert!(p.infer(&feat(12.0, 0.2, 0.0, 0.0)).unwrap().value == 1.0); + assert!(p.infer(&feat(1.0, 0.1, 0.0, 0.0)).unwrap().value == 0.0); + } + + #[test] + fn posture_nearest_prototype() { + let anchors = vec![ + af(AnchorLabel::StandStill, 10.0, 0.2), + af(AnchorLabel::Sit, 6.0, 0.2), + af(AnchorLabel::LieDown, 3.0, 0.2), + ]; + let post = PostureSpecialist::train(&anchors).unwrap(); + // A window close to the standing prototype. + let r = post.infer(&feat(10.1, 0.2, 0.0, 0.0)).unwrap(); + assert_eq!(r.label.as_deref(), Some("standing")); + } + + #[test] + fn breathing_reports_bpm() { + let b = BreathingSpecialist::default(); + let r = b.infer(&feat(5.0, 0.2, 0.3, 0.8)).unwrap(); + assert!((r.value - 18.0).abs() < 0.1); // 0.3 Hz = 18 BPM + assert!(r.confidence > 0.5); + assert!(b.infer(&feat(5.0, 0.2, 0.3, 0.1)).is_none()); // low score → none + } + + #[test] + fn restlessness_normalizes() { + let anchors = vec![ + af(AnchorLabel::SleepPosture, 3.0, 0.1), + af(AnchorLabel::SmallMove, 3.0, 1.1), + ]; + let rs = RestlessnessSpecialist::train(&anchors).unwrap(); + assert!(rs.infer(&feat(3.0, 0.1, 0.0, 0.0)).unwrap().value < 0.1); + assert!(rs.infer(&feat(3.0, 1.1, 0.0, 0.0)).unwrap().value > 0.9); + } + + #[test] + fn anomaly_flags_outliers() { + let anchors = vec![ + af(AnchorLabel::Empty, 1.0, 0.1), + af(AnchorLabel::StandStill, 10.0, 0.2), + af(AnchorLabel::Sit, 6.0, 0.2), + ]; + let a = AnomalySpecialist::train(&anchors).unwrap(); + // Far-out window. + let r = a.infer(&feat(500.0, 50.0, 0.0, 0.0)).unwrap(); + assert!(r.value > 0.5, "score {}", r.value); + } +}