mirror of
https://github.com/ruvnet/RuView
synced 2026-07-03 14:13:17 +00:00
feat(calibration): ADR-151 Stages 2–5 — enrollment, extraction, specialist bank, runtime
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 <ruv@ruv.net>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
@@ -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> {
|
||||
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<Posture> {
|
||||
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<EnrollmentEvent>,
|
||||
}
|
||||
|
||||
impl EnrollmentSession {
|
||||
/// Open a new session.
|
||||
pub fn new(room_id: impl Into<String>, baseline_id: impl Into<String>, 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<Anchor> {
|
||||
let mut out: Vec<Anchor> = 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<AnchorLabel> {
|
||||
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<AnchorLabel> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -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<PresenceSpecialist>,
|
||||
/// Posture classifier (requires posture anchors).
|
||||
pub posture: Option<PostureSpecialist>,
|
||||
/// Breathing (band-limited periodicity; stateless).
|
||||
pub breathing: BreathingSpecialist,
|
||||
/// Heartbeat (band-limited periodicity; stateless).
|
||||
pub heartbeat: HeartbeatSpecialist,
|
||||
/// Restlessness (requires calm + active anchors).
|
||||
pub restlessness: Option<RestlessnessSpecialist>,
|
||||
/// Anomaly novelty detector (requires ≥2 anchors).
|
||||
pub anomaly: Option<AnomalySpecialist>,
|
||||
}
|
||||
|
||||
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<String>,
|
||||
baseline_id: impl Into<String>,
|
||||
anchors: &[AnchorFeature],
|
||||
at_unix_s: i64,
|
||||
) -> Result<Self> {
|
||||
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<SpecialistKind> {
|
||||
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<String> {
|
||||
serde_json::to_string_pretty(self).map_err(|e| CalibrationError::Serde(e.to_string()))
|
||||
}
|
||||
|
||||
/// Deserialize from JSON.
|
||||
pub fn from_json(s: &str) -> Result<Self> {
|
||||
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<AnchorFeature> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -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<String>) {
|
||||
let mut reason: Option<String> = 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<String>) {
|
||||
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<String>) {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
@@ -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<String>,
|
||||
},
|
||||
|
||||
/// 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<T> = std::result::Result<T, CalibrationError>;
|
||||
@@ -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::<f32>() / n as f32;
|
||||
let variance =
|
||||
series.iter().map(|v| (v - mean) * (v - mean)).sum::<f32>() / n as f32;
|
||||
let motion = if n > 1 {
|
||||
series.windows(2).map(|w| (w[1] - w[0]).abs()).sum::<f32>() / (n - 1) as f32
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
// De-mean before periodicity search.
|
||||
let centered: Vec<f32> = 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<String>,
|
||||
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<f32> {
|
||||
(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<f32> = (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);
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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<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);
|
||||
}
|
||||
}
|
||||
@@ -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<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: 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<Self> {
|
||||
let empty = anchors.iter().find(|a| a.label == AnchorLabel::Empty)?;
|
||||
let occ: Vec<f32> = 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::<f32>() / 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<SpecialistReading> {
|
||||
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<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),
|
||||
}
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user