//! # RuView Streaming Engine — integration layer //! //! This crate is the **composition root** that wires the ADR-135..146 building //! blocks into one end-to-end *trust-traceable* pipeline cycle. Each block was //! built and unit-tested independently; this crate proves they compose and that //! the **trust throughline** holds end-to-end: //! //! > *Why believe the system when it says a person is present?* — every //! > [`TrustedOutput`] names its **signal evidence** (ADR-137 `EvidenceRef`), //! > its **model version** (ADR-136), its **calibration version** (ADR-135 //! > baseline id, ADR-136 `calibration_id`), and the **privacy decision** //! > (ADR-141 mode → class) it was emitted under — and is anchored as a //! > provenance-bearing node in the ADR-139 WorldGraph. //! //! One [`StreamingEngine::process_cycle`] performs, in order: //! 1. **Fuse + score** the node frames (ADR-137 `fuse_scored`) → `QualityScore` //! with per-node weights, evidence, and tolerated contradiction flags. //! 2. **Stamp calibration provenance** (ADR-135/136): the `CalibrationId` the //! calibration stage applied is recorded on the `QualityScore`. //! 3. **Privacy control plane** (ADR-141): if the fusion recorded a tolerated //! contradiction, the active privacy class is **demoted one step** before //! emission (monotonic — information only ever removed). //! 4. **Semantic state** (ADR-139/140): a `SemanticState` node is appended to //! the WorldGraph with mandatory provenance and a `DerivedFrom` edge to the //! room it was observed in. //! //! What is intentionally *not* here: the live 20 Hz I/O loop (sensing-server), //! UWB hardware (ADR-144), and model training (ADR-146). This is the //! composition + validation layer those will plug into. #![forbid(unsafe_code)] use std::collections::BTreeMap; use wifi_densepose_bfld::{PrivacyAction, PrivacyClass, PrivacyMode, PrivacyModeRegistry}; use wifi_densepose_geo::types::GeoRegistration; use wifi_densepose_ruvector::viewpoint::coherence::ClockQualityScore; use wifi_densepose_signal::ruvsense::fusion_quality::CalibrationId; use wifi_densepose_signal::ruvsense::multistatic::{MultistaticConfig, MultistaticFuser}; use wifi_densepose_signal::ruvsense::{ ArrayCoordinator, ArrayCoordinatorConfig, ArrayNodeInput, ChangePoint, DirectionalEvidence, EvolutionTracker, MultiBandCsiFrame, QualityScore, ReflectorObservation, RfSlam, }; use wifi_densepose_worldgraph::{ AnchorKind, EnuPoint, PrivacyRollup, SemanticProvenance, WorldEdge, WorldGraph, WorldGraphError, WorldId, WorldNode, ZoneBoundsEnu, }; pub mod mesh_guard; pub use mesh_guard::{MeshGuard, MeshPartitionReport}; /// Errors from an engine cycle. #[derive(Debug)] pub enum EngineError { /// Multistatic fusion failed (no frames, timestamp spread, dimension mismatch). Fusion(wifi_densepose_signal::ruvsense::multistatic::MultistaticError), } impl core::fmt::Display for EngineError { fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { match self { EngineError::Fusion(e) => write!(f, "fusion error: {e}"), } } } impl std::error::Error for EngineError {} impl From for EngineError { fn from(e: wifi_densepose_signal::ruvsense::multistatic::MultistaticError) -> Self { EngineError::Fusion(e) } } /// Geometry of a sensing node, needed to run the ADR-138 array coordinator. #[derive(Debug, Clone, Copy)] struct NodeGeom { x: f32, y: f32, azimuth: f32, } /// The auditable result of one engine cycle — the trust chain made concrete. #[derive(Debug, Clone)] pub struct TrustedOutput { /// The `SemanticState` node id created in the WorldGraph. pub semantic_id: WorldId, /// The fusion quality record (evidence + contradictions + calibration). pub quality: QualityScore, /// The privacy class the output was emitted under (after any demotion). pub effective_class: PrivacyClass, /// Whether a tolerated contradiction forced a privacy demotion this cycle. pub demoted: bool, /// The mandatory provenance attached to the semantic node. pub provenance: SemanticProvenance, /// ADR-138 directional evidence, when node geometry is registered for every /// contributing node (else `None`). pub directional: Option, /// ADR-142 cross-link change-point detected this cycle, if any (and the /// `Event` node it was recorded as in the WorldGraph). pub change_point: Option<(ChangePoint, WorldId)>, /// BLAKE3 witness over the trust decision (provenance ‖ class ‖ calibration) /// — a deterministic, signed-belief fingerprint (ADR-137 §2.7 / ADR-028). pub witness: [u8; 32], /// Whether the drift→recalibration advisor recommends re-running the /// ADR-135 baseline / refitting the per-room adapter (ADR-150 §3.4): /// sustained low coherence or an ADR-142 change-point this cycle. pub recalibration_recommended: bool, /// Dynamic min-cut partition report over the live mesh coupling graph /// (None for meshes of fewer than two nodes). `at_risk` counts as a /// structural event for the recalibration advisor and names the nodes /// (`weak_side`) closest to splitting off — failure/jamming triage. pub mesh: Option, } /// Composition root for the RuView streaming engine. pub struct StreamingEngine { fuser: MultistaticFuser, coherence_accept: f32, privacy: PrivacyModeRegistry, world: WorldGraph, model_version: u16, cycle: u64, // ADR-138: array coordinator + per-node geometry (by frame node_id). array: ArrayCoordinator, node_geom: BTreeMap, // ADR-142: per-link evolution tracker (sized lazily to the node count). evolution: Option, // ADR-143: persistent reflector discovery (v2 mode). slam: RfSlam, // ADR-139 live loop: stable track_id -> PersonTrack WorldId. person_tracks: BTreeMap, // WorldGraph belief retention: max live SemanticState nodes. The live loop // appends one belief per cycle (1.7M/day at 20 Hz); durable history is the // recorder's job, so old beliefs are evicted deterministically past this cap. semantic_retention: usize, // Per-room calibration adapter (ADR-150 §3.4: ~11 KB LoRA on a frozen // base). Identity is part of the trust chain: when set, the adapter id is // appended to the provenance model_version, so swapping adapters changes // the witness. None = shared base model. adapter: Option, // Drift→recalibration advisor (ADR-135 trigger for ADR-150 §3.4 refit). recal: RecalibrationAdvisor, // Dynamic min-cut mesh partition guard (incremental, change-gated). mesh: MeshGuard, } /// Identity of an active per-room calibration adapter (ADR-150 §3.4). The id /// must be content-derived (e.g. a hash prefix of the adapter file) so the /// provenance/witness chain pins the exact weights that shaped inference. #[derive(Debug, Clone, PartialEq, Eq)] pub struct AdapterInfo { /// Content-derived adapter identity (e.g. first 16 hex of its SHA-256). pub adapter_id: String, /// Number of in-room samples the adapter was fitted on (0 if unknown). pub trained_samples: u32, } /// Recommends re-running calibration / adapter refit when the live signal /// degrades persistently (ADR-135 drift → ADR-150 §3.4 few-shot recalibration). /// /// Two triggers, both cheap and deterministic: /// - `low_coherence_streak`: N consecutive cycles whose base coherence fell /// below the floor (sustained degradation, not a single bad frame); /// - any ADR-142 change-point this cycle (the environment itself changed). #[derive(Debug, Clone)] pub struct RecalibrationAdvisor { /// Coherence below this counts toward the streak. pub coherence_floor: f32, /// Consecutive low-coherence cycles required to recommend recalibration. pub streak_threshold: u32, streak: u32, } impl Default for RecalibrationAdvisor { fn default() -> Self { Self { coherence_floor: 0.5, streak_threshold: 60, // ~3 s at 20 Hz of sustained degradation streak: 0, } } } impl RecalibrationAdvisor { /// Feed one cycle's evidence; returns whether recalibration is recommended. fn observe(&mut self, base_coherence: f32, change_point: bool) -> bool { if base_coherence < self.coherence_floor { self.streak = self.streak.saturating_add(1); } else { self.streak = 0; } change_point || self.streak >= self.streak_threshold } /// Current consecutive low-coherence cycle count. #[must_use] pub fn streak(&self) -> u32 { self.streak } } impl StreamingEngine { /// Build an engine with a starting privacy mode and model version. The /// WorldGraph is registered to the installation origin. #[must_use] pub fn new(mode: PrivacyMode, model_version: u16, registration: GeoRegistration) -> Self { Self { fuser: MultistaticFuser::with_config(MultistaticConfig::default()), coherence_accept: 0.85, privacy: PrivacyModeRegistry::new(mode), world: WorldGraph::new(registration), model_version, cycle: 0, array: ArrayCoordinator::new(ArrayCoordinatorConfig::default()), node_geom: BTreeMap::new(), evolution: None, slam: RfSlam::with_discovery(0.5, 5, 0.6), person_tracks: BTreeMap::new(), semantic_retention: Self::DEFAULT_SEMANTIC_RETENTION, adapter: None, recal: RecalibrationAdvisor::default(), mesh: MeshGuard::default(), } } /// Activate a per-room calibration adapter (ADR-150 §3.4). From the next /// cycle on, the adapter id is part of provenance `model_version` — and /// therefore of the witness — so the exact weights shaping inference are /// pinned in the trust chain. Pass the result of hashing the adapter file. pub fn set_room_adapter(&mut self, info: AdapterInfo) { self.adapter = Some(info); } /// Deactivate the adapter (revert to the shared base model). pub fn clear_room_adapter(&mut self) { self.adapter = None; } /// The active adapter, if any. #[must_use] pub fn room_adapter(&self) -> Option<&AdapterInfo> { self.adapter.as_ref() } /// Tune the drift→recalibration advisor (floor + streak threshold). pub fn set_recalibration_advisor(&mut self, advisor: RecalibrationAdvisor) { self.recal = advisor; } /// Mutable access to the mesh partition guard (risk threshold, quantum, /// min-node count). Operators tune the partition-risk sensitivity here. pub fn mesh_guard_mut(&mut self) -> &mut MeshGuard { &mut self.mesh } /// Default cap on live `SemanticState` beliefs in the WorldGraph /// (~6 minutes of full-rate history at 20 Hz; older beliefs are evicted — /// durable history belongs to the recorder). pub const DEFAULT_SEMANTIC_RETENTION: usize = 7_200; /// Override the `SemanticState` retention cap (minimum 1). pub fn set_semantic_retention(&mut self, max_states: usize) { self.semantic_retention = max_states.max(1); } /// ADR-139 live loop: create or update a `PersonTrack` node by stable /// `track_id`, locate it in `room`, and wire an `Observes` edge from /// `sensor` (so the privacy rollup can suppress it under identity-strict /// modes). Returns the (stable) WorldGraph id. pub fn update_person_track( &mut self, track_id: u64, x: f32, y: f32, room: WorldId, sensor: WorldId, ) -> WorldId { let existing = self.person_tracks.get(&track_id).copied(); let node = WorldNode::PersonTrack { id: existing.unwrap_or(WorldId::UNASSIGNED), track_id, last_position: EnuPoint { east_m: f64::from(x), north_m: f64::from(y), up_m: 0.0 }, reid_embedding_ref: None, }; let id = self.world.upsert_node(node); if existing.is_none() { self.person_tracks.insert(track_id, id); let _ = self.world.add_edge(id, room, WorldEdge::LocatedIn { since_unix_ms: 0 }); let _ = self.world.add_edge( sensor, id, WorldEdge::Observes { quality: 1.0, last_seen_unix_ms: 0 }, ); } id } /// ADR-139 §2.4 / ADR-141: materialise `PrivacyLimitedBy` edges for the /// active privacy mode. Under an identity-suppressing mode, `person_track` /// observations are denied; the rollup names what was suppressed. pub fn apply_active_privacy_mode(&mut self) -> PrivacyRollup { let mode = self.privacy.active_mode(); let suppress_identity = self.privacy.is_action_enforced(PrivacyAction::SuppressIdentity); self.world.apply_privacy_mode( &format!("{mode:?}"), "SuppressIdentity", move |_sensor_kind, node_kind| !(suppress_identity && node_kind == "person_track"), ) } /// Persist the WorldGraph as deterministic JSON (the RVF payload). Contains /// only graph nodes/edges — **never** raw RF frames. /// /// # Errors /// [`WorldGraphError`] on serialisation failure. pub fn snapshot_json(&self) -> Result, WorldGraphError> { self.world.to_json() } /// Register a contributing node's geometry (ADR-138). When every frame's /// `node_id` in a cycle has a registered geometry, the cycle runs the array /// coordinator and folds its contradictions into the privacy decision. pub fn register_node_geometry(&mut self, node_id: u8, x: f32, y: f32, azimuth: f32) { self.node_geom.insert(node_id, NodeGeom { x, y, azimuth }); } /// Ingest CIR-derived reflector sightings (ADR-143) and persist any newly /// stable static anchors into the WorldGraph as `ObjectAnchor` nodes. /// Returns the WorldGraph ids written this call. pub fn ingest_reflectors(&mut self, observations: &[ReflectorObservation]) -> Vec { for obs in observations { self.slam.observe(obs); } let mut written = Vec::new(); for (pos, class) in self.slam.static_anchors(0.05, 1.0) { let kind = match class { wifi_densepose_signal::ruvsense::ReflectorClass::Wall => AnchorKind::Reflector, wifi_densepose_signal::ruvsense::ReflectorClass::Furniture => AnchorKind::Furniture, wifi_densepose_signal::ruvsense::ReflectorClass::Mobile => continue, }; let id = self.world.upsert_node(WorldNode::ObjectAnchor { id: WorldId::UNASSIGNED, position: EnuPoint { east_m: pos[0], north_m: pos[1], up_m: pos[2] }, anchor_kind: kind, confidence: 0.9, }); written.push(id); } written } /// Register a room and return its WorldGraph id (the observation scope). pub fn add_room(&mut self, area_id: &str, name: &str) -> WorldId { self.world.upsert_node(WorldNode::Room { id: WorldId::UNASSIGNED, area_id: Some(area_id.to_string()), name: name.to_string(), bounds_enu: ZoneBoundsEnu::Rectangle { min_e: 0.0, min_n: 0.0, max_e: 5.0, max_n: 4.0 }, floor: 0, }) } /// Register a sensor node and an `observes` edge to a room. pub fn add_sensor(&mut self, device_id: &str, room: WorldId) -> WorldId { let id = self.world.upsert_node(WorldNode::Sensor { id: WorldId::UNASSIGNED, device_id: device_id.to_string(), position: EnuPoint { east_m: 0.0, north_m: 0.0, up_m: 0.0 }, modality: wifi_densepose_worldgraph::SensorModality::WifiCsi, }); let _ = self.world.add_edge( id, room, WorldEdge::Observes { quality: 1.0, last_seen_unix_ms: 0 }, ); id } /// Switch the active privacy mode (records a hash-chained attestation). pub fn set_privacy_mode(&mut self, mode: PrivacyMode) { self.privacy.set_mode(mode); } /// Borrow the WorldGraph (for queries / persistence). #[must_use] pub fn world(&self) -> &WorldGraph { &self.world } /// Borrow the privacy registry (for attestation audit). #[must_use] pub fn privacy(&self) -> &PrivacyModeRegistry { &self.privacy } /// Cycles processed so far. #[must_use] pub fn cycle_count(&self) -> u64 { self.cycle } /// Run one full trust-traceable cycle (see crate docs for the steps). /// /// `calibration` is the [`CalibrationId`] the calibration stage applied to /// these frames (ADR-135 `BaselineCalibration::calibration_id()`); `room` is /// the observation scope (an existing WorldGraph Room id). /// /// # Errors /// [`EngineError::Fusion`] if multistatic fusion rejects the input. pub fn process_cycle( &mut self, node_frames: &[MultiBandCsiFrame], calibration: CalibrationId, room: WorldId, now_ms: i64, ) -> Result { // Uniform-calibration convenience: every node shares one epoch. let cals = vec![Some(calibration); node_frames.len()]; self.process_cycle_calibrated(node_frames, &cals, room, now_ms) } /// Like [`Self::process_cycle`] but with a **per-node** calibration epoch /// (ADR-137 §2.3). If the nodes' calibrations disagree, fusion raises a /// `CalibrationIdMismatch`, the score's `calibration_id` is `None`, and the /// privacy class is demoted — proving the calibration → trust → privacy path. /// /// # Errors /// [`EngineError::Fusion`] if multistatic fusion rejects the input. pub fn process_cycle_calibrated( &mut self, node_frames: &[MultiBandCsiFrame], calibrations: &[Option], room: WorldId, now_ms: i64, ) -> Result { // 1. Array coordination (ADR-138) — only when geometry is known for // every contributing node. Its contradictions feed the privacy gate. let directional = self.coordinate_array(node_frames); let array_contradiction = directional.as_ref().is_some_and(|d| !d.contradictions.is_empty()); // 2. Fuse + score with per-node calibration (ADR-137 §2.3). let (fused, quality) = self.fuser.fuse_scored_calibrated(node_frames, calibrations, self.coherence_accept)?; // 4. Evolution change-point (ADR-142) over per-node mean amplitude. let change_point = self.track_evolution(node_frames, now_ms, room); // 5. Mesh partition guard (ADR-032): dynamic min-cut over the coupling // graph. Coupling between nodes i and j is the product of their // fusion attention weights scaled by the node count, so a node the // fuser down-weights is exactly a node weakly coupled in the graph. // (Change-gated incremental updates: steady state touches 0 edges.) let node_ids: Vec = node_frames.iter().map(|f| f.node_id).collect(); let weights = &quality.per_node_weights; let n = weights.len() as f64; let mesh = self.mesh.update(&node_ids, |i, j| { let wi = weights.get(i).copied().unwrap_or(0.0) as f64; let wj = weights.get(j).copied().unwrap_or(0.0) as f64; wi * wj * n }); let mesh_at_risk = mesh.as_ref().is_some_and(|m| m.at_risk); // 6. Privacy control plane (ADR-141): demote on a fusion-level OR an // array-level contradiction OR a mesh close to partitioning. The // last is a security/reliability signal (ADR-032): a fragmenting // array makes the fused belief less trustworthy, so we emit at a // more restricted class. Monotonic — information is only ever // removed — and the demotion is part of the witness. let base_class = self.privacy.active_class(); let demoted = quality.forces_privacy_demotion() || array_contradiction || mesh_at_risk; let effective_class = if demoted { demote_one(base_class) } else { base_class }; // 7. Semantic state with mandatory provenance (ADR-139/140). The // calibration version comes from the *agreed* epoch (None on mismatch). // When a per-room adapter is active (ADR-150 §3.4) its content-derived // id is part of model_version — and therefore of the witness — so the // exact weights shaping inference are pinned in the trust chain. let calibration_version = match quality.calibration_id { Some(c) => format!("cal:{:016x}", c.0), None => "cal:none".to_string(), }; let model_version = match &self.adapter { Some(a) => format!("rfenc-v{}+adapter:{}", self.model_version, a.adapter_id), None => format!("rfenc-v{}", self.model_version), }; let provenance = SemanticProvenance { evidence: quality.evidence_refs.iter().map(|e| format!("{e:?}")).collect(), model_version, calibration_version, privacy_decision: format!("{:?}/{:?}", self.privacy.active_mode(), effective_class), }; let statement = format!( "occupancy coherence={:.2} nodes={} demoted={}", quality.base_coherence, fused.active_nodes, demoted ); let semantic_id = self.world.add_semantic_state( statement, quality.penalized_coherence(), now_ms, provenance.clone(), &[room], ); // Retention: bound the live belief set (one node is appended per cycle; // without this the graph grows ~1.7M nodes/day at 20 Hz). Deterministic // eviction; the just-added belief is always newest and survives. self.world.prune_semantic_states(self.semantic_retention); // 8. Deterministic witness over the trust decision (ADR-137 §2.7). // `effective_class` already reflects any mesh-risk demotion, so a // fragmenting array shifts the witness — partition risk is auditable. let witness = witness_of(&provenance, effective_class); // 9. Drift→recalibration advisor (ADR-135 → ADR-150 §3.4): sustained // low coherence, an environment change-point, or a mesh close to // partitioning recommends refit. let recalibration_recommended = self .recal .observe(quality.base_coherence, change_point.is_some() || mesh_at_risk); self.cycle += 1; Ok(TrustedOutput { semantic_id, quality, effective_class, demoted, provenance, directional, change_point, witness, recalibration_recommended, mesh, }) } /// ADR-138: build per-node array inputs and coordinate, iff every frame's /// `node_id` has a registered geometry. Returns `None` otherwise. fn coordinate_array(&self, node_frames: &[MultiBandCsiFrame]) -> Option { if node_frames.is_empty() { return None; } let mut inputs = Vec::with_capacity(node_frames.len()); for f in node_frames { let g = self.node_geom.get(&f.node_id)?; // bail if any node lacks geometry inputs.push(ArrayNodeInput { node_id: u32::from(f.node_id), position: (g.x, g.y), azimuth: g.azimuth, coherence: f.coherence, clock: ClockQualityScore { offset_stdev_us: 50.0, age_us: 1_000, valid: true }, amplitude: f.channel_frames.first().map(|cf| cf.amplitude.clone()), }); } Some(self.array.coordinate(&inputs)) } /// ADR-142: fold per-node mean amplitude into the evolution tracker and, /// on a cross-link change-point, record an `Event` node in the WorldGraph. fn track_evolution( &mut self, node_frames: &[MultiBandCsiFrame], now_ms: i64, room: WorldId, ) -> Option<(ChangePoint, WorldId)> { let values: Vec = node_frames .iter() .filter_map(|f| f.channel_frames.first()) .map(|cf| { if cf.amplitude.is_empty() { 0.0 } else { cf.amplitude.iter().map(|&a| f64::from(a)).sum::() / cf.amplitude.len() as f64 } }) .collect(); if values.is_empty() { return None; } let n = values.len(); let tracker = self .evolution .get_or_insert_with(|| EvolutionTracker::new(n, 2.0, (n / 2).max(2))); // Node count must be stable for the tracker to remain meaningful. if tracker.n_links() != n { return None; } let cp = tracker.observe_window(&values)?; let event = self.world.upsert_node(WorldNode::Event { id: WorldId::UNASSIGNED, event_type: "baseline_topology_change".to_string(), at_unix_ms: now_ms, located_in: Some(room), }); let _ = self.world.add_edge(event, room, WorldEdge::LocatedIn { since_unix_ms: now_ms }); Some((cp, event)) } } /// Deterministic BLAKE3 witness over a trust decision: the provenance tuple /// (evidence ‖ model ‖ calibration ‖ privacy decision) plus the effective /// privacy-class byte. Stable across runs for identical decisions — the /// "signed operational belief" fingerprint (ADR-137 §2.7 / ADR-028). fn witness_of(p: &SemanticProvenance, class: PrivacyClass) -> [u8; 32] { let mut h = blake3::Hasher::new(); for e in &p.evidence { h.update(e.as_bytes()); h.update(b"\x1f"); } h.update(p.model_version.as_bytes()); h.update(p.calibration_version.as_bytes()); h.update(p.privacy_decision.as_bytes()); h.update(&[class.as_u8()]); *h.finalize().as_bytes() } /// Demote a privacy class by one step (more restrictive), clamped at `Restricted`. /// Monotonic: information is only ever removed (ADR-120/141). fn demote_one(c: PrivacyClass) -> PrivacyClass { let next = (c.as_u8() + 1).min(PrivacyClass::Restricted.as_u8()); PrivacyClass::try_from(next).unwrap_or(PrivacyClass::Restricted) } #[cfg(test)] mod tests { use super::*; use wifi_densepose_signal::hardware_norm::{CanonicalCsiFrame, HardwareType}; fn node_frame(node_id: u8, ts_us: u64, n_sub: usize) -> MultiBandCsiFrame { MultiBandCsiFrame { node_id, timestamp_us: ts_us, channel_frames: vec![CanonicalCsiFrame { amplitude: (0..n_sub).map(|i| 1.0 + 0.1 * i as f32).collect(), phase: (0..n_sub).map(|i| i as f32 * 0.05).collect(), hardware_type: HardwareType::Esp32S3, }], frequencies_mhz: vec![2412], coherence: 0.9, } } fn engine() -> (StreamingEngine, WorldId) { let mut e = StreamingEngine::new(PrivacyMode::PrivateHome, 1, GeoRegistration::default()); let room = e.add_room("living_room", "Living Room"); e.add_sensor("esp32-com9", room); (e, room) } /// End-to-end trust invariant: a clean cycle produces a SemanticState whose /// provenance names evidence + model + calibration + privacy decision, and /// the calibration id flows from input → QualityScore → provenance. #[test] fn cycle_carries_full_provenance() { let (mut e, room) = engine(); let cal = CalibrationId(0xABCD_1234); let frames = [node_frame(0, 1000, 56), node_frame(1, 1001, 56)]; let out = e.process_cycle(&frames, cal, room, 10_000).unwrap(); // Calibration flows all the way through. assert_eq!(out.quality.calibration_id, Some(cal)); assert_eq!(out.provenance.calibration_version, "cal:00000000abcd1234"); // Model + privacy provenance present. assert_eq!(out.provenance.model_version, "rfenc-v1"); assert!(out.provenance.privacy_decision.starts_with("PrivateHome/")); // Evidence refs recorded. assert!(!out.provenance.evidence.is_empty()); // Clean cycle (tight timestamps) → no demotion, stays Anonymous (PrivateHome). assert!(!out.demoted); assert_eq!(out.effective_class, PrivacyClass::Anonymous); // The SemanticState is in the graph with a DerivedFrom edge to the room. assert!(e.world().node(out.semantic_id).is_some()); assert!(e .world() .neighbors(out.semantic_id) .iter() .any(|(to, edge)| *to == room && matches!(edge, WorldEdge::DerivedFrom { .. }))); } /// A tolerated contradiction (loose timestamp spread, within the hard guard) /// demotes the privacy class one step — proving ADR-137 → ADR-141 wiring. #[test] fn contradiction_demotes_privacy() { let (mut e, room) = engine(); let cal = CalibrationId(7); // 2 ms spread: within the 5 ms hard guard but above the 1 ms soft guard. let frames = [node_frame(0, 1000, 56), node_frame(1, 3000, 56)]; let out = e.process_cycle(&frames, cal, room, 20_000).unwrap(); assert!(out.demoted, "loose alignment must demote"); // PrivateHome base = Anonymous(2) → demoted to Restricted(3). assert_eq!(out.effective_class, PrivacyClass::Restricted); assert!(out.provenance.privacy_decision.contains("Restricted")); // Penalized coherence is below the base coherence. assert!(out.quality.penalized_coherence() <= out.quality.base_coherence); } /// Determinism: identical input twice → identical provenance + class /// (the ADR-136 witness-replay spirit, end-to-end through the engine). #[test] fn cycle_is_deterministic() { let cal = CalibrationId(42); let frames = [node_frame(0, 1000, 56), node_frame(1, 1001, 56)]; let (mut e1, r1) = engine(); let o1 = e1.process_cycle(&frames, cal, r1, 5_000).unwrap(); let (mut e2, r2) = engine(); let o2 = e2.process_cycle(&frames, cal, r2, 5_000).unwrap(); assert_eq!(o1.provenance.calibration_version, o2.provenance.calibration_version); assert_eq!(o1.provenance.evidence, o2.provenance.evidence); assert_eq!(o1.effective_class, o2.effective_class); assert_eq!(o1.quality.per_node_weights, o2.quality.per_node_weights); } /// ADR-150 §3.4 adapter provenance: activating a per-room adapter changes /// the provenance model_version AND the witness — the exact weights shaping /// inference are pinned in the trust chain, so an adapter can never swap /// silently. Clearing it restores the base identity (and base witness). #[test] fn adapter_identity_is_witnessed() { let cal = CalibrationId(9); let frames = [node_frame(0, 1000, 56), node_frame(1, 1001, 56)]; let (mut e, room) = engine(); let base = e.process_cycle(&frames, cal, room, 1_000).unwrap(); assert_eq!(base.provenance.model_version, "rfenc-v1"); e.set_room_adapter(AdapterInfo { adapter_id: "a1b2c3d4e5f60718".into(), trained_samples: 150, }); let adapted = e.process_cycle(&frames, cal, room, 2_000).unwrap(); assert_eq!( adapted.provenance.model_version, "rfenc-v1+adapter:a1b2c3d4e5f60718" ); assert_ne!(adapted.witness, base.witness, "adapter must shift the witness"); // A different adapter id yields a different witness again. e.set_room_adapter(AdapterInfo { adapter_id: "ffffffffffffffff".into(), trained_samples: 150, }); let other = e.process_cycle(&frames, cal, room, 3_000).unwrap(); assert_ne!(other.witness, adapted.witness); // Clearing restores the base identity and the base witness. e.clear_room_adapter(); let back = e.process_cycle(&frames, cal, room, 4_000).unwrap(); assert_eq!(back.provenance.model_version, "rfenc-v1"); assert_eq!(back.witness, base.witness); } /// Drift→recalibration advisor logic: a sustained low-coherence streak /// recommends refit; a single healthy cycle resets the streak; a /// change-point recommends immediately regardless of streak. #[test] fn recalibration_advisor_streak_and_change_point() { let mut adv = RecalibrationAdvisor { coherence_floor: 0.5, streak_threshold: 3, ..Default::default() }; // Healthy cycles never recommend and keep the streak at zero. for _ in 0..5 { assert!(!adv.observe(0.9, false)); } assert_eq!(adv.streak(), 0); // Two low cycles: not yet. assert!(!adv.observe(0.2, false)); assert!(!adv.observe(0.2, false)); // Third consecutive low cycle: fire. assert!(adv.observe(0.2, false)); // Recovery resets the streak. assert!(!adv.observe(0.9, false)); assert_eq!(adv.streak(), 0); // A change-point recommends immediately, even at full coherence. assert!(adv.observe(0.9, true)); } /// Engine-level: clean coherent cycles never recommend recalibration (the /// advisor is wired into process_cycle and stays quiet on healthy input). #[test] fn healthy_cycles_do_not_recommend_recalibration() { let (mut e, room) = engine(); e.set_recalibration_advisor(RecalibrationAdvisor { coherence_floor: 0.5, streak_threshold: 3, ..Default::default() }); let cal = CalibrationId(2); for i in 0..5u64 { let frames = [ node_frame(0, 1_000 + i * 50_000, 56), node_frame(1, 1_001 + i * 50_000, 56), ]; let out = e.process_cycle(&frames, cal, room, i as i64).unwrap(); assert!(!out.recalibration_recommended); } } /// Maximum total coupling mass of an n-node mesh whose attention weights /// sum to 1 (coupling = wᵢ·wⱼ·n): Σ_{i f64 { (n_nodes as f64 - 1.0) / 2.0 } /// Mesh guard wiring: a balanced 2-node cycle reports a mesh (cut exists) /// but never flags risk (min_nodes=3); a 3-node mesh whose cut value /// *deterministically* falls at or below the configured risk threshold /// (threshold = the provable upper bound on any achievable cut) is flagged /// at_risk, and the structural event feeds the recalibration advisor /// immediately — no conditional assertions (review finding 4). #[test] fn mesh_partition_risk_feeds_recalibration() { let (mut e, room) = engine(); let cal = CalibrationId(3); // Balanced 2-node mesh: report present, no risk. let out = e .process_cycle(&[node_frame(0, 1000, 56), node_frame(1, 1001, 56)], cal, room, 1) .unwrap(); let mesh = out.mesh.expect("2-node mesh reports"); assert!(!mesh.at_risk); assert!(!out.recalibration_recommended); // 3-node mesh with the operator risk threshold set to the provable // cut upper bound: the crossing is deterministic regardless of the // fuser's exact weighting. e.mesh_guard_mut().risk_threshold = max_coupling_mass(3); let frames = [ node_frame(0, 10_000_000, 56), node_frame(1, 10_000_001, 56), node_frame(2, 10_000_002, 56), ]; let out3 = e.process_cycle(&frames, cal, room, 2).unwrap(); let m3 = out3.mesh.expect("3-node mesh reports"); assert!(m3.at_risk, "cut ≤ threshold must flag partition risk"); assert!( out3.recalibration_recommended, "mesh risk is a structural event — the advisor must fire immediately, no streak" ); assert!(m3.cut_value.is_finite() && m3.cut_value >= 0.0); } /// Mesh partition risk demotes the privacy class and shifts the witness — /// a fragmenting array makes the fused belief less trustworthy, so it is /// emitted at a more restricted class, and that demotion is auditable. /// Both cycles use the *same 3-node topology and frames*; the engines /// differ only in the forced mesh risk, so the witness delta is /// attributable to the risk demotion alone (review finding 4). #[test] fn mesh_risk_demotes_privacy_and_shifts_witness() { let cal = CalibrationId(8); let frames3 = [ node_frame(0, 1000, 56), node_frame(1, 1001, 56), node_frame(2, 1002, 56), ]; // Baseline: same topology, default risk threshold — clean cycle, not // demoted (PrivateHome → Anonymous), mesh healthy. let (mut e1, r1) = engine(); let base = e1.process_cycle(&frames3, cal, r1, 5_000).unwrap(); assert!(!base.mesh.as_ref().unwrap().at_risk); assert!(!base.demoted); assert_eq!(base.effective_class, PrivacyClass::Anonymous); // Forced risk: identical frames/topology, threshold at the provable // cut upper bound so the crossing is deterministic. let (mut e2, r2) = engine(); e2.mesh_guard_mut().risk_threshold = max_coupling_mass(3); let risky = e2.process_cycle(&frames3, cal, r2, 5_000).unwrap(); assert!(risky.mesh.as_ref().unwrap().at_risk); assert!(risky.demoted, "mesh risk must demote"); // PrivateHome base Anonymous(2) → demoted to Restricted(3). assert_eq!(risky.effective_class, PrivacyClass::Restricted); assert!(risky.provenance.privacy_decision.contains("Restricted")); assert_ne!( risky.witness, base.witness, "same topology, risk-only delta must shift the witness" ); } /// WorldGraph belief retention: the live loop appends one SemanticState per /// cycle; past the cap the oldest beliefs are evicted so graph memory is /// bounded, while structural nodes and the newest belief always survive. #[test] fn semantic_state_growth_is_bounded() { let (mut e, room) = engine(); e.set_semantic_retention(5); let cal = CalibrationId(1); let mut last_id = None; let baseline_nodes = 2; // room + sensor for i in 0..20u64 { let frames = [ node_frame(0, 1000 + i * 50_000, 56), node_frame(1, 1001 + i * 50_000, 56), ]; let out = e.process_cycle(&frames, cal, room, 5_000 + i as i64).unwrap(); last_id = Some(out.semantic_id); assert!(e.world().node_count() <= baseline_nodes + 5); } // 20 cycles ran, only 5 beliefs remain, newest is still present. assert_eq!(e.world().node_count(), baseline_nodes + 5); assert!(e.world().node(last_id.unwrap()).is_some()); // Structural nodes survive eviction. assert!(e.world().node(room).is_some()); } fn node_frame_scaled(node_id: u8, ts_us: u64, n_sub: usize, scale: f32) -> MultiBandCsiFrame { MultiBandCsiFrame { node_id, timestamp_us: ts_us, channel_frames: vec![CanonicalCsiFrame { amplitude: (0..n_sub).map(|i| scale * (1.0 + 0.1 * i as f32)).collect(), phase: (0..n_sub).map(|i| i as f32 * 0.05).collect(), hardware_type: HardwareType::Esp32S3, }], frequencies_mhz: vec![2412], coherence: 0.9, } } /// ADR-138 composed: with node geometry registered, the cycle produces /// directional evidence (admitted nodes + weights). #[test] fn array_coordinator_runs_when_geometry_registered() { use std::f32::consts::PI; let (mut e, room) = engine(); e.register_node_geometry(0, 1.0, 0.0, 0.0); e.register_node_geometry(1, -1.0, 0.0, PI); // opposite → good diversity let out = e .process_cycle(&[node_frame(0, 1000, 56), node_frame(1, 1001, 56)], CalibrationId(1), room, 1) .unwrap(); let d = out.directional.expect("geometry registered → directional evidence"); assert_eq!(d.n_admitted, 2); assert!((d.weights.iter().map(|(_, w)| *w).sum::() - 1.0).abs() < 1e-3); // Well-separated, coherent nodes → no array contradiction → no demotion. assert!(!out.demoted); } /// ADR-138 composed: poor geometry (near-colinear nodes) raises a /// GeometryInsufficient contradiction that demotes privacy. #[test] fn array_geometry_insufficient_demotes() { let (mut e, room) = engine(); e.register_node_geometry(0, 1.0, 0.0, 0.0); e.register_node_geometry(1, 1.0, 0.01, 0.01); // nearly colinear → low GDI let out = e .process_cycle(&[node_frame(0, 1000, 56), node_frame(1, 1001, 56)], CalibrationId(1), room, 1) .unwrap(); let d = out.directional.unwrap(); assert!(!d.contradictions.is_empty(), "insufficient geometry flagged"); assert!(out.demoted && out.effective_class == PrivacyClass::Restricted); } /// ADR-142 composed: a sustained baseline then a simultaneous amplitude /// shift on both links yields a change-point + an Event node in the graph. #[test] fn evolution_change_point_recorded_as_event() { let (mut e, room) = engine(); let cal = CalibrationId(1); // Jittered baseline so each link has non-zero std (constant std=0 is undefined). for i in 0..30u64 { let s = if i % 2 == 0 { 0.99 } else { 1.01 }; let out = e .process_cycle(&[node_frame_scaled(0, 1000, 56, s), node_frame_scaled(1, 1001, 56, s)], cal, room, i as i64) .unwrap(); assert!(out.change_point.is_none(), "baseline must not trip a change-point"); } // Large simultaneous excursion on both links → change-point. let out = e .process_cycle(&[node_frame_scaled(0, 1000, 56, 1.6), node_frame_scaled(1, 1001, 56, 1.6)], cal, room, 99) .unwrap(); let (_, event_id) = out.change_point.expect("simultaneous shift → change-point"); assert!(matches!( e.world().node(event_id), Some(WorldNode::Event { event_type, .. }) if event_type == "baseline_topology_change" )); } /// ADR-143 composed: ingesting stable reflector sightings writes an /// ObjectAnchor node into the WorldGraph. #[test] fn reflector_ingestion_writes_object_anchors() { use wifi_densepose_signal::ruvsense::ReflectorObservation; let (mut e, _room) = engine(); let day_ns = 86_400_000_000_000u64; // 8 tight, coherent sightings spanning ~a day → a stable Wall anchor. let obs: Vec = (0..8u64) .map(|i| { let j = if i % 2 == 0 { 0.005 } else { -0.005 }; ReflectorObservation { position: [3.0 + j, 1.0, 0.0], delay_ns: 12.0, coherence: 0.9, at_ns: i * (day_ns / 8) } }) .collect(); let written = e.ingest_reflectors(&obs); assert!(!written.is_empty(), "stable reflector → ObjectAnchor written"); assert!(matches!( e.world().node(written[0]), Some(WorldNode::ObjectAnchor { .. }) )); } /// ADR-137 acceptance (the trust-root path): /// `two calibrated frames -> calibration mismatch -> QualityScore /// contradiction -> Restricted -> calibration_id None -> witness stable`. #[test] fn calibration_mismatch_demotes_and_witness_stable() { let run = || { let (mut e, room) = engine(); // PrivateHome base = Anonymous; mismatch must demote to Restricted. e.process_cycle_calibrated( &[node_frame(0, 1000, 56), node_frame(1, 1001, 56)], &[Some(CalibrationId(1)), Some(CalibrationId(2))], // DISAGREE room, 1, ) .unwrap() }; let out = run(); // QualityScore raised the contradiction; no single calibration epoch. assert!(out.quality.forces_privacy_demotion()); assert_eq!(out.quality.calibration_id, None); assert_eq!(out.provenance.calibration_version, "cal:none"); // BFLD class demoted to Restricted (identity surface removed downstream). assert!(out.demoted); assert_eq!(out.effective_class, PrivacyClass::Restricted); // Witness is deterministic across identical runs. assert_eq!(out.witness, run().witness); assert_ne!(out.witness, [0u8; 32]); } /// Agreeing calibrations set the epoch and do NOT demote (the happy path /// counterpart, proving the mismatch test isn't trivially always-demoting). #[test] fn matching_calibration_sets_epoch_no_demotion() { let (mut e, room) = engine(); let cal = CalibrationId(0xABCD); let out = e .process_cycle_calibrated( &[node_frame(0, 1000, 56), node_frame(1, 1001, 56)], &[Some(cal), Some(cal)], room, 1, ) .unwrap(); assert_eq!(out.quality.calibration_id, Some(cal)); assert!(!out.demoted); assert_eq!(out.effective_class, PrivacyClass::Anonymous); } /// ADR-139 live-loop acceptance (the architecture-proving path): /// `live_frame -> fusion_event -> worldgraph_update -> privacy_rollup -> /// persist -> reload -> same_contents`, with NO raw RF frame persisted. #[test] fn live_frame_to_reload_same_contents() { let mut e = StreamingEngine::new(PrivacyMode::StrictNoIdentity, 1, GeoRegistration::default()); let room = e.add_room("living_room", "Living Room"); let sensor = e.add_sensor("esp32-com9", room); // live_frame -> fusion_event -> worldgraph_update (SemanticState). let out = e .process_cycle(&[node_frame(0, 1000, 56), node_frame(1, 1001, 56)], CalibrationId(9), room, 100) .unwrap(); // person track feeding. let pt = e.update_person_track(7, 2.0, 2.0, room, sensor); // privacy_rollup: StrictNoIdentity suppresses the person_track. let rollup = e.apply_active_privacy_mode(); assert!(rollup.suppressed_nodes.contains(&pt), "person track suppressed"); assert!(rollup.denied_pairs.iter().any(|(_s, n)| *n == pt)); // persist. let bytes = e.snapshot_json().unwrap(); // No raw RF frame persisted — the snapshot is graph nodes/edges only. let json = String::from_utf8(bytes.clone()).unwrap(); assert!(!json.contains("\"amplitude\"") && !json.contains("\"data\""), "no raw RF in snapshot"); // reload. let reloaded = WorldGraph::from_json(&bytes).unwrap(); // same_contents: node count, area resolution, the SemanticState + track, // and an identical room-contents query before vs after reload. assert_eq!(reloaded.node_count(), e.world().node_count()); assert_eq!(reloaded.room_for_area("living_room"), e.world().room_for_area("living_room")); assert!(reloaded.node(out.semantic_id).is_some()); assert!(reloaded.node(pt).is_some()); let mut before = e.world().contents_of(room); before.sort_by_key(|w| w.0); let mut after = reloaded.contents_of(room); after.sort_by_key(|w| w.0); assert_eq!(before, after, "same room-contents query after reload"); // Deterministic persistence: re-serialising the reload is byte-identical. assert_eq!(reloaded.to_json().unwrap(), bytes); } /// The privacy mode switch is recorded in a verifiable attestation chain /// (ADR-141), and a stricter mode raises the emitted class. #[test] fn privacy_mode_switch_is_attested_and_effective() { let (mut e, room) = engine(); e.set_privacy_mode(PrivacyMode::StrictNoIdentity); assert!(e.privacy().verify_chain()); let out = e .process_cycle(&[node_frame(0, 1000, 56), node_frame(1, 1001, 56)], CalibrationId(1), room, 1) .unwrap(); // StrictNoIdentity base = Restricted, even with no contradiction. assert_eq!(out.effective_class, PrivacyClass::Restricted); } }