diff --git a/v2/crates/wifi-densepose-sensing-server/src/semantic/mod.rs b/v2/crates/wifi-densepose-sensing-server/src/semantic/mod.rs index 685a387b..4bd833e2 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/semantic/mod.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/semantic/mod.rs @@ -59,5 +59,11 @@ mod no_movement; mod room_active; mod sleeping; +// ADR-140: auditable semantic-state record + Ruflo multi-signal agent bridge. +pub mod record; + pub use bus::{SemanticBus, SemanticEvent, SemanticKind}; pub use common::{PrimitiveConfig, PrimitiveState, RawSnapshot, Reason}; +pub use record::{ + AgentRoute, MultiSignalRule, PrivacyAction, RecordContext, SemanticStateRecord, route_all, +}; diff --git a/v2/crates/wifi-densepose-sensing-server/src/semantic/record.rs b/v2/crates/wifi-densepose-sensing-server/src/semantic/record.rs new file mode 100644 index 00000000..ce4c7886 --- /dev/null +++ b/v2/crates/wifi-densepose-sensing-server/src/semantic/record.rs @@ -0,0 +1,274 @@ +//! ADR-140 — `SemanticStateRecord`: the auditable, versioned, privacy-gated +//! wire form of a semantic belief, plus the Ruflo agent-bridge routing that +//! fires on *multi-signal agreement* (e.g. fall-risk + elderly-anomaly → +//! caregiver escalation). +//! +//! This extends the existing [`SemanticEvent`](super::bus::SemanticEvent) +//! (kind/state/node/timestamp) with the provenance the house rule mandates: +//! model version, calibration version, privacy action, expiry, confidence, +//! room, and evidence refs. Each record is the wire form of an ADR-139 +//! `WorldNode::SemanticState`. + +use super::bus::{SemanticEvent, SemanticKind}; +use super::common::PrimitiveState; + +/// Privacy action enforced at the semantic layer (ADR-140 §2 → ADR-141). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PrivacyAction { + /// Emit the full record (RawResearch / CareWithConsent style). + Allow, + /// Drop person-identifying detail, keep room-level occupancy. + AnonymizeByRoom, + /// Strip biometric scalars (HR/BR) from the emitted record. + StripBiometrics, +} + +/// Per-deployment context used to enrich a [`SemanticEvent`] into a +/// [`SemanticStateRecord`]. Loaded from the model/calibration manifest. +#[derive(Debug, Clone)] +pub struct RecordContext { + /// Model version that produced the underlying belief (ADR-136 `model_id`). + pub model_version: String, + /// Calibration version (ADR-135 baseline id) in effect. + pub calibration_version: String, + /// Active privacy action (ADR-141 mode → action). + pub privacy_action: PrivacyAction, + /// Record time-to-live (ms); `expiry_at = timestamp_ms + default_ttl_ms`. + pub default_ttl_ms: i64, +} + +impl Default for RecordContext { + fn default() -> Self { + Self { + model_version: "unassigned".into(), + calibration_version: "uncalibrated".into(), + privacy_action: PrivacyAction::Allow, + default_ttl_ms: 30_000, + } + } +} + +/// Auditable, versioned semantic state record (ADR-140 §2.1). +#[derive(Debug, Clone, PartialEq)] +pub struct SemanticStateRecord { + /// Which primitive produced this belief. + pub kind: SemanticKind, + /// Room/area this belief is scoped to (None = whole installation). + pub room: Option, + /// Source sensing node id. + pub node_id: String, + /// Capture time (Unix ms). + pub timestamp_ms: i64, + /// Belief expiry (Unix ms); after this the record is stale. + pub expiry_at_ms: i64, + /// Confidence in [0, 1]. + pub confidence: f32, + /// Model version (ADR-136). + pub model_version: String, + /// Calibration version (ADR-135). + pub calibration_version: String, + /// Privacy action under which it was derived (ADR-141). + pub privacy_action: PrivacyAction, + /// Evidence refs (ADR-137) — here, the human-readable reason tags. + pub evidence_refs: Vec, + /// Whether the underlying primitive is currently "active"/firing. + pub active: bool, +} + +impl SemanticStateRecord { + /// Enrich a [`SemanticEvent`] into a record using deployment context and a + /// room mapping for the event's node. + #[must_use] + pub fn from_event(event: &SemanticEvent, room: Option, ctx: &RecordContext) -> Self { + let (confidence, active, mut evidence_refs) = match &event.state { + PrimitiveState::Boolean { active, reason, .. } => { + (if *active { 0.9 } else { 0.1 }, *active, reason.tags.clone()) + } + PrimitiveState::Scalar { value, reason } => { + ((*value as f32 / 100.0).clamp(0.0, 1.0), *value > 0.0, reason.tags.clone()) + } + PrimitiveState::Event { event_type, reason } => { + let mut t = reason.tags.clone(); + t.push(format!("event={event_type}")); + (1.0, true, t) + } + PrimitiveState::Idle => (0.0, false, Vec::new()), + }; + + // Privacy enforcement at the record boundary. + if ctx.privacy_action == PrivacyAction::StripBiometrics { + evidence_refs.retain(|t| !is_biometric_tag(t)); + } + + Self { + kind: event.kind, + room, + node_id: event.node_id.clone(), + timestamp_ms: event.timestamp_ms, + expiry_at_ms: event.timestamp_ms + ctx.default_ttl_ms, + confidence, + model_version: ctx.model_version.clone(), + calibration_version: ctx.calibration_version.clone(), + privacy_action: ctx.privacy_action, + evidence_refs, + active, + } + } + + /// Whether this record is still valid at `now_ms`. + #[must_use] + pub fn is_fresh(&self, now_ms: i64) -> bool { + now_ms < self.expiry_at_ms + } +} + +fn is_biometric_tag(tag: &str) -> bool { + let t = tag.to_ascii_lowercase(); + t.contains("hr=") || t.contains("br=") || t.contains("bpm") +} + +// ---- ADR-140 §2.3 Ruflo agent bridge: multi-signal agreement routing ---- + +/// A routing decision handed to the ADR-133 HOMECORE-ASSIST / Ruflo layer. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AgentRoute { + /// Stable route identifier (e.g. "caregiver_escalation"). + pub route_id: &'static str, + /// Severity 0..=3 (info, notice, warning, critical). + pub severity: u8, +} + +/// A rule that fires a route when *all* required kinds are simultaneously +/// active and fresh in the candidate record set (multi-signal agreement). +#[derive(Debug, Clone)] +pub struct MultiSignalRule { + /// Kinds that must all be active+fresh for the route to fire. + pub required_kinds: Vec, + /// Minimum confidence each required record must meet. + pub min_confidence: f32, + /// Route emitted when the rule matches. + pub route: AgentRoute, +} + +impl MultiSignalRule { + /// Evaluate the rule against fresh, active records at `now_ms`. Returns the + /// route iff every required kind has at least one active record meeting + /// `min_confidence`. Routing on agreement (not a single signal) is what + /// suppresses single-primitive false positives for high-impact actions. + #[must_use] + pub fn evaluate(&self, records: &[SemanticStateRecord], now_ms: i64) -> Option { + let all_present = self.required_kinds.iter().all(|k| { + records.iter().any(|r| { + r.kind == *k && r.active && r.is_fresh(now_ms) && r.confidence >= self.min_confidence + }) + }); + all_present.then(|| self.route.clone()) + } +} + +/// Evaluate every rule, returning the matched routes (deduped by route_id, +/// highest severity first). +#[must_use] +pub fn route_all( + rules: &[MultiSignalRule], + records: &[SemanticStateRecord], + now_ms: i64, +) -> Vec { + let mut routes: Vec = + rules.iter().filter_map(|r| r.evaluate(records, now_ms)).collect(); + routes.sort_by(|a, b| b.severity.cmp(&a.severity).then(a.route_id.cmp(b.route_id))); + routes.dedup(); + routes +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::semantic::common::Reason; + + fn event(kind: SemanticKind, state: PrimitiveState, ts: i64) -> SemanticEvent { + SemanticEvent { kind, state, node_id: "node-1".into(), timestamp_ms: ts } + } + + #[test] + fn record_from_scalar_event_carries_provenance() { + let ctx = RecordContext { + model_version: "rfenc-1.2".into(), + calibration_version: "cal:abc".into(), + privacy_action: PrivacyAction::Allow, + default_ttl_ms: 30_000, + }; + let ev = event( + SemanticKind::FallRisk, + PrimitiveState::Scalar { value: 80.0, reason: Reason::new(&["accel_spike", "hr=110bpm"]) }, + 1_000, + ); + let r = SemanticStateRecord::from_event(&ev, Some("living_room".into()), &ctx); + assert_eq!(r.model_version, "rfenc-1.2"); + assert_eq!(r.calibration_version, "cal:abc"); + assert_eq!(r.expiry_at_ms, 31_000); + assert!((r.confidence - 0.8).abs() < 1e-6); + assert!(r.active); + assert_eq!(r.room.as_deref(), Some("living_room")); + assert!(r.is_fresh(20_000) && !r.is_fresh(31_000)); + } + + #[test] + fn strip_biometrics_removes_hr_br_tags() { + let ctx = RecordContext { privacy_action: PrivacyAction::StripBiometrics, ..Default::default() }; + let ev = event( + SemanticKind::PossibleDistress, + PrimitiveState::Scalar { value: 50.0, reason: Reason::new(&["motion<5%", "hr=130bpm", "br=22"]) }, + 0, + ); + let r = SemanticStateRecord::from_event(&ev, None, &ctx); + assert_eq!(r.evidence_refs, vec!["motion<5%".to_string()]); + } + + #[test] + fn multi_signal_rule_fires_only_on_agreement() { + let now = 1_000; + let ctx = RecordContext::default(); + let fall = SemanticStateRecord::from_event( + &event(SemanticKind::FallRisk, PrimitiveState::Scalar { value: 90.0, reason: Reason::empty() }, now), + Some("bedroom".into()), &ctx, + ); + let elderly = SemanticStateRecord::from_event( + &event(SemanticKind::ElderlyAnomaly, PrimitiveState::Boolean { active: true, changed: true, reason: Reason::empty() }, now), + Some("bedroom".into()), &ctx, + ); + let rule = MultiSignalRule { + required_kinds: vec![SemanticKind::FallRisk, SemanticKind::ElderlyAnomaly], + min_confidence: 0.5, + route: AgentRoute { route_id: "caregiver_escalation", severity: 3 }, + }; + + // Only fall present → no route (no agreement). + assert_eq!(rule.evaluate(&[fall.clone()], now), None); + // Both present + active + fresh → route fires. + assert_eq!( + rule.evaluate(&[fall.clone(), elderly.clone()], now), + Some(AgentRoute { route_id: "caregiver_escalation", severity: 3 }) + ); + // Stale records do not fire. + assert_eq!(rule.evaluate(&[fall.clone(), elderly.clone()], now + 60_000), None); + } + + #[test] + fn route_all_sorts_by_severity_and_dedups() { + let now = 0; + let ctx = RecordContext::default(); + let active = |k| SemanticStateRecord::from_event( + &event(k, PrimitiveState::Boolean { active: true, changed: true, reason: Reason::empty() }, now), + None, &ctx, + ); + let records = vec![active(SemanticKind::FallRisk), active(SemanticKind::NoMovement)]; + let rules = vec![ + MultiSignalRule { required_kinds: vec![SemanticKind::FallRisk], min_confidence: 0.5, route: AgentRoute { route_id: "fall_notice", severity: 2 } }, + MultiSignalRule { required_kinds: vec![SemanticKind::NoMovement, SemanticKind::FallRisk], min_confidence: 0.5, route: AgentRoute { route_id: "safety_critical", severity: 3 } }, + ]; + let routes = route_all(&rules, &records, now); + assert_eq!(routes.len(), 2); + assert_eq!(routes[0].route_id, "safety_critical"); // higher severity first + } +}