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:
parent
2517a16d88
commit
5878868060
|
|
@ -10656,6 +10656,7 @@ dependencies = [
|
|||
name = "wifi-densepose-engine"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"wifi-densepose-bfld",
|
||||
"wifi-densepose-core",
|
||||
"wifi-densepose-geo",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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<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> {
|
||||
// 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.
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
///
|
||||
/// 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.
|
||||
|
|
|
|||
Loading…
Reference in New Issue