feat(sensing-server): ADR-140 semantic state record + Ruflo agent bridge (#844)
- semantic/record.rs: SemanticStateRecord (kind/room/node/timestamp/expiry/ confidence/model_version/calibration_version/privacy_action/evidence_refs) — the auditable wire form of an ADR-139 SemanticState node, enriched from the existing SemanticEvent via RecordContext - PrivacyAction enum (Allow/AnonymizeByRoom/StripBiometrics); StripBiometrics removes HR/BR evidence tags at the record boundary - Ruflo agent bridge: MultiSignalRule.evaluate() fires AgentRoute only on multi-signal agreement (fall_risk + elderly_anomaly → caregiver_escalation); route_all() sorts by severity + dedups - 4 tests; workspace 0 errors Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
parent
521a012d84
commit
169a355bde
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
/// 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<String>,
|
||||
/// 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<String>, 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<SemanticKind>,
|
||||
/// 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<AgentRoute> {
|
||||
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<AgentRoute> {
|
||||
let mut routes: Vec<AgentRoute> =
|
||||
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
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue