mirror of
https://github.com/ruvnet/RuView
synced 2026-08-09 20:21:43 +00:00
7a0158c44d
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>
337 lines
10 KiB
Rust
337 lines
10 KiB
Rust
//! 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());
|
||
}
|
||
}
|