From fb741c3ab3a9ba30cf3bf61dd395e254866e515d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 21:26:56 +0000 Subject: [PATCH] feat(engine): per-room adapter provenance + drift-to-recalibration advisor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the trust-chain gap where an ~11 KB per-room LoRA adapter (ADR-150 section 3.4) could silently change inference without the witness noticing: provenance carried only "rfenc-v" with no notion of adapter identity. - StreamingEngine::set_room_adapter(AdapterInfo): pins the adapter's content-derived id into provenance model_version ("rfenc-v1+adapter:") — and therefore into the BLAKE3 witness — so swapping or clearing adapter weights always shifts the witness. Engine test proves base -> adapter -> other-adapter -> cleared all witness differently and cleared == base. - RecalibrationAdvisor: recommends re-running the ADR-135 empty-room baseline / refitting the room adapter on sustained low fusion coherence (streak threshold, default 60 cycles ~ 3 s at 20 Hz) or an ADR-142 change-point. Surfaced as TrustedOutput::recalibration_recommended, stored on the sensing-server AppState alongside the witness at both live fusion sites. - Bridge plumbing: EngineBridge::{set_room_adapter, clear_room_adapter} + live-path test that the adapter id flows into the live witness. Scope note (honest): this is the deployable provenance/trigger half of the "retrained model" roadmap item. Fitting the adapter itself runs in the existing external calibration service (aether-arena/calibration/); a trained RF-encoder checkpoint still does not exist in-tree. Engine 15 tests, bridge 7 tests. Workspace gate: 2,918 passed / 0 failed. https://claude.ai/code/session_01MjBucx95K4BuUxZi8NWwRH --- CHANGELOG.md | 1 + v2/crates/wifi-densepose-engine/src/lib.rs | 194 +++++++++++++++++- .../src/engine_bridge.rs | 44 +++- .../wifi-densepose-sensing-server/src/main.rs | 7 + 4 files changed, 244 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 18f511f6..3b5e46c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **Per-room adapter provenance + drift→recalibration advisor in the streaming engine.** Closes the trust-chain gap where an ~11 KB per-room LoRA adapter (ADR-150 §3.4) could silently change inference without the witness noticing. `StreamingEngine::set_room_adapter(AdapterInfo)` pins the adapter's content-derived id into provenance `model_version` (`rfenc-v1+adapter:`) — and therefore into the BLAKE3 witness — so swapping or clearing adapter weights always shifts the witness (engine test proves base → adapter → other-adapter → cleared all witness differently, and cleared == base). New `RecalibrationAdvisor` recommends re-running the ADR-135 baseline / refitting the adapter on sustained low fusion coherence (streak threshold, default 60 cycles ≈ 3 s at 20 Hz) or an ADR-142 change-point; surfaced as `TrustedOutput::recalibration_recommended` and stored on the sensing-server `AppState` alongside the witness. Bridge plumbing: `EngineBridge::{set_room_adapter, clear_room_adapter}` + live-path test that the adapter id flows into the live witness. Engine 15 tests, bridge 7 tests. *Scope note: this is the deployable provenance/trigger half of the "retrained model" roadmap item — fitting the adapter itself runs in the existing external calibration service (`aether-arena/calibration/`), and a trained RF-encoder checkpoint still does not exist in-tree.* - **RuView beyond-SOTA research series** (`docs/research/ruview-beyond-sota/`, 6 docs) — research-swarm output defining the beyond-SOTA bar and the path to it: system capability audit (role→crate maturity matrix, gap analysis, risk register), web-verified 2026 SOTA landscape per capability axis (incl. ratified IEEE 802.11bf-2025), 8-pillar target architecture on the ADR-136 contract spine (no rewrite), 6-layer benchmark/validation methodology (all 15 criterion bench targets inventoried; ADR-149 statistical protocol), and a determinism-safe optimization roadmap. Includes session validation evidence: 2,797 workspace tests / 0 failed, Python proof PASS (bit-exact), paired pre/post criterion runs. ### Performance diff --git a/v2/crates/wifi-densepose-engine/src/lib.rs b/v2/crates/wifi-densepose-engine/src/lib.rs index 994b8b35..37d16a3a 100644 --- a/v2/crates/wifi-densepose-engine/src/lib.rs +++ b/v2/crates/wifi-densepose-engine/src/lib.rs @@ -97,6 +97,10 @@ pub struct TrustedOutput { /// 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, } /// Composition root for the RuView streaming engine. @@ -120,6 +124,68 @@ pub struct StreamingEngine { // 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, +} + +/// 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 { @@ -140,9 +206,35 @@ impl StreamingEngine { slam: RfSlam::with_discovery(0.5, 5, 0.6), person_tracks: BTreeMap::new(), semantic_retention: Self::DEFAULT_SEMANTIC_RETENTION, + adapter: None, + recal: RecalibrationAdvisor::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; + } + /// 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). @@ -344,13 +436,20 @@ impl StreamingEngine { // 6. 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: format!("rfenc-v{}", self.model_version), + model_version, calibration_version, privacy_decision: format!("{:?}/{:?}", self.privacy.active_mode(), effective_class), }; @@ -373,6 +472,11 @@ impl StreamingEngine { // 7. Deterministic witness over the trust decision (ADR-137 §2.7). let witness = witness_of(&provenance, effective_class); + // 8. Drift→recalibration advisor (ADR-135 → ADR-150 §3.4): sustained + // low coherence or an environment change-point recommends refit. + let recalibration_recommended = + self.recal.observe(quality.base_coherence, change_point.is_some()); + self.cycle += 1; Ok(TrustedOutput { semantic_id, @@ -383,6 +487,7 @@ impl StreamingEngine { directional, change_point, witness, + recalibration_recommended, }) } @@ -566,6 +671,93 @@ mod tests { 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); + } + } + /// 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. diff --git a/v2/crates/wifi-densepose-sensing-server/src/engine_bridge.rs b/v2/crates/wifi-densepose-sensing-server/src/engine_bridge.rs index 8a789203..0a174d5a 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/engine_bridge.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/engine_bridge.rs @@ -19,7 +19,7 @@ use std::collections::HashMap; use wifi_densepose_bfld::PrivacyMode; -use wifi_densepose_engine::{EngineError, StreamingEngine, TrustedOutput}; +use wifi_densepose_engine::{AdapterInfo, EngineError, StreamingEngine, TrustedOutput}; use wifi_densepose_geo::types::GeoRegistration; use wifi_densepose_signal::ruvsense::fusion_quality::CalibrationId; use wifi_densepose_worldgraph::WorldId; @@ -69,6 +69,18 @@ impl EngineBridge { self.engine.set_privacy_mode(mode); } + /// Activate a per-room calibration adapter (ADR-150 §3.4). The adapter's + /// content-derived id becomes part of provenance/witness from the next + /// cycle — weights can never swap silently on the live path. + pub fn set_room_adapter(&mut self, info: AdapterInfo) { + self.engine.set_room_adapter(info); + } + + /// Deactivate the per-room adapter (revert to the shared base model). + pub fn clear_room_adapter(&mut self) { + self.engine.clear_room_adapter(); + } + /// Borrow the engine (queries, WorldGraph snapshot, privacy audit). pub fn engine(&self) -> &StreamingEngine { &self.engine @@ -215,6 +227,36 @@ mod tests { assert!(bridge.engine().world().node_count() <= 3 + 5); } + #[test] + fn adapter_identity_flows_into_live_witness() { + let states = two_node_states_fixed(); + let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "r", "R"); + let base = bridge + .process_cycle_from_states(&states, 1_000) + .unwrap() + .unwrap(); + bridge.set_room_adapter(AdapterInfo { + adapter_id: "deadbeefcafef00d".into(), + trained_samples: 120, + }); + let adapted = bridge + .process_cycle_from_states(&states, 2_000) + .unwrap() + .unwrap(); + assert!(adapted + .provenance + .model_version + .ends_with("+adapter:deadbeefcafef00d")); + assert_ne!(adapted.witness, base.witness); + // Clearing reverts to the base model identity. + bridge.clear_room_adapter(); + let back = bridge + .process_cycle_from_states(&states, 3_000) + .unwrap() + .unwrap(); + assert_eq!(back.provenance.model_version, "rfenc-v1"); + } + #[test] fn identity_strict_mode_is_carried_into_provenance() { let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "r", "R"); diff --git a/v2/crates/wifi-densepose-sensing-server/src/main.rs b/v2/crates/wifi-densepose-sensing-server/src/main.rs index adfb632e..7f61fa5c 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/main.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/main.rs @@ -1043,6 +1043,10 @@ struct AppStateInner { engine_bridge: engine_bridge::EngineBridge, /// Witness of the most recent governed trust cycle (BLAKE3), for audit/UI. pub(crate) last_trust_witness: Option<[u8; 32]>, + /// Latest drift→recalibration recommendation from the governed engine + /// (ADR-135 → ADR-150 §3.4): sustained low coherence or a change-point + /// suggests re-running the empty-room baseline / refitting the room adapter. + pub(crate) recalibration_recommended: bool, /// SVD-based room field model for eigenvalue person counting (None until calibration). field_model: Option, // ── ADR-044 §5.2: adaptive rolling-p95 normalization ───────────────────── @@ -5069,6 +5073,7 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) { .process_cycle_from_states(&sref.node_states, now_ms) { sref.last_trust_witness = Some(trust.witness); + sref.recalibration_recommended = trust.recalibration_recommended; } } @@ -5538,6 +5543,7 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) { .process_cycle_from_states(&sref.node_states, now_ms) { sref.last_trust_witness = Some(trust.witness); + sref.recalibration_recommended = trust.recalibration_recommended; } } @@ -6859,6 +6865,7 @@ async fn main() { "Default Room", ), last_trust_witness: None, + recalibration_recommended: false, field_model: if args.calibrate { info!("Field model calibration enabled — room should be empty during startup"); FieldModel::new(field_bridge::single_link_config()).ok()