//! 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 learned from empty vs occupied anchors. /// /// Two complementary signals (ADR-152 finding, "variance-only presence"): /// - **variance** — motion/occupancy energy; catches a moving person but is /// blind to a *motionless* one, whose body raises the scalar *mean* (extra /// multipath energy) while barely raising variance; /// - **mean shift** — |mean − empty-room mean|; catches the motionless person /// the variance channel misses. Symmetric (abs) because a body can shadow /// paths and *lower* the mean too. /// /// Present when EITHER channel fires. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PresenceSpecialist { /// Decision threshold on series variance. pub threshold: f32, /// Occupied-anchor mean variance (for confidence scaling). pub occupied_var: f32, /// Empty-room mean of the scalar series (mean-shift reference). #[serde(default)] pub empty_mean: f32, /// |mean − empty_mean| beyond which the mean alone indicates presence. /// `None` disables the channel — both for banks persisted before the /// channel existed (serde default) and for rooms where the empty/occupied /// means don't separate at train time. #[serde(default)] pub mean_dist_threshold: Option, } impl PresenceSpecialist { /// Fit from anchors: variance threshold at the midpoint between the empty /// variance and the mean occupied variance; mean-shift threshold at half /// the empty→occupied mean distance (inert when the means don't separate). pub fn train(anchors: &[AnchorFeature]) -> Option { let empty = anchors.iter().find(|a| a.label == AnchorLabel::Empty)?; let occ: Vec<&Features> = anchors .iter() .filter(|a| a.label.expects_presence()) .map(|a| &a.features) .collect(); if occ.is_empty() { return None; } let occ_var = occ.iter().map(|f| f.variance).sum::() / occ.len() as f32; let occ_mean = occ.iter().map(|f| f.mean).sum::() / occ.len() as f32; let empty_var = empty.features.variance; let empty_mean = empty.features.mean; let mean_dist = (occ_mean - empty_mean).abs(); let mean_dist_threshold = (mean_dist > 1e-4).then(|| 0.5 * mean_dist); Some(Self { threshold: 0.5 * (empty_var + occ_var), occupied_var: occ_var.max(empty_var + 1e-3), empty_mean, mean_dist_threshold, }) } } impl Specialist for PresenceSpecialist { fn kind(&self) -> SpecialistKind { SpecialistKind::Presence } fn infer(&self, f: &Features) -> Option { let by_variance = f.variance > self.threshold; let mean_dist = (f.mean - self.empty_mean).abs(); let by_mean = self.mean_dist_threshold.is_some_and(|thr| mean_dist > thr); let present = by_variance || by_mean; // Confidence: strongest margin among the channels that are enabled. let var_span = (self.occupied_var - self.threshold).max(1e-3); let var_conf = ((f.variance - self.threshold).abs() / var_span).clamp(0.0, 1.0); let mean_conf = self .mean_dist_threshold .map(|thr| ((mean_dist - thr).abs() / thr.max(1e-3)).clamp(0.0, 1.0)) .unwrap_or(0.0); let confidence = var_conf.max(mean_conf); Some(SpecialistReading { kind: SpecialistKind::Presence, value: if present { 1.0 } else { 0.0 }, 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), } } /// Like `feat` but with an explicit series mean (the presence mean-gate input). fn feat_mean(mean: f32, variance: f32, motion: f32) -> Features { Features { mean, variance, motion, breathing_score: 0.0, breathing_hz: 0.0, heart_score: 0.0, heart_hz: 0.0, } } fn af_mean(label: AnchorLabel, mean: f32, variance: f32, motion: f32) -> AnchorFeature { AnchorFeature { room_id: "r".into(), label, features: feat_mean(mean, variance, motion), } } #[test] fn presence_learns_threshold_and_classifies() { let anchors = vec![ 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); } /// ADR-152 "variance-only presence" regression: a MOTIONLESS person raises /// the scalar mean (extra multipath energy) but barely the variance — the /// mean channel must still detect them, and a window matching the empty /// room on BOTH channels must still read absent. #[test] fn presence_detects_motionless_person_via_mean_shift() { let anchors = vec![ af_mean(AnchorLabel::Empty, 1.0, 1.0, 0.1), af_mean(AnchorLabel::StandStill, 1.6, 10.0, 0.2), af_mean(AnchorLabel::LieDown, 1.5, 8.0, 0.15), ]; let p = PresenceSpecialist::train(&anchors).unwrap(); // Motionless person: variance at the empty level, mean shifted. let r = p.infer(&feat_mean(1.55, 1.0, 0.05)).unwrap(); assert_eq!(r.value, 1.0, "motionless person must read present"); // Truly empty window: both channels quiet. let r = p.infer(&feat_mean(1.0, 1.0, 0.05)).unwrap(); assert_eq!(r.value, 0.0, "empty room must still read absent"); } /// Banks persisted BEFORE the mean gate existed must deserialize to the /// inert (+∞) gate and keep their original variance-only behavior. #[test] fn presence_old_bank_json_stays_variance_only() { let old_json = r#"{"threshold":5.5,"occupied_var":10.0}"#; let p: PresenceSpecialist = serde_json::from_str(old_json).unwrap(); assert!(p.mean_dist_threshold.is_none()); // Mean wildly shifted but variance below threshold → still absent // (old behavior preserved; the mean channel is disabled). let r = p.infer(&feat_mean(99.0, 1.0, 0.05)).unwrap(); assert_eq!(r.value, 0.0); } #[test] fn posture_nearest_prototype() { let anchors = vec![ 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); } }