feat(signal,engine): ADR-137 calibration-mismatch contradiction + trust witness

- signal: MultistaticFuser::fuse_scored_calibrated() threads per-node
  CalibrationId; agreeing epochs → calibration_id set + CalibrationApplied
  evidence; disagreeing → calibration_id None + CalibrationIdMismatch flag
  (forces demotion). +2 tests.
- engine: process_cycle_calibrated() per-node calibration path; process_cycle
  delegates with a uniform epoch. TrustedOutput gains a deterministic BLAKE3
  witness over (provenance || class). calibration_version='cal:none' on mismatch.
- ADR-137 acceptance test: two frames + mismatched calibration -> QualityScore
  contradiction -> Restricted -> calibration_id None -> witness stable. +happy path.
- 11 engine tests, signal 411+ lib tests; workspace 0 errors.

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
ruv 2026-05-29 08:35:40 -04:00
parent 2517a16d88
commit 5878868060
4 changed files with 199 additions and 7 deletions

1
v2/Cargo.lock generated
View File

@ -10656,6 +10656,7 @@ dependencies = [
name = "wifi-densepose-engine" name = "wifi-densepose-engine"
version = "0.3.0" version = "0.3.0"
dependencies = [ dependencies = [
"blake3",
"wifi-densepose-bfld", "wifi-densepose-bfld",
"wifi-densepose-core", "wifi-densepose-core",
"wifi-densepose-geo", "wifi-densepose-geo",

View File

@ -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-bfld = { version = "0.3.0", path = "../wifi-densepose-bfld", features = ["std"] }
wifi-densepose-worldgraph = { version = "0.3.0", path = "../wifi-densepose-worldgraph" } wifi-densepose-worldgraph = { version = "0.3.0", path = "../wifi-densepose-worldgraph" }
wifi-densepose-geo = { path = "../wifi-densepose-geo" } 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] [lints.rust]
unsafe_code = "forbid" unsafe_code = "forbid"

View File

@ -94,6 +94,9 @@ pub struct TrustedOutput {
/// ADR-142 cross-link change-point detected this cycle, if any (and the /// ADR-142 cross-link change-point detected this cycle, if any (and the
/// `Event` node it was recorded as in the WorldGraph). /// `Event` node it was recorded as in the WorldGraph).
pub change_point: Option<(ChangePoint, WorldId)>, 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. /// Composition root for the RuView streaming engine.
@ -285,6 +288,25 @@ impl StreamingEngine {
calibration: CalibrationId, calibration: CalibrationId,
room: WorldId, room: WorldId,
now_ms: i64, now_ms: i64,
) -> Result<TrustedOutput, EngineError> {
// 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<CalibrationId>],
room: WorldId,
now_ms: i64,
) -> Result<TrustedOutput, EngineError> { ) -> Result<TrustedOutput, EngineError> {
// 1. Array coordination (ADR-138) — only when geometry is known for // 1. Array coordination (ADR-138) — only when geometry is known for
// every contributing node. Its contradictions feed the privacy gate. // every contributing node. Its contradictions feed the privacy gate.
@ -292,11 +314,9 @@ impl StreamingEngine {
let array_contradiction = let array_contradiction =
directional.as_ref().is_some_and(|d| !d.contradictions.is_empty()); directional.as_ref().is_some_and(|d| !d.contradictions.is_empty());
// 2. Fuse + score (ADR-137). // 2. Fuse + score with per-node calibration (ADR-137 §2.3).
let (fused, mut quality) = self.fuser.fuse_scored(node_frames, self.coherence_accept)?; let (fused, quality) =
self.fuser.fuse_scored_calibrated(node_frames, calibrations, self.coherence_accept)?;
// 3. Stamp calibration provenance (ADR-135 → ADR-136 → ADR-137).
quality.calibration_id = Some(calibration);
// 4. Evolution change-point (ADR-142) over per-node mean amplitude. // 4. Evolution change-point (ADR-142) over per-node mean amplitude.
let change_point = self.track_evolution(node_frames, now_ms, room); 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 demoted = quality.forces_privacy_demotion() || array_contradiction;
let effective_class = if demoted { demote_one(base_class) } else { base_class }; 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 { let provenance = SemanticProvenance {
evidence: quality.evidence_refs.iter().map(|e| format!("{e:?}")).collect(), evidence: quality.evidence_refs.iter().map(|e| format!("{e:?}")).collect(),
model_version: format!("rfenc-v{}", self.model_version), 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), privacy_decision: format!("{:?}/{:?}", self.privacy.active_mode(), effective_class),
}; };
let statement = format!( let statement = format!(
@ -326,6 +351,9 @@ impl StreamingEngine {
&[room], &[room],
); );
// 7. Deterministic witness over the trust decision (ADR-137 §2.7).
let witness = witness_of(&provenance, effective_class);
self.cycle += 1; self.cycle += 1;
Ok(TrustedOutput { Ok(TrustedOutput {
semantic_id, semantic_id,
@ -335,6 +363,7 @@ impl StreamingEngine {
provenance, provenance,
directional, directional,
change_point, 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`. /// Demote a privacy class by one step (more restrictive), clamped at `Restricted`.
/// Monotonic: information is only ever removed (ADR-120/141). /// Monotonic: information is only ever removed (ADR-120/141).
fn demote_one(c: PrivacyClass) -> PrivacyClass { 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): /// ADR-139 live-loop acceptance (the architecture-proving path):
/// `live_frame -> fusion_event -> worldgraph_update -> privacy_rollup -> /// `live_frame -> fusion_event -> worldgraph_update -> privacy_rollup ->
/// persist -> reload -> same_contents`, with NO raw RF frame persisted. /// persist -> reload -> same_contents`, with NO raw RF frame persisted.

View File

@ -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<super::fusion_quality::CalibrationId>],
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). /// Apply the CIR-domain coherence gate (ADR-134).
/// ///
/// When `use_cir_gate` is enabled and a `CirEstimator` is present, runs /// 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); 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] #[test]
fn ac_fuse_scored_hard_guard_still_errors() { fn ac_fuse_scored_hard_guard_still_errors() {
// Beyond the hard guard interval, fuse_scored errors like fuse. // Beyond the hard guard interval, fuse_scored errors like fuse.