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"
|
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",
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue