179 lines
5.8 KiB
Rust
179 lines
5.8 KiB
Rust
//! 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<SpecialistReading>,
|
|
/// Posture reading.
|
|
pub posture: Option<SpecialistReading>,
|
|
/// Breathing reading (BPM).
|
|
pub breathing: Option<SpecialistReading>,
|
|
/// Heartbeat reading (BPM).
|
|
pub heartbeat: Option<SpecialistReading>,
|
|
/// Restlessness reading [0, 1].
|
|
pub restlessness: Option<SpecialistReading>,
|
|
/// Anomaly reading [0, 1].
|
|
pub anomaly: Option<SpecialistReading>,
|
|
/// 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);
|
|
}
|
|
}
|