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:
ruv
2026-06-09 12:05:05 -04:00
parent 901f12add9
commit 7a0158c44d
10 changed files with 1697 additions and 0 deletions
+1
View File
@@ -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.10.15 Hz).
BreatheSlow,
/// Normal respiration (~0.20.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(&centered, fs, 0.1, 0.6);
let (heart_hz, heart_score) = autocorr_dominant(&centered, 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);
}
}