diff --git a/v2/Cargo.lock b/v2/Cargo.lock index 7a31dc76..7d360dc1 100644 --- a/v2/Cargo.lock +++ b/v2/Cargo.lock @@ -10656,6 +10656,7 @@ dependencies = [ name = "wifi-densepose-engine" version = "0.3.0" dependencies = [ + "blake3", "wifi-densepose-bfld", "wifi-densepose-core", "wifi-densepose-geo", diff --git a/v2/crates/wifi-densepose-engine/Cargo.toml b/v2/crates/wifi-densepose-engine/Cargo.toml index 3ce36749..62193dc1 100644 --- a/v2/crates/wifi-densepose-engine/Cargo.toml +++ b/v2/crates/wifi-densepose-engine/Cargo.toml @@ -17,6 +17,8 @@ wifi-densepose-ruvector = { version = "0.3.0", path = "../wifi-densepose-ruvecto wifi-densepose-bfld = { version = "0.3.0", path = "../wifi-densepose-bfld", features = ["std"] } wifi-densepose-worldgraph = { version = "0.3.0", path = "../wifi-densepose-worldgraph" } wifi-densepose-geo = { path = "../wifi-densepose-geo" } +# Deterministic witness over the trust decision (ADR-137 §2.7 / ADR-028). +blake3 = { version = "1.5", default-features = false } [lints.rust] unsafe_code = "forbid" diff --git a/v2/crates/wifi-densepose-engine/src/lib.rs b/v2/crates/wifi-densepose-engine/src/lib.rs index 72b42e7a..10d31ea0 100644 --- a/v2/crates/wifi-densepose-engine/src/lib.rs +++ b/v2/crates/wifi-densepose-engine/src/lib.rs @@ -94,6 +94,9 @@ pub struct TrustedOutput { /// 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], } /// Composition root for the RuView streaming engine. @@ -285,6 +288,25 @@ impl StreamingEngine { 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. @@ -292,11 +314,9 @@ impl StreamingEngine { let array_contradiction = directional.as_ref().is_some_and(|d| !d.contradictions.is_empty()); - // 2. Fuse + score (ADR-137). - let (fused, mut quality) = self.fuser.fuse_scored(node_frames, self.coherence_accept)?; - - // 3. Stamp calibration provenance (ADR-135 → ADR-136 → ADR-137). - quality.calibration_id = Some(calibration); + // 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); @@ -307,11 +327,16 @@ impl StreamingEngine { let demoted = quality.forces_privacy_demotion() || array_contradiction; let effective_class = if demoted { demote_one(base_class) } else { base_class }; - // 6. Semantic state with mandatory provenance (ADR-139/140). + // 6. Semantic state with mandatory provenance (ADR-139/140). The + // calibration version comes from the *agreed* epoch (None on mismatch). + let calibration_version = match quality.calibration_id { + Some(c) => format!("cal:{:016x}", c.0), + None => "cal:none".to_string(), + }; let provenance = SemanticProvenance { evidence: quality.evidence_refs.iter().map(|e| format!("{e:?}")).collect(), model_version: format!("rfenc-v{}", self.model_version), - calibration_version: format!("cal:{:016x}", calibration.0), + calibration_version, privacy_decision: format!("{:?}/{:?}", self.privacy.active_mode(), effective_class), }; let statement = format!( @@ -326,6 +351,9 @@ impl StreamingEngine { &[room], ); + // 7. Deterministic witness over the trust decision (ADR-137 §2.7). + let witness = witness_of(&provenance, effective_class); + self.cycle += 1; Ok(TrustedOutput { semantic_id, @@ -335,6 +363,7 @@ impl StreamingEngine { provenance, directional, change_point, + witness, }) } @@ -401,6 +430,23 @@ impl StreamingEngine { } } +/// 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 { @@ -595,6 +641,54 @@ mod tests { )); } + /// 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. diff --git a/v2/crates/wifi-densepose-signal/src/ruvsense/multistatic.rs b/v2/crates/wifi-densepose-signal/src/ruvsense/multistatic.rs index e0bc3c18..ce198a25 100644 --- a/v2/crates/wifi-densepose-signal/src/ruvsense/multistatic.rs +++ b/v2/crates/wifi-densepose-signal/src/ruvsense/multistatic.rs @@ -370,6 +370,65 @@ impl MultistaticFuser { )) } + /// Like [`Self::fuse_scored`], but threads a per-node calibration epoch + /// (ADR-137 §2.3). `calibrations[i]` is the [`CalibrationId`] applied to + /// `node_frames[i]` (ADR-135 `BaselineCalibration::calibration_id`). + /// + /// - If every contributing node carries the **same** calibration id, the + /// score's `calibration_id` is set to it and a + /// [`EvidenceRef::CalibrationApplied`] is recorded. + /// - If the calibrations **disagree** (or some are missing), the score's + /// `calibration_id` is left `None` and a + /// [`ContradictionFlag::CalibrationIdMismatch`] is raised — which forces a + /// downstream privacy demotion (ADR-141). + /// + /// # Errors + /// Same hard-error preconditions as [`Self::fuse`]. + pub fn fuse_scored_calibrated( + &self, + node_frames: &[MultiBandCsiFrame], + calibrations: &[Option], + coherence_accept: f32, + ) -> std::result::Result<(FusedSensingFrame, super::fusion_quality::QualityScore), MultistaticError> + { + use super::fusion_quality::{ContradictionFlag, EvidenceRef}; + let (fused, mut score) = self.fuse_scored(node_frames, coherence_accept)?; + + let present: Vec<_> = calibrations.iter().flatten().copied().collect(); + if present.is_empty() { + return Ok((fused, score)); // uncalibrated path — leave None. + } + // Modal (most frequent) calibration id; ties resolve to the first seen. + let mut modal = present[0]; + let mut best = 0usize; + for &cand in &present { + let c = present.iter().filter(|&&x| x == cand).count(); + if c > best { + best = c; + modal = cand; + } + } + // Disagreement = any node whose calibration differs from the modal, + // including nodes that carried no calibration at all. + let agreeing = present.iter().filter(|&&x| x == modal).count(); + let disagreeing = calibrations.len() - agreeing; + + if disagreeing == 0 { + score.calibration_id = Some(modal); + score.evidence_refs.push(EvidenceRef::CalibrationApplied { + calibration_id: modal, + n_frames: agreeing, + }); + } else { + // Mismatch: unsafe to claim a single calibration epoch (§2.3). + score.calibration_id = None; + score + .contradiction_flags + .push(ContradictionFlag::CalibrationIdMismatch { expected: modal, disagreeing }); + } + Ok((fused, score)) + } + /// Apply the CIR-domain coherence gate (ADR-134). /// /// When `use_cir_gate` is enabled and a `CirEstimator` is present, runs @@ -724,6 +783,42 @@ mod tests { assert!(score.penalized_coherence() < score.base_coherence); } + #[test] + fn ac_fuse_scored_calibrated_agreement_sets_id() { + use super::super::fusion_quality::{CalibrationId, EvidenceRef}; + let fuser = MultistaticFuser::new(); + let f0 = make_node_frame(0, 1000, 56, 1.0); + let f1 = make_node_frame(1, 1001, 56, 1.0); + let cal = CalibrationId(0xCAFE); + let (_f, score) = fuser + .fuse_scored_calibrated(&[f0, f1], &[Some(cal), Some(cal)], 0.85) + .unwrap(); + assert_eq!(score.calibration_id, Some(cal), "agreed calibration recorded"); + assert!(score + .evidence_refs + .iter() + .any(|e| matches!(e, EvidenceRef::CalibrationApplied { calibration_id, .. } if *calibration_id == cal))); + assert!(!score.forces_privacy_demotion()); + } + + #[test] + fn ac_fuse_scored_calibration_mismatch_flags_and_nulls_id() { + use super::super::fusion_quality::{CalibrationId, ContradictionFlag}; + let fuser = MultistaticFuser::new(); + let f0 = make_node_frame(0, 1000, 56, 1.0); + let f1 = make_node_frame(1, 1001, 56, 1.0); + // Two nodes, DIFFERENT calibration epochs → mismatch. + let (_f, score) = fuser + .fuse_scored_calibrated(&[f0, f1], &[Some(CalibrationId(1)), Some(CalibrationId(2))], 0.85) + .unwrap(); + assert_eq!(score.calibration_id, None, "mismatch ⇒ no single calibration id"); + assert!(score + .contradiction_flags + .iter() + .any(|c| matches!(c, ContradictionFlag::CalibrationIdMismatch { disagreeing: 1, .. }))); + assert!(score.forces_privacy_demotion(), "mismatch forces demotion"); + } + #[test] fn ac_fuse_scored_hard_guard_still_errors() { // Beyond the hard guard interval, fuse_scored errors like fuse.