feat(signal): ADR-137 fusion quality scoring + evidence/contradiction flags (#841)
- fusion_quality.rs: QualityScore, FamilyId, CalibrationId, EvidenceRef, ContradictionFlag (canonical owner per §2.3; 138 imports CoherenceDrop/ GeometryInsufficient variants) - QualityScore impls ADR-136 QualityScored (penalized_coherence, bounds) - MultistaticFuser::fuse_scored() — additive over fuse(): real per-node attention weights, WeightEntropy + CoherenceGateThreshold evidence, soft-guard TimestampMismatch contradiction → forces_privacy_demotion() - node_attention_weights() extracted + reused by attention_weighted_fusion - soft_guard_us config (default guard/5); 6 ADR-137 tests - workspace check: 0 errors Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
parent
11f89727f1
commit
4fa3847acd
|
|
@ -0,0 +1,188 @@
|
|||
//! ADR-137 — Fusion-engine quality scoring with evidence references and
|
||||
//! contradiction flags.
|
||||
//!
|
||||
//! Every fusion stage emits a [`QualityScore`] alongside its payload. The score
|
||||
//! names the positive evidence ([`EvidenceRef`]) that justified the fusion and
|
||||
//! the tolerated-but-recorded disagreements ([`ContradictionFlag`]) that must
|
||||
//! lower the downstream BFLD privacy class (ADR-141 §2 / ADR-120). It implements
|
||||
//! the ADR-136 [`QualityScored`](super::QualityScored) trait so the streaming
|
||||
//! engine can route, gate, and log on quality uniformly.
|
||||
//!
|
||||
//! [`ContradictionFlag`] is the **single canonical type** for tolerated fusion
|
||||
//! disagreements (ADR-137 §2.3); the ADR-138 `ArrayCoordinator` imports it and
|
||||
//! emits its `CoherenceDrop` / `GeometryInsufficient` variants.
|
||||
|
||||
use super::QualityScored;
|
||||
|
||||
/// Identifies which sensing family produced a fused frame, so one
|
||||
/// [`QualityScore`] can be correlated across the signal-domain fuser
|
||||
/// (`multistatic.rs`) and the embedding-domain fuser (`viewpoint/fusion.rs`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum FamilyId {
|
||||
/// `ruvsense/multistatic.rs` CSI/CIR-domain fusion.
|
||||
MultistaticCsi,
|
||||
/// `ruvector/viewpoint/fusion.rs` AETHER-embedding fusion.
|
||||
ViewpointEmbedding,
|
||||
}
|
||||
|
||||
/// Calibration epoch identifier (ADR-137 §2.1). Derived from the ADR-135
|
||||
/// `BaselineCalibration` capture time plus device id; stable across reboots,
|
||||
/// changes only on recalibration.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct CalibrationId(pub u64);
|
||||
|
||||
/// A single piece of positive evidence supporting a fusion decision (ADR-137
|
||||
/// §2.2). Each variant carries the value that crossed a threshold, not just a
|
||||
/// boolean, so the witness record is reproducible.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum EvidenceRef {
|
||||
/// The coherence-gate threshold was met. `coherence` is the value,
|
||||
/// `threshold` the configured gate.
|
||||
CoherenceGateThreshold { coherence: f32, threshold: f32 },
|
||||
/// The ADR-134 CIR dominant-tap ratio contributed to the gate. `blended`
|
||||
/// is true when it was folded into `base_coherence` (false on fallback).
|
||||
CirDominantTapRatio { ratio: f32, blended: bool },
|
||||
/// Attention-weight entropy supported a balanced (multi-node) fusion.
|
||||
WeightEntropy { normalized_entropy: f32, n_nodes: usize },
|
||||
/// An ADR-135 baseline was applied to every contributing frame at a single
|
||||
/// agreed calibration epoch before pooling.
|
||||
CalibrationApplied { calibration_id: CalibrationId, n_frames: usize },
|
||||
}
|
||||
|
||||
/// A tolerated disagreement detected during fusion (ADR-137 §2.3). A non-empty
|
||||
/// set lowers the emitted BFLD privacy class and produces a witness record.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum ContradictionFlag {
|
||||
/// Node `capture_ns` values spread within the guard interval but beyond a
|
||||
/// stricter "comparable" sub-threshold. Carries the observed spread.
|
||||
TimestampMismatch { spread_ns: u64, soft_guard_ns: u64 },
|
||||
/// Contributing frames carried different calibration ids. `expected` is the
|
||||
/// modal id; `disagreeing` counts the disagreeing frames.
|
||||
CalibrationIdMismatch { expected: CalibrationId, disagreeing: usize },
|
||||
/// Phase alignment did not converge for at least one node.
|
||||
PhaseAlignmentFailed { node_idx: usize },
|
||||
/// A node's ADR-135 drift score conflicts with the array consensus.
|
||||
DriftProfileConflict { node_idx: usize, drift_score: f32 },
|
||||
/// Raised upstream by the ADR-138 `ArrayCoordinator`: a node's coherence
|
||||
/// dropped beyond `sigma`σ of its rolling mean.
|
||||
CoherenceDrop { node_idx: usize, sigma: f32 },
|
||||
/// Raised upstream by the ADR-138 `ArrayCoordinator`: array Geometric
|
||||
/// Diversity Index fell below the geometry-sufficiency floor.
|
||||
GeometryInsufficient { gdi: f32 },
|
||||
}
|
||||
|
||||
/// Auditable quality record for one fused frame (ADR-137 §2.1).
|
||||
///
|
||||
/// Every semantic state downstream of fusion traces back to exactly one
|
||||
/// `QualityScore`, which names the signal evidence (`evidence_refs`), the
|
||||
/// calibration epoch (`calibration_id`), and the privacy-relevant disagreements
|
||||
/// (`contradiction_flags`) that informed it.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct QualityScore {
|
||||
/// Which fuser produced this score.
|
||||
pub family_id: FamilyId,
|
||||
/// Capture-clock timestamp (ns) of the fused cycle (median of contributors).
|
||||
pub capture_ns: u64,
|
||||
/// The calibration epoch all contributing frames agreed on, or `None` when
|
||||
/// they disagreed (see [`ContradictionFlag::CalibrationIdMismatch`]).
|
||||
pub calibration_id: Option<CalibrationId>,
|
||||
/// Coherence in [0, 1] before any contradiction penalty is applied.
|
||||
pub base_coherence: f32,
|
||||
/// Per-contributing-node attention weight, node-index aligned. Sums to ~1.0.
|
||||
pub per_node_weights: Vec<f32>,
|
||||
/// Concrete checks that fired in support of this fusion.
|
||||
pub evidence_refs: Vec<EvidenceRef>,
|
||||
/// Tolerated-but-recorded disagreements. A non-empty set forces a BFLD
|
||||
/// privacy demotion.
|
||||
pub contradiction_flags: Vec<ContradictionFlag>,
|
||||
/// Monotonic capture-clock time at which this score was computed (ns).
|
||||
pub timestamp_computed_ns: u64,
|
||||
}
|
||||
|
||||
impl QualityScore {
|
||||
/// True when a non-empty contradiction set must demote the BFLD privacy
|
||||
/// class (ADR-137 §2.7 → ADR-141). The fusion stage and the privacy gate
|
||||
/// both consult this so the demotion rule lives in one place.
|
||||
#[must_use]
|
||||
pub fn forces_privacy_demotion(&self) -> bool {
|
||||
!self.contradiction_flags.is_empty()
|
||||
}
|
||||
|
||||
/// Coherence after the contradiction penalty: each contradiction multiplies
|
||||
/// the base coherence by 0.8, clamped to [0, 1]. This is the value the
|
||||
/// streaming engine routes/gates on.
|
||||
#[must_use]
|
||||
pub fn penalized_coherence(&self) -> f32 {
|
||||
let penalty = 0.8_f32.powi(self.contradiction_flags.len() as i32);
|
||||
(self.base_coherence * penalty).clamp(0.0, 1.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl QualityScored for QualityScore {
|
||||
fn quality_score(&self) -> f32 {
|
||||
self.penalized_coherence()
|
||||
}
|
||||
|
||||
fn confidence_bounds(&self) -> (f32, f32) {
|
||||
// Width grows with the number of tolerated contradictions: each adds
|
||||
// ±0.1 of uncertainty around the penalized coherence, clamped to [0,1].
|
||||
let c = self.penalized_coherence();
|
||||
let half = (0.1 * self.contradiction_flags.len() as f32).min(c.min(1.0 - c));
|
||||
((c - half).max(0.0), (c + half).min(1.0))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn base() -> QualityScore {
|
||||
QualityScore {
|
||||
family_id: FamilyId::MultistaticCsi,
|
||||
capture_ns: 1_000,
|
||||
calibration_id: None,
|
||||
base_coherence: 0.9,
|
||||
per_node_weights: vec![0.5, 0.5],
|
||||
evidence_refs: vec![EvidenceRef::WeightEntropy {
|
||||
normalized_entropy: 1.0,
|
||||
n_nodes: 2,
|
||||
}],
|
||||
contradiction_flags: vec![],
|
||||
timestamp_computed_ns: 1_000,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_contradiction_no_demotion() {
|
||||
let q = base();
|
||||
assert!(!q.forces_privacy_demotion());
|
||||
assert!((q.penalized_coherence() - 0.9).abs() < 1e-6);
|
||||
let (lo, hi) = q.confidence_bounds();
|
||||
assert!(lo <= hi && (lo - 0.9).abs() < 1e-6 && (hi - 0.9).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contradiction_penalizes_and_demotes() {
|
||||
let mut q = base();
|
||||
q.contradiction_flags.push(ContradictionFlag::TimestampMismatch {
|
||||
spread_ns: 2_000,
|
||||
soft_guard_ns: 1_000,
|
||||
});
|
||||
assert!(q.forces_privacy_demotion());
|
||||
assert!((q.penalized_coherence() - 0.72).abs() < 1e-5); // 0.9 * 0.8
|
||||
let (lo, hi) = q.confidence_bounds();
|
||||
assert!(0.0 <= lo && lo <= hi && hi <= 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quality_scored_trait_bounds_invariant() {
|
||||
let mut q = base();
|
||||
for _ in 0..5 {
|
||||
q.contradiction_flags.push(ContradictionFlag::PhaseAlignmentFailed { node_idx: 0 });
|
||||
}
|
||||
let s = q.quality_score();
|
||||
let (lo, hi) = q.confidence_bounds();
|
||||
assert!((0.0..=1.0).contains(&s));
|
||||
assert!(0.0 <= lo && lo <= hi && hi <= 1.0);
|
||||
}
|
||||
}
|
||||
|
|
@ -58,12 +58,18 @@ pub mod pose_tracker;
|
|||
// ADR-134: CIR estimation (ISTA + NeumannSolver warm-start)
|
||||
pub mod cir;
|
||||
|
||||
// ADR-137: Fusion-engine quality scoring (evidence + contradiction flags)
|
||||
pub mod fusion_quality;
|
||||
|
||||
// ADR-135: Empty-room baseline calibration (Welford online, circular phase)
|
||||
pub mod calibration;
|
||||
|
||||
// Re-export core types for ergonomic access
|
||||
pub use coherence::CoherenceState;
|
||||
pub use coherence_gate::{GateDecision, GatePolicy};
|
||||
pub use fusion_quality::{
|
||||
CalibrationId, ContradictionFlag, EvidenceRef, FamilyId, QualityScore,
|
||||
};
|
||||
pub use multiband::MultiBandCsiFrame;
|
||||
pub use multistatic::FusedSensingFrame;
|
||||
pub use phase_align::{PhaseAlignError, PhaseAligner};
|
||||
|
|
|
|||
|
|
@ -86,6 +86,10 @@ pub struct MultistaticConfig {
|
|||
/// Maximum timestamp spread (microseconds) across nodes in one cycle.
|
||||
/// Default: 5000 us (5 ms), well within the 50 ms TDMA cycle.
|
||||
pub guard_interval_us: u64,
|
||||
/// ADR-137 soft guard (microseconds): a spread above this but within
|
||||
/// `guard_interval_us` is fused but recorded as a `TimestampMismatch`
|
||||
/// contradiction (loose alignment ⇒ privacy demotion). Default guard/5.
|
||||
pub soft_guard_us: u64,
|
||||
/// Minimum number of nodes for multistatic mode.
|
||||
/// Falls back to single-node mode if fewer nodes are available.
|
||||
pub min_nodes: usize,
|
||||
|
|
@ -103,6 +107,7 @@ impl Default for MultistaticConfig {
|
|||
fn default() -> Self {
|
||||
Self {
|
||||
guard_interval_us: 5000,
|
||||
soft_guard_us: 1000,
|
||||
min_nodes: 2,
|
||||
attention_temperature: 1.0,
|
||||
enable_person_separation: true,
|
||||
|
|
@ -281,6 +286,90 @@ impl MultistaticFuser {
|
|||
})
|
||||
}
|
||||
|
||||
/// Fuse and produce an auditable [`QualityScore`] alongside the frame
|
||||
/// (ADR-137). Additive over [`Self::fuse`]: the frame is identical; the
|
||||
/// score records the per-node attention weights actually used, the positive
|
||||
/// [`EvidenceRef`]s, and any tolerated [`ContradictionFlag`]s (e.g. a loose
|
||||
/// but in-guard timestamp spread). A non-empty contradiction set must demote
|
||||
/// the downstream BFLD privacy class (see [`QualityScore::forces_privacy_demotion`]).
|
||||
///
|
||||
/// `coherence_accept` is the gate threshold (mirrors `RuvSenseConfig`);
|
||||
/// meeting it records a [`EvidenceRef::CoherenceGateThreshold`].
|
||||
///
|
||||
/// # Errors
|
||||
/// Same hard-error preconditions as [`Self::fuse`].
|
||||
pub fn fuse_scored(
|
||||
&self,
|
||||
node_frames: &[MultiBandCsiFrame],
|
||||
coherence_accept: f32,
|
||||
) -> std::result::Result<(FusedSensingFrame, super::fusion_quality::QualityScore), MultistaticError>
|
||||
{
|
||||
use super::fusion_quality::{ContradictionFlag, EvidenceRef, FamilyId, QualityScore};
|
||||
|
||||
let fused = self.fuse(node_frames)?;
|
||||
|
||||
// Recompute the per-node amplitude views (same selection as `fuse`).
|
||||
let amplitudes: Vec<&[f32]> = node_frames
|
||||
.iter()
|
||||
.filter_map(|f| f.channel_frames.first().map(|cf| cf.amplitude.as_slice()))
|
||||
.collect();
|
||||
let n_nodes = amplitudes.len();
|
||||
let per_node_weights = if n_nodes <= 1 {
|
||||
vec![1.0_f32; n_nodes]
|
||||
} else {
|
||||
node_attention_weights(&litudes, self.config.attention_temperature)
|
||||
};
|
||||
|
||||
// --- Positive evidence ---
|
||||
let mut evidence_refs = Vec::new();
|
||||
if n_nodes > 1 {
|
||||
evidence_refs.push(EvidenceRef::WeightEntropy {
|
||||
normalized_entropy: compute_weight_coherence(&per_node_weights),
|
||||
n_nodes,
|
||||
});
|
||||
}
|
||||
if fused.cross_node_coherence >= coherence_accept {
|
||||
evidence_refs.push(EvidenceRef::CoherenceGateThreshold {
|
||||
coherence: fused.cross_node_coherence,
|
||||
threshold: coherence_accept,
|
||||
});
|
||||
}
|
||||
|
||||
// --- Tolerated contradictions ---
|
||||
let mut contradiction_flags = Vec::new();
|
||||
if n_nodes > 1 {
|
||||
let min_ts = node_frames.iter().map(|f| f.timestamp_us).min().unwrap_or(0);
|
||||
let max_ts = node_frames.iter().map(|f| f.timestamp_us).max().unwrap_or(0);
|
||||
let spread_ns = (max_ts - min_ts).saturating_mul(1000);
|
||||
let soft_guard_ns = self.config.soft_guard_us.saturating_mul(1000);
|
||||
if spread_ns > soft_guard_ns {
|
||||
contradiction_flags.push(ContradictionFlag::TimestampMismatch {
|
||||
spread_ns,
|
||||
soft_guard_ns,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let capture_ns = fused.timestamp_us.saturating_mul(1000);
|
||||
let base_coherence = fused.cross_node_coherence;
|
||||
Ok((
|
||||
fused,
|
||||
QualityScore {
|
||||
family_id: FamilyId::MultistaticCsi,
|
||||
capture_ns,
|
||||
// Frames at this layer do not yet carry a calibration epoch
|
||||
// (ADR-135 id propagation lands with the calibration Stage);
|
||||
// recorded as None until then.
|
||||
calibration_id: None,
|
||||
base_coherence,
|
||||
per_node_weights,
|
||||
evidence_refs,
|
||||
contradiction_flags,
|
||||
timestamp_computed_ns: capture_ns,
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
/// Apply the CIR-domain coherence gate (ADR-134).
|
||||
///
|
||||
/// When `use_cir_gate` is enabled and a `CirEstimator` is present, runs
|
||||
|
|
@ -366,46 +455,10 @@ fn attention_weighted_fusion(
|
|||
phases: &[&[f32]],
|
||||
temperature: f32,
|
||||
) -> (Vec<f32>, Vec<f32>, f32) {
|
||||
let n_nodes = amplitudes.len();
|
||||
let n_sub = amplitudes[0].len();
|
||||
|
||||
// Compute mean amplitude as consensus reference
|
||||
let mut mean_amp = vec![0.0_f32; n_sub];
|
||||
for amp in amplitudes {
|
||||
for (i, &v) in amp.iter().enumerate() {
|
||||
mean_amp[i] += v;
|
||||
}
|
||||
}
|
||||
for v in &mut mean_amp {
|
||||
*v /= n_nodes as f32;
|
||||
}
|
||||
|
||||
// Compute attention weights based on similarity to consensus
|
||||
let mut logits = vec![0.0_f32; n_nodes];
|
||||
for (n, amp) in amplitudes.iter().enumerate() {
|
||||
let mut dot = 0.0_f32;
|
||||
let mut norm_a = 0.0_f32;
|
||||
let mut norm_b = 0.0_f32;
|
||||
for i in 0..n_sub {
|
||||
dot += amp[i] * mean_amp[i];
|
||||
norm_a += amp[i] * amp[i];
|
||||
norm_b += mean_amp[i] * mean_amp[i];
|
||||
}
|
||||
let denom = (norm_a * norm_b).sqrt().max(1e-12);
|
||||
let similarity = dot / denom;
|
||||
logits[n] = similarity / temperature;
|
||||
}
|
||||
|
||||
// Numerically stable softmax: subtract max to prevent exp() overflow
|
||||
let max_logit = logits.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
|
||||
let mut weights = vec![0.0_f32; n_nodes];
|
||||
for (n, &logit) in logits.iter().enumerate() {
|
||||
weights[n] = (logit - max_logit).exp();
|
||||
}
|
||||
let weight_sum: f32 = weights.iter().sum::<f32>().max(1e-12);
|
||||
for w in &mut weights {
|
||||
*w /= weight_sum;
|
||||
}
|
||||
// Attention weights (cosine similarity to consensus, softmax).
|
||||
let weights = node_attention_weights(amplitudes, temperature);
|
||||
|
||||
// Weighted fusion
|
||||
let mut fused_amp = vec![0.0_f32; n_sub];
|
||||
|
|
@ -434,11 +487,62 @@ fn attention_weighted_fusion(
|
|||
(fused_amp, fused_ph, coherence)
|
||||
}
|
||||
|
||||
/// Compute the per-node attention weights (cosine similarity to the amplitude
|
||||
/// consensus, softmaxed at `temperature`). Returned weights sum to ~1.0 and are
|
||||
/// node-index aligned. Exposed so the ADR-137 fusion-quality scorer records the
|
||||
/// exact weights used for fusion rather than re-deriving an approximation.
|
||||
#[must_use]
|
||||
pub fn node_attention_weights(amplitudes: &[&[f32]], temperature: f32) -> Vec<f32> {
|
||||
let n_nodes = amplitudes.len();
|
||||
if n_nodes == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let n_sub = amplitudes[0].len();
|
||||
|
||||
// Mean amplitude as consensus reference.
|
||||
let mut mean_amp = vec![0.0_f32; n_sub];
|
||||
for amp in amplitudes {
|
||||
for (i, &v) in amp.iter().enumerate() {
|
||||
mean_amp[i] += v;
|
||||
}
|
||||
}
|
||||
for v in &mut mean_amp {
|
||||
*v /= n_nodes as f32;
|
||||
}
|
||||
|
||||
// Cosine-similarity logits.
|
||||
let mut logits = vec![0.0_f32; n_nodes];
|
||||
for (n, amp) in amplitudes.iter().enumerate() {
|
||||
let mut dot = 0.0_f32;
|
||||
let mut norm_a = 0.0_f32;
|
||||
let mut norm_b = 0.0_f32;
|
||||
for i in 0..n_sub.min(amp.len()) {
|
||||
dot += amp[i] * mean_amp[i];
|
||||
norm_a += amp[i] * amp[i];
|
||||
norm_b += mean_amp[i] * mean_amp[i];
|
||||
}
|
||||
let denom = (norm_a * norm_b).sqrt().max(1e-12);
|
||||
logits[n] = (dot / denom) / temperature;
|
||||
}
|
||||
|
||||
// Numerically stable softmax.
|
||||
let max_logit = logits.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
|
||||
let mut weights = vec![0.0_f32; n_nodes];
|
||||
for (n, &logit) in logits.iter().enumerate() {
|
||||
weights[n] = (logit - max_logit).exp();
|
||||
}
|
||||
let weight_sum: f32 = weights.iter().sum::<f32>().max(1e-12);
|
||||
for w in &mut weights {
|
||||
*w /= weight_sum;
|
||||
}
|
||||
weights
|
||||
}
|
||||
|
||||
/// Compute coherence from attention weights.
|
||||
///
|
||||
/// Returns 1.0 when all weights are equal (all nodes agree),
|
||||
/// and approaches 0.0 when a single node dominates.
|
||||
fn compute_weight_coherence(weights: &[f32]) -> f32 {
|
||||
pub(crate) fn compute_weight_coherence(weights: &[f32]) -> f32 {
|
||||
let n = weights.len() as f32;
|
||||
if n <= 1.0 {
|
||||
return 1.0;
|
||||
|
|
@ -575,6 +679,67 @@ mod tests {
|
|||
assert_eq!(fused.active_nodes, 4);
|
||||
}
|
||||
|
||||
// ===== ADR-137 fusion-quality scoring =====
|
||||
|
||||
#[test]
|
||||
fn ac_fuse_scored_tight_alignment_no_contradiction() {
|
||||
use super::super::fusion_quality::{EvidenceRef, FamilyId};
|
||||
let fuser = MultistaticFuser::new();
|
||||
// Two identical nodes, 1 us apart (< soft_guard 1000 us): no contradiction.
|
||||
let f0 = make_node_frame(0, 1000, 56, 1.0);
|
||||
let f1 = make_node_frame(1, 1001, 56, 1.0);
|
||||
let (fused, score) = fuser.fuse_scored(&[f0, f1], 0.85).unwrap();
|
||||
|
||||
assert_eq!(score.family_id, FamilyId::MultistaticCsi);
|
||||
assert_eq!(score.per_node_weights.len(), 2);
|
||||
assert!((score.per_node_weights.iter().sum::<f32>() - 1.0).abs() < 1e-4);
|
||||
assert_eq!(score.capture_ns, fused.timestamp_us * 1000);
|
||||
// Identical nodes → high coherence → gate evidence present.
|
||||
assert!(score
|
||||
.evidence_refs
|
||||
.iter()
|
||||
.any(|e| matches!(e, EvidenceRef::CoherenceGateThreshold { .. })));
|
||||
assert!(score
|
||||
.evidence_refs
|
||||
.iter()
|
||||
.any(|e| matches!(e, EvidenceRef::WeightEntropy { n_nodes: 2, .. })));
|
||||
assert!(!score.forces_privacy_demotion(), "tight alignment ⇒ no demotion");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ac_fuse_scored_loose_alignment_flags_soft_contradiction() {
|
||||
use super::super::fusion_quality::ContradictionFlag;
|
||||
// guard 5000 us; spread 2000 us is within guard but > soft_guard 1000 us.
|
||||
let fuser = MultistaticFuser::new();
|
||||
let f0 = make_node_frame(0, 1000, 56, 1.0);
|
||||
let f1 = make_node_frame(1, 3000, 56, 1.0);
|
||||
let (_fused, score) = fuser.fuse_scored(&[f0, f1], 0.85).unwrap();
|
||||
|
||||
assert!(score.forces_privacy_demotion(), "loose alignment ⇒ demotion");
|
||||
assert!(matches!(
|
||||
score.contradiction_flags[0],
|
||||
ContradictionFlag::TimestampMismatch { spread_ns: 2_000_000, soft_guard_ns: 1_000_000 }
|
||||
));
|
||||
// Penalized coherence is strictly below base when a contradiction fires.
|
||||
assert!(score.penalized_coherence() < score.base_coherence);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ac_fuse_scored_hard_guard_still_errors() {
|
||||
// Beyond the hard guard interval, fuse_scored errors like fuse.
|
||||
let config = MultistaticConfig {
|
||||
guard_interval_us: 100,
|
||||
..Default::default()
|
||||
};
|
||||
let fuser = MultistaticFuser::with_config(config);
|
||||
let f0 = make_node_frame(0, 0, 56, 1.0);
|
||||
let f1 = make_node_frame(1, 200, 56, 1.0);
|
||||
assert!(matches!(
|
||||
fuser.fuse_scored(&[f0, f1], 0.85),
|
||||
Err(MultistaticError::TimestampMismatch { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_frames_error() {
|
||||
let fuser = MultistaticFuser::new();
|
||||
|
|
|
|||
Loading…
Reference in New Issue