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:
ruv 2026-05-28 23:01:46 -04:00
parent 11f89727f1
commit 4fa3847acd
3 changed files with 398 additions and 39 deletions

View File

@ -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);
}
}

View File

@ -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};

View File

@ -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(&amplitudes, 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();