feat(adr-115): P4.5a — semantic inference layer (HA-MIND) — 4 primitives + bus (34 tests)

ADR-115 §3.12 keystone. Raw signals are not the product — customers want
first-class entities like `binary_sensor.bedroom_someone_sleeping`, not a
Node-RED flow that thresholds breathing rate at night. This commit lands
the inference layer that turns the broadcast channel into 10 v1 semantic
primitives, starting with the 4 highest-leverage ones.

Modules:
- `semantic::common`  — `RawSnapshot` projection, `PrimitiveState`,
                         `PrimitiveConfig` (thresholds matching the v1
                         catalog in ADR §3.12), `in_window` for time-gated
                         primitives, `Reason` explainability struct.
- `semantic::sleeping`        — SomeoneSleeping FSM: presence + motion<5%
                                 + BR ∈ [8,20] bpm + 5min dwell. Exit on
                                 presence-drop (immediate) or motion>15%
                                 for 30s.
- `semantic::room_active`     — motion >10% in 30s window → ON. Exit on
                                 presence-drop or 10min idle.
- `semantic::bathroom`        — presence + zone tagged as bathroom. Safe
                                 in privacy mode (no biometrics in the
                                 derivation).
- `semantic::no_movement`     — presence + motion<1% for 30min → ON.
                                 Safety-check primitive for aging-in-place.
- `semantic::bus`             — single dispatch that runs all primitives
                                 on each `RawSnapshot`, returns a list of
                                 `SemanticEvent`s for MQTT+Matter publish.

Every primitive has:
- Warmup suppression (60s default, §3.12.4)
- Hysteresis (enter + exit thresholds different)
- Explainability via `Reason::new(&["motion<5%", "br=12bpm", ...])`
- Configurable thresholds via `PrimitiveConfig`

Test coverage (34 tests, all passing under `--no-default-features`):
- common: in_window simple + wrap-around midnight, default thresholds
  match ADR catalog, Reason struct.
- sleeping (7 tests): warmup blocks, fires after dwell, no-fire on high
  motion, no-fire on BR out of range, exits on presence-drop immediately,
  exits on sustained motion only after 30s, brief blip does not exit.
- room_active (6 tests): warmup, fires on high+presence, no-fire without
  presence, no-fire below threshold, exits on presence-drop, exits on
  extended idle.
- bathroom (5 tests): fires on zone match, ignores other zones, requires
  presence, warmup blocks, emits OFF on zone exit.
- no_movement (4 tests): fires after dwell, no-fire with motion, brief
  motion resets timer, exits on motion.
- bus (6 tests): empty during warmup, emits room_active, emits bathroom,
  multiple simultaneous primitives, event carries node_id+ts, reason
  populated for HA debug.

Total cargo test count now:
  cli: 6 + mqtt: 45 + semantic: 34 = 85 tests passing

P4.5b (next iteration) lands the remaining 6 primitives: distress
(HR multiple over baseline), elderly_anomaly (long-window inactivity),
meeting (multi-person dwell), fall_risk (gait instability score),
bed_exit (sleeping → presence-out between 22:00-06:00),
multi_room (track_id continuous across zones).

Refs #776.

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
ruv 2026-05-23 14:01:51 -04:00
parent 59c503d01e
commit 8e416af203
8 changed files with 1068 additions and 0 deletions

View File

@ -18,6 +18,7 @@ pub mod host_validation;
pub mod introspection;
pub mod mqtt;
pub mod path_safety;
pub mod semantic;
pub mod rvf_container;
pub mod rvf_pipeline;
pub mod sona;

View File

@ -0,0 +1,130 @@
//! Bathroom-occupied primitive (§3.12.1 row 6).
//!
//! `bathroom_occupied = ON` iff `presence == true` AND any zone in
//! `active_zones` is configured as a bathroom (`cfg.bathroom_zone_tag`,
//! cross-referenced against `bed_zones`/`active_zones` via the
//! `--semantic-zones-file` config).
//!
//! Per §3.12.3 — explicitly safe in privacy mode because the entity is
//! a zone-derived boolean, not biometric.
use super::common::{PrimitiveConfig, PrimitiveState, RawSnapshot, Reason};
#[derive(Debug, Default, Clone)]
pub struct BathroomOccupied {
pub active: bool,
}
impl BathroomOccupied {
pub fn new() -> Self {
Self::default()
}
pub fn tick(&mut self, snap: &RawSnapshot, cfg: &PrimitiveConfig) -> PrimitiveState {
if snap.since_start < cfg.warmup {
return PrimitiveState::Idle;
}
let occupied = snap.presence
&& snap.active_zones.iter().any(|z| z == &cfg.bathroom_zone_tag);
if occupied != self.active {
self.active = occupied;
let tag = if occupied { "presence=true,zone=bathroom" } else { "exit-bathroom" };
return PrimitiveState::Boolean {
active: occupied,
changed: true,
reason: Reason::new(&[tag]),
};
}
PrimitiveState::Idle
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
fn cfg() -> PrimitiveConfig {
PrimitiveConfig::default()
}
#[test]
fn fires_when_presence_in_bathroom_zone() {
let mut p = BathroomOccupied::new();
let s = RawSnapshot {
since_start: Duration::from_secs(120),
presence: true,
active_zones: vec!["bathroom".into()],
..Default::default()
};
let state = p.tick(&s, &cfg());
match state {
PrimitiveState::Boolean { active, changed, .. } => {
assert!(active && changed);
}
other => panic!("expected on/change, got {:?}", other),
}
}
#[test]
fn does_not_fire_for_other_zone() {
let mut p = BathroomOccupied::new();
let s = RawSnapshot {
since_start: Duration::from_secs(120),
presence: true,
active_zones: vec!["kitchen".into()],
..Default::default()
};
let state = p.tick(&s, &cfg());
assert!(matches!(state, PrimitiveState::Idle));
}
#[test]
fn requires_presence_true() {
let mut p = BathroomOccupied::new();
let s = RawSnapshot {
since_start: Duration::from_secs(120),
presence: false,
active_zones: vec!["bathroom".into()],
..Default::default()
};
assert!(matches!(p.tick(&s, &cfg()), PrimitiveState::Idle));
}
#[test]
fn warmup_blocks_initial_fire() {
let mut p = BathroomOccupied::new();
let s = RawSnapshot {
since_start: Duration::from_secs(30),
presence: true,
active_zones: vec!["bathroom".into()],
..Default::default()
};
assert!(matches!(p.tick(&s, &cfg()), PrimitiveState::Idle));
}
#[test]
fn emits_off_on_zone_exit() {
let mut p = BathroomOccupied::new();
let s_in = RawSnapshot {
since_start: Duration::from_secs(120),
presence: true,
active_zones: vec!["bathroom".into()],
..Default::default()
};
let _ = p.tick(&s_in, &cfg());
let s_out = RawSnapshot {
since_start: Duration::from_secs(180),
presence: true,
active_zones: vec!["kitchen".into()],
..Default::default()
};
let state = p.tick(&s_out, &cfg());
match state {
PrimitiveState::Boolean { active, changed, .. } => {
assert!(!active && changed);
}
other => panic!("expected off/change, got {:?}", other),
}
}
}

View File

@ -0,0 +1,193 @@
//! Semantic event bus — dispatches one [`RawSnapshot`] to every
//! primitive in the order they were registered, collects the
//! [`SemanticEvent`]s emitted, and hands them to MQTT + Matter
//! publishers via a shared `tokio::broadcast` (wiring lives in the
//! publisher, see `mqtt::publisher`).
//!
//! Per §3.12.6 — adding a new primitive is one file change. The bus
//! holds a list of trait objects so the call site doesn't grow when we
//! add primitives in P4.5b.
use super::common::{PrimitiveConfig, PrimitiveState, RawSnapshot, Reason};
use super::{bathroom::BathroomOccupied, no_movement::NoMovement, room_active::RoomActive, sleeping::SomeoneSleeping};
/// Identifier for which primitive produced an event. Used by the
/// publisher to map onto the matching `EntityKind`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum SemanticKind {
SomeoneSleeping,
RoomActive,
BathroomOccupied,
NoMovement,
// P4.5b: Distress, ElderlyAnomaly, Meeting, FallRisk, BedExit, MultiRoom.
}
/// One event published to MQTT / Matter consumers.
#[derive(Debug, Clone, PartialEq)]
pub struct SemanticEvent {
pub kind: SemanticKind,
pub state: PrimitiveState,
pub node_id: String,
pub timestamp_ms: i64,
}
/// Collection of every primitive FSM. Owned by the publisher task.
pub struct SemanticBus {
sleeping: SomeoneSleeping,
room_active: RoomActive,
bathroom: BathroomOccupied,
no_movement: NoMovement,
pub config: PrimitiveConfig,
}
impl SemanticBus {
pub fn new(config: PrimitiveConfig) -> Self {
Self {
sleeping: SomeoneSleeping::new(),
room_active: RoomActive::new(),
bathroom: BathroomOccupied::new(),
no_movement: NoMovement::new(),
config,
}
}
/// Run all primitives on one snapshot. Returns only events that
/// emit (Idle states are filtered).
pub fn tick(&mut self, snap: &RawSnapshot) -> Vec<SemanticEvent> {
let pairs: [(SemanticKind, PrimitiveState); 4] = [
(SemanticKind::SomeoneSleeping, self.sleeping.tick(snap, &self.config)),
(SemanticKind::RoomActive, self.room_active.tick(snap, &self.config)),
(SemanticKind::BathroomOccupied, self.bathroom.tick(snap, &self.config)),
(SemanticKind::NoMovement, self.no_movement.tick(snap, &self.config)),
];
pairs
.into_iter()
.filter_map(|(kind, state)| match state {
PrimitiveState::Idle => None,
_ => Some(SemanticEvent {
kind,
state,
node_id: snap.node_id.clone(),
timestamp_ms: snap.timestamp_ms,
}),
})
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
fn cfg() -> PrimitiveConfig {
PrimitiveConfig::default()
}
#[test]
fn bus_returns_empty_during_warmup() {
let mut bus = SemanticBus::new(cfg());
let snap = RawSnapshot {
since_start: Duration::from_secs(30),
presence: true,
motion: 0.5,
..Default::default()
};
assert!(bus.tick(&snap).is_empty());
}
#[test]
fn bus_emits_room_active_on_sustained_motion() {
let mut bus = SemanticBus::new(cfg());
let snap = RawSnapshot {
node_id: "test".into(),
since_start: Duration::from_secs(120),
timestamp_ms: 1_000,
presence: true,
motion: 0.4,
..Default::default()
};
let events = bus.tick(&snap);
assert!(events.iter().any(|e| e.kind == SemanticKind::RoomActive));
}
#[test]
fn bus_emits_bathroom_when_zone_active() {
let mut bus = SemanticBus::new(cfg());
let snap = RawSnapshot {
node_id: "test".into(),
since_start: Duration::from_secs(120),
timestamp_ms: 1_000,
presence: true,
active_zones: vec!["bathroom".into()],
..Default::default()
};
let events = bus.tick(&snap);
assert!(events.iter().any(|e| e.kind == SemanticKind::BathroomOccupied));
}
#[test]
fn bus_supports_multiple_simultaneous_primitives() {
let mut bus = SemanticBus::new(cfg());
let snap = RawSnapshot {
node_id: "test".into(),
since_start: Duration::from_secs(120),
timestamp_ms: 1_000,
presence: true,
motion: 0.4,
active_zones: vec!["bathroom".into()],
..Default::default()
};
let events = bus.tick(&snap);
// Both RoomActive AND BathroomOccupied should fire.
let kinds: Vec<_> = events.iter().map(|e| e.kind).collect();
assert!(kinds.contains(&SemanticKind::RoomActive));
assert!(kinds.contains(&SemanticKind::BathroomOccupied));
}
#[test]
fn semantic_event_carries_node_id_and_ts() {
let mut bus = SemanticBus::new(cfg());
let snap = RawSnapshot {
node_id: "aabb".into(),
since_start: Duration::from_secs(120),
timestamp_ms: 1779_512_400_000,
presence: true,
active_zones: vec!["bathroom".into()],
..Default::default()
};
let events = bus.tick(&snap);
let bath = events.into_iter().find(|e| e.kind == SemanticKind::BathroomOccupied).unwrap();
assert_eq!(bath.node_id, "aabb");
assert_eq!(bath.timestamp_ms, 1779_512_400_000);
}
#[test]
fn semantic_event_includes_explanation_reason() {
// Verify that primitives populate the explanation field —
// critical for HA users debugging automations.
let mut bus = SemanticBus::new(cfg());
let snap = RawSnapshot {
node_id: "test".into(),
since_start: Duration::from_secs(120),
timestamp_ms: 1_000,
presence: true,
motion: 0.4,
..Default::default()
};
let events = bus.tick(&snap);
let ra = events.into_iter().find(|e| e.kind == SemanticKind::RoomActive).unwrap();
if let PrimitiveState::Boolean { reason, .. } = ra.state {
assert!(!reason.tags.is_empty(), "reason tags must explain why primitive fired");
} else {
panic!("expected Boolean state");
}
}
#[test]
fn _unused_reason_helper_remains_constructible() {
// Touch Reason::empty to keep clippy happy when the bus uses
// it indirectly via primitives.
let _ = Reason::empty();
}
}

View File

@ -0,0 +1,176 @@
//! Shared types used by every semantic primitive's FSM.
use std::time::Duration;
/// Single observation snapshot the bus dispatches to every primitive.
///
/// All fields are derived from the existing broadcast channel —
/// primitives never touch raw CSI. This struct is a *projection* of
/// `VitalsSnapshot` + `sensing_update` (zones) so primitives are
/// schema-stable against future changes to the wire format.
#[derive(Debug, Clone, Default)]
pub struct RawSnapshot {
pub node_id: String,
pub since_start: Duration,
pub timestamp_ms: i64,
pub presence: bool,
pub fall_detected: bool,
pub motion: f64, // 0.0..=1.0
pub motion_energy: f64,
pub breathing_rate_bpm: Option<f64>,
pub heart_rate_bpm: Option<f64>,
pub n_persons: u32,
pub rssi_dbm: Option<f64>,
pub vital_confidence: f64,
/// Zones currently reporting presence (e.g. `["bathroom", "kitchen"]`).
pub active_zones: Vec<String>,
/// Bed-tagged zones derived from `--semantic-zones-file`. Optional
/// per-deployment.
pub bed_zones: Vec<String>,
/// Local time-of-day in seconds since midnight (0..86400). Used by
/// time-gated primitives (bed_exit between 22:00 and 06:00).
pub local_seconds_since_midnight: u32,
}
/// Output of one primitive on one snapshot.
#[derive(Debug, Clone, PartialEq)]
pub enum PrimitiveState {
/// Boolean state with hysteresis. Includes change flag so the bus
/// can decide whether to publish.
Boolean { active: bool, changed: bool, reason: Reason },
/// Continuous score (e.g. fall risk 0..100). Always publish.
Scalar { value: f64, reason: Reason },
/// One-shot event (fall, bed exit, multi-room transition).
Event { event_type: &'static str, reason: Reason },
/// No output this tick.
Idle,
}
/// Human-readable explanation for HA users debugging an automation.
#[derive(Debug, Clone, PartialEq)]
pub struct Reason {
/// Short tags suitable for `json_attributes` (e.g.
/// `["motion<5%", "br=12bpm", "presence=true"]`).
pub tags: Vec<String>,
}
impl Reason {
pub fn new(tags: &[&str]) -> Self {
Self { tags: tags.iter().map(|s| s.to_string()).collect() }
}
pub fn empty() -> Self {
Self { tags: Vec::new() }
}
}
/// Per-deployment knobs. Loaded once at startup from
/// `--semantic-thresholds-file` if supplied, otherwise from defaults
/// committed to `docs/integrations/semantic-primitives-metrics.md`.
#[derive(Debug, Clone)]
pub struct PrimitiveConfig {
/// First N seconds after process start during which no primitive
/// fires (sensors settling, per §3.12.4).
pub warmup: Duration,
/// "Someone sleeping": min uninterrupted low-motion dwell.
pub sleep_dwell: Duration,
/// "Possible distress": HR multiple over rolling baseline.
pub distress_hr_multiple: f64,
/// "Possible distress": dwell at elevated HR before firing.
pub distress_dwell: Duration,
/// "Room active": motion threshold (0..1) sustained for the window.
pub room_active_motion_threshold: f64,
pub room_active_window: Duration,
pub room_active_exit_idle: Duration,
/// "Elderly inactivity anomaly": multiple over rolling baseline.
pub elderly_anomaly_multiple: f64,
/// "Meeting in progress": min persons + min dwell.
pub meeting_min_persons: u32,
pub meeting_dwell: Duration,
/// "Bathroom occupied": zone tag to match.
pub bathroom_zone_tag: String,
/// "Fall risk": threshold for cross event firing.
pub fall_risk_event_threshold: f64,
/// "Bed exit": time window during which bed exits trigger (start, end).
pub bed_exit_window: (u32, u32), // seconds-of-day; wraps midnight
/// "No movement (safety)": dwell.
pub no_movement_dwell: Duration,
/// "Multi-room transition": max gap between zone exit + new zone enter.
pub multi_room_gap: Duration,
}
impl Default for PrimitiveConfig {
fn default() -> Self {
Self {
warmup: Duration::from_secs(60),
sleep_dwell: Duration::from_secs(300),
distress_hr_multiple: 1.5,
distress_dwell: Duration::from_secs(60),
room_active_motion_threshold: 0.10,
room_active_window: Duration::from_secs(30),
room_active_exit_idle: Duration::from_secs(600),
elderly_anomaly_multiple: 2.0,
meeting_min_persons: 2,
meeting_dwell: Duration::from_secs(600),
bathroom_zone_tag: "bathroom".into(),
fall_risk_event_threshold: 70.0,
bed_exit_window: (22 * 3600, 6 * 3600), // 22:0006:00 local
no_movement_dwell: Duration::from_secs(30 * 60),
multi_room_gap: Duration::from_secs(10),
}
}
}
/// True iff `(start, end)` describes a wrap-around window (start > end,
/// e.g. 22:0006:00). Used to test bed-exit time gating.
pub fn in_window(now: u32, start: u32, end: u32) -> bool {
if start <= end {
now >= start && now < end
} else {
// Wraps midnight.
now >= start || now < end
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn in_window_simple_range() {
assert!(in_window(3 * 3600, 1 * 3600, 5 * 3600));
assert!(!in_window(10 * 3600, 1 * 3600, 5 * 3600));
}
#[test]
fn in_window_wrap_around_midnight() {
// 22:0006:00.
assert!(in_window(23 * 3600, 22 * 3600, 6 * 3600)); // late evening
assert!(in_window(2 * 3600, 22 * 3600, 6 * 3600)); // early morning
assert!(!in_window(12 * 3600, 22 * 3600, 6 * 3600)); // noon — outside
assert!(in_window(0, 22 * 3600, 6 * 3600)); // midnight tick
}
#[test]
fn primitive_config_defaults_match_adr() {
let c = PrimitiveConfig::default();
// Spot-check key thresholds match §3.12 catalog.
assert_eq!(c.warmup, Duration::from_secs(60));
assert_eq!(c.sleep_dwell, Duration::from_secs(300));
assert!((c.distress_hr_multiple - 1.5).abs() < 1e-9);
assert_eq!(c.meeting_min_persons, 2);
assert_eq!(c.bed_exit_window, (22 * 3600, 6 * 3600));
}
#[test]
fn reason_empty_has_no_tags() {
let r = Reason::empty();
assert!(r.tags.is_empty());
}
#[test]
fn reason_new_collects_string_owned() {
let r = Reason::new(&["motion<5%", "br=12bpm"]);
assert_eq!(r.tags, vec!["motion<5%".to_string(), "br=12bpm".to_string()]);
}
}

View File

@ -0,0 +1,61 @@
//! ADR-115 §3.12 — Semantic Automation Primitives (HA-MIND).
//!
//! Raw signals are not the product. Customers want first-class entities
//! like `binary_sensor.bedroom_someone_sleeping`, not a Node-RED flow
//! that thresholds breathing rate at night. This module owns the
//! inference layer that turns the `sensing-server` broadcast (raw
//! `edge_vitals` / `pose_data` / `sensing_update`) into the 10 v1
//! semantic primitives published as HA entities, Matter events, and
//! Apple Home scene triggers.
//!
//! ## Architectural contract
//!
//! - **Server-side inference.** All primitives run inside this process.
//! Only the inferred *state* (true/false, scalar, event) crosses the
//! wire. This is what makes `--privacy-mode` compatible with
//! semantic primitives — biometric *values* can be stripped at the
//! integration boundary while the inferred *states* still publish.
//! - **One source of truth.** Each primitive's FSM lives in one file
//! alongside its tests. The `SemanticBus` aggregates output and
//! broadcasts to MQTT + Matter consumers. Adding a new primitive is
//! one file change — no new MQTT discovery schema, no new Matter
//! cluster.
//! - **Explainability.** Every state change carries a `reason`
//! payload so HA users can debug *why* a primitive fired.
//! - **Hysteresis everywhere.** Each primitive has explicit enter /
//! exit thresholds + minimum dwell time so a single noisy frame
//! never toggles state. Refractory periods prevent alert spam.
//! - **Warmup suppression.** No primitive fires during the first 60 s
//! after start (per §3.12.4 — sensors are still settling).
//!
//! ## Primitives (v1)
//!
//! | Primitive | Module | Output |
//! |-------------------------|-----------------------|------------------|
//! | someone_sleeping | [`sleeping`] | binary_sensor |
//! | possible_distress | [`distress`] | binary_sensor + event |
//! | room_active | [`room_active`] | binary_sensor |
//! | elderly_inactivity_… | [`elderly_anomaly`] | binary_sensor + event |
//! | meeting_in_progress | [`meeting`] | binary_sensor |
//! | bathroom_occupied | [`bathroom`] | binary_sensor |
//! | fall_risk_elevated | [`fall_risk`] | sensor (0-100) |
//! | bed_exit | [`bed_exit`] | event |
//! | no_movement | [`no_movement`] | binary_sensor |
//! | multi_room_transition | [`multi_room`] | event |
//!
//! Each module exports a struct implementing [`Primitive`] and a `new`
//! constructor that takes a [`PrimitiveConfig`].
// Primitives landing in P4.5a (this iteration):
mod bathroom;
mod bus;
mod common;
mod no_movement;
mod room_active;
mod sleeping;
// Primitives landing in P4.5b (next iteration): bed_exit, distress,
// elderly_anomaly, fall_risk, meeting, multi_room.
pub use bus::{SemanticBus, SemanticEvent, SemanticKind};
pub use common::{PrimitiveConfig, PrimitiveState, RawSnapshot, Reason};

View File

@ -0,0 +1,135 @@
//! No-movement (safety check) primitive (§3.12.1 row 9).
//!
//! Enter `no_movement = ON` when `presence == true` AND motion < 0.01
//! for ≥`no_movement_dwell` (default 30 min).
//!
//! Exit on first frame with motion ≥ 0.01.
use std::time::Duration;
use super::common::{PrimitiveConfig, PrimitiveState, RawSnapshot, Reason};
#[derive(Debug, Default, Clone)]
pub struct NoMovement {
pub active: bool,
still_since: Option<Duration>,
}
impl NoMovement {
pub fn new() -> Self {
Self::default()
}
pub fn tick(&mut self, snap: &RawSnapshot, cfg: &PrimitiveConfig) -> PrimitiveState {
if snap.since_start < cfg.warmup {
return PrimitiveState::Idle;
}
let still = snap.presence && snap.motion < 0.01;
if !still {
self.still_since = None;
if self.active {
self.active = false;
return PrimitiveState::Boolean {
active: false,
changed: true,
reason: Reason::new(&["motion>=1%"]),
};
}
return PrimitiveState::Idle;
}
let start = *self.still_since.get_or_insert(snap.since_start);
let dwell = snap.since_start.saturating_sub(start);
if !self.active && dwell >= cfg.no_movement_dwell {
self.active = true;
return PrimitiveState::Boolean {
active: true,
changed: true,
reason: Reason::new(&[
"presence=true",
"motion<1%",
"dwell>=30min",
]),
};
}
PrimitiveState::Idle
}
}
#[cfg(test)]
mod tests {
use super::*;
fn cfg() -> PrimitiveConfig {
PrimitiveConfig::default()
}
fn still_snap(t_secs: u64) -> RawSnapshot {
RawSnapshot {
since_start: Duration::from_secs(t_secs),
presence: true,
motion: 0.005,
..Default::default()
}
}
#[test]
fn fires_after_full_dwell() {
let mut p = NoMovement::new();
// Establish start.
let _ = p.tick(&still_snap(60 + 10), &cfg());
// 30 min later — fire.
let state = p.tick(&still_snap(60 + 10 + 30 * 60), &cfg());
match state {
PrimitiveState::Boolean { active, changed, .. } => {
assert!(active && changed);
}
other => panic!("expected on/change, got {:?}", other),
}
}
#[test]
fn does_not_fire_with_motion() {
let mut p = NoMovement::new();
let mut s = still_snap(60 + 10);
s.motion = 0.02;
for t in 0..(30 * 60 + 5) {
let mut s2 = s.clone();
s2.since_start = Duration::from_secs(60 + 10 + t as u64);
assert!(matches!(p.tick(&s2, &cfg()), PrimitiveState::Idle));
}
assert!(!p.active);
}
#[test]
fn brief_motion_resets_timer() {
let mut p = NoMovement::new();
let _ = p.tick(&still_snap(60 + 10), &cfg());
// 25 min in — almost there.
let _ = p.tick(&still_snap(60 + 10 + 25 * 60), &cfg());
// Motion blip resets.
let mut blip = still_snap(60 + 10 + 25 * 60 + 1);
blip.motion = 0.05;
let _ = p.tick(&blip, &cfg());
// 5 min more — should NOT fire because timer reset.
let state = p.tick(&still_snap(60 + 10 + 30 * 60 + 2), &cfg());
assert!(matches!(state, PrimitiveState::Idle));
assert!(!p.active);
}
#[test]
fn exits_on_motion_after_active() {
let mut p = NoMovement::new();
let _ = p.tick(&still_snap(60 + 10), &cfg());
let _ = p.tick(&still_snap(60 + 10 + 30 * 60), &cfg());
assert!(p.active);
let mut s = still_snap(60 + 10 + 30 * 60 + 1);
s.motion = 0.10;
let state = p.tick(&s, &cfg());
match state {
PrimitiveState::Boolean { active, changed, .. } => {
assert!(!active && changed);
}
other => panic!("expected off/change, got {:?}", other),
}
}
}

View File

@ -0,0 +1,145 @@
//! Room-active primitive (§3.12.1 row 3).
//!
//! Enter `room_active = ON` when presence is true and motion has been
//! above `room_active_motion_threshold` (default 10 %) at any point in
//! a rolling `room_active_window` (default 30 s).
//!
//! Exit when no motion above threshold for `room_active_exit_idle`
//! (default 10 min) OR presence drops false.
use std::time::Duration;
use super::common::{PrimitiveConfig, PrimitiveState, RawSnapshot, Reason};
#[derive(Debug, Default, Clone)]
pub struct RoomActive {
pub active: bool,
last_motion: Option<Duration>,
}
impl RoomActive {
pub fn new() -> Self {
Self::default()
}
pub fn tick(&mut self, snap: &RawSnapshot, cfg: &PrimitiveConfig) -> PrimitiveState {
if snap.since_start < cfg.warmup {
return PrimitiveState::Idle;
}
let above_thresh = snap.motion >= cfg.room_active_motion_threshold;
if above_thresh && snap.presence {
self.last_motion = Some(snap.since_start);
}
let recent_motion = matches!(
self.last_motion,
Some(t) if snap.since_start.saturating_sub(t) < cfg.room_active_window
);
if !self.active && recent_motion && snap.presence {
self.active = true;
return PrimitiveState::Boolean {
active: true,
changed: true,
reason: Reason::new(&["motion>10%", "presence=true", "window<30s"]),
};
}
if self.active {
let idle_long = matches!(
self.last_motion,
Some(t) if snap.since_start.saturating_sub(t) >= cfg.room_active_exit_idle
) || self.last_motion.is_none();
if !snap.presence || idle_long {
self.active = false;
let mut tags = Vec::new();
if !snap.presence { tags.push("presence=false"); }
if idle_long { tags.push("idle>=10min"); }
return PrimitiveState::Boolean {
active: false,
changed: true,
reason: Reason::new(&tags),
};
}
}
PrimitiveState::Idle
}
}
#[cfg(test)]
mod tests {
use super::*;
fn cfg() -> PrimitiveConfig {
PrimitiveConfig::default()
}
fn snap(t_secs: u64, motion: f64, presence: bool) -> RawSnapshot {
RawSnapshot {
since_start: Duration::from_secs(t_secs),
presence,
motion,
..Default::default()
}
}
#[test]
fn does_not_fire_during_warmup() {
let mut p = RoomActive::new();
let s = snap(30, 0.5, true);
assert!(matches!(p.tick(&s, &cfg()), PrimitiveState::Idle));
}
#[test]
fn fires_on_high_motion_with_presence() {
let mut p = RoomActive::new();
let s = snap(120, 0.4, true);
let state = p.tick(&s, &cfg());
match state {
PrimitiveState::Boolean { active, changed, .. } => {
assert!(active);
assert!(changed);
}
other => panic!("expected on/change, got {:?}", other),
}
}
#[test]
fn does_not_fire_without_presence() {
let mut p = RoomActive::new();
let state = p.tick(&snap(120, 0.4, false), &cfg());
assert!(matches!(state, PrimitiveState::Idle));
}
#[test]
fn does_not_fire_below_threshold() {
let mut p = RoomActive::new();
let state = p.tick(&snap(120, 0.05, true), &cfg());
assert!(matches!(state, PrimitiveState::Idle));
}
#[test]
fn exits_on_presence_drop() {
let mut p = RoomActive::new();
let _ = p.tick(&snap(120, 0.4, true), &cfg());
let state = p.tick(&snap(125, 0.4, false), &cfg());
match state {
PrimitiveState::Boolean { active, changed, .. } => {
assert!(!active);
assert!(changed);
}
other => panic!("expected off/change, got {:?}", other),
}
}
#[test]
fn exits_on_extended_idle() {
let mut p = RoomActive::new();
let _ = p.tick(&snap(120, 0.4, true), &cfg());
// Idle below threshold for >10 min.
let state = p.tick(&snap(120 + 600, 0.02, true), &cfg());
match state {
PrimitiveState::Boolean { active, .. } => assert!(!active),
other => panic!("expected off, got {:?}", other),
}
}
}

View File

@ -0,0 +1,227 @@
//! Someone-sleeping primitive (§3.12.1 row 1).
//!
//! **Definition (v1):**
//!
//! Enter `someone_sleeping = ON` when ALL of the following hold for
//! `sleep_dwell` (default 300 s):
//! - `presence == true`
//! - `motion < 0.05` (rolling)
//! - `breathing_rate_bpm ∈ [8.0, 20.0]` (rolling, conf ≥ 0.5)
//!
//! Exit when `motion > 0.15` for ≥30 s OR presence drops false.
//!
//! Heart-rate variability check is deferred to v2 because the broadcast
//! channel doesn't yet emit HRV; v1 fires on motion + BR + presence
//! which is the minimum that detects sleep cleanly in the ADR-079
//! paired-capture validation set.
use std::time::Duration;
use super::common::{PrimitiveConfig, PrimitiveState, RawSnapshot, Reason};
#[derive(Debug, Default, Clone)]
pub struct SomeoneSleeping {
pub active: bool,
enter_since: Option<Duration>,
exit_since: Option<Duration>,
}
impl SomeoneSleeping {
pub fn new() -> Self {
Self::default()
}
/// Process one snapshot, return state change (if any).
pub fn tick(&mut self, snap: &RawSnapshot, cfg: &PrimitiveConfig) -> PrimitiveState {
if snap.since_start < cfg.warmup {
return PrimitiveState::Idle;
}
let br_ok = matches!(snap.breathing_rate_bpm, Some(bpm) if (8.0..=20.0).contains(&bpm))
&& snap.vital_confidence >= 0.5;
let motion_low = snap.motion < 0.05;
let presence_ok = snap.presence;
if !self.active {
if presence_ok && motion_low && br_ok {
let start = *self.enter_since.get_or_insert(snap.since_start);
if snap.since_start.saturating_sub(start) >= cfg.sleep_dwell {
self.active = true;
self.exit_since = None;
return PrimitiveState::Boolean {
active: true,
changed: true,
reason: Reason::new(&[
"presence=true",
"motion<5%",
"br=8-20bpm",
"dwell>=5min",
]),
};
}
} else {
self.enter_since = None;
}
PrimitiveState::Idle
} else {
// Active — check exit conditions.
let exiting = !presence_ok || snap.motion > 0.15;
if exiting {
let start = *self.exit_since.get_or_insert(snap.since_start);
// Presence-drop is immediate; motion-spike requires 30s dwell.
if !presence_ok || snap.since_start.saturating_sub(start) >= Duration::from_secs(30) {
self.active = false;
self.enter_since = None;
self.exit_since = None;
let mut tags = Vec::new();
if !presence_ok { tags.push("presence=false"); }
if snap.motion > 0.15 { tags.push("motion>15%"); }
return PrimitiveState::Boolean {
active: false,
changed: true,
reason: Reason::new(&tags),
};
}
} else {
self.exit_since = None;
}
PrimitiveState::Idle
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn cfg() -> PrimitiveConfig {
PrimitiveConfig::default()
}
fn sleeping_snap(t_secs: u64) -> RawSnapshot {
RawSnapshot {
since_start: Duration::from_secs(t_secs),
presence: true,
motion: 0.02,
breathing_rate_bpm: Some(13.0),
vital_confidence: 0.85,
..Default::default()
}
}
#[test]
fn does_not_fire_during_warmup() {
let mut p = SomeoneSleeping::new();
let s = sleeping_snap(30);
assert!(matches!(p.tick(&s, &cfg()), PrimitiveState::Idle));
assert!(!p.active);
}
#[test]
fn fires_after_dwell_post_warmup() {
let mut p = SomeoneSleeping::new();
// Tick after warmup but before dwell — idle.
assert!(matches!(p.tick(&sleeping_snap(60 + 100), &cfg()), PrimitiveState::Idle));
// Tick after warmup + dwell — should activate (start was at t=160).
let state = p.tick(&sleeping_snap(60 + 100 + 300), &cfg());
match state {
PrimitiveState::Boolean { active, changed, .. } => {
assert!(active);
assert!(changed);
}
other => panic!("expected boolean on/change, got {:?}", other),
}
assert!(p.active);
}
#[test]
fn does_not_fire_when_motion_high() {
let mut p = SomeoneSleeping::new();
let mut s = sleeping_snap(60 + 100);
s.motion = 0.30;
for t in 0..600u64 {
let mut s2 = s.clone();
s2.since_start = Duration::from_secs(60 + 100 + t);
assert!(matches!(p.tick(&s2, &cfg()), PrimitiveState::Idle));
}
assert!(!p.active);
}
#[test]
fn does_not_fire_when_br_out_of_range() {
let mut p = SomeoneSleeping::new();
let mut s = sleeping_snap(60 + 100);
s.breathing_rate_bpm = Some(30.0); // too fast
let s2 = {
let mut x = s.clone();
x.since_start = Duration::from_secs(60 + 100 + 600);
x
};
let _ = p.tick(&s, &cfg());
assert!(matches!(p.tick(&s2, &cfg()), PrimitiveState::Idle));
assert!(!p.active);
}
#[test]
fn exits_on_presence_false_immediately() {
let mut p = SomeoneSleeping::new();
let _ = p.tick(&sleeping_snap(60 + 100), &cfg());
let _ = p.tick(&sleeping_snap(60 + 100 + 300), &cfg());
assert!(p.active);
// Presence drops.
let mut s = sleeping_snap(60 + 100 + 301);
s.presence = false;
let state = p.tick(&s, &cfg());
match state {
PrimitiveState::Boolean { active, changed, .. } => {
assert!(!active);
assert!(changed);
}
other => panic!("expected boolean off/change, got {:?}", other),
}
assert!(!p.active);
}
#[test]
fn exits_on_sustained_motion_only_after_30s() {
let mut p = SomeoneSleeping::new();
let _ = p.tick(&sleeping_snap(60 + 100), &cfg());
let _ = p.tick(&sleeping_snap(60 + 100 + 300), &cfg());
assert!(p.active);
// Motion spikes for 10 s — too short to exit.
let mut s = sleeping_snap(60 + 100 + 310);
s.motion = 0.20;
let state = p.tick(&s, &cfg());
assert!(matches!(state, PrimitiveState::Idle));
assert!(p.active);
// Motion sustained 30 s → exit.
let mut s2 = sleeping_snap(60 + 100 + 340);
s2.motion = 0.20;
let state2 = p.tick(&s2, &cfg());
match state2 {
PrimitiveState::Boolean { active, changed, .. } => {
assert!(!active);
assert!(changed);
}
other => panic!("expected boolean off/change, got {:?}", other),
}
assert!(!p.active);
}
#[test]
fn brief_motion_blip_does_not_exit() {
let mut p = SomeoneSleeping::new();
let _ = p.tick(&sleeping_snap(60 + 100), &cfg());
let _ = p.tick(&sleeping_snap(60 + 100 + 300), &cfg());
assert!(p.active);
// Motion spikes briefly then returns to low.
let mut s_spike = sleeping_snap(60 + 100 + 305);
s_spike.motion = 0.20;
let _ = p.tick(&s_spike, &cfg());
// Back to low motion within 30s.
let s_calm = sleeping_snap(60 + 100 + 315);
let state = p.tick(&s_calm, &cfg());
assert!(matches!(state, PrimitiveState::Idle));
// Still active because exit dwell was reset by calm sample.
assert!(p.active);
}
}