532 lines
19 KiB
Rust
532 lines
19 KiB
Rust
//! 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<String>,
|
||
}
|
||
|
||
/// 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<SpecialistReading>;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 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<f32>,
|
||
}
|
||
|
||
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<Self> {
|
||
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::<f32>() / occ.len() as f32;
|
||
let occ_mean = occ.iter().map(|f| f.mean).sum::<f32>() / 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<SpecialistReading> {
|
||
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<Self> {
|
||
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<SpecialistReading> {
|
||
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<SpecialistReading> {
|
||
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<SpecialistReading> {
|
||
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<Self> {
|
||
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<SpecialistReading> {
|
||
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<Self> {
|
||
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<SpecialistReading> {
|
||
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::<f32>()
|
||
.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);
|
||
}
|
||
}
|