From 4fa3847acd6fff814bbba509a8aeffe3eb2e79d7 Mon Sep 17 00:00:00 2001 From: ruv Date: Thu, 28 May 2026 23:01:46 -0400 Subject: [PATCH] feat(signal): ADR-137 fusion quality scoring + evidence/contradiction flags (#841) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../src/ruvsense/fusion_quality.rs | 188 ++++++++++++++ .../wifi-densepose-signal/src/ruvsense/mod.rs | 6 + .../src/ruvsense/multistatic.rs | 243 +++++++++++++++--- 3 files changed, 398 insertions(+), 39 deletions(-) create mode 100644 v2/crates/wifi-densepose-signal/src/ruvsense/fusion_quality.rs diff --git a/v2/crates/wifi-densepose-signal/src/ruvsense/fusion_quality.rs b/v2/crates/wifi-densepose-signal/src/ruvsense/fusion_quality.rs new file mode 100644 index 00000000..98484989 --- /dev/null +++ b/v2/crates/wifi-densepose-signal/src/ruvsense/fusion_quality.rs @@ -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, + /// 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, + /// Concrete checks that fired in support of this fusion. + pub evidence_refs: Vec, + /// Tolerated-but-recorded disagreements. A non-empty set forces a BFLD + /// privacy demotion. + pub contradiction_flags: Vec, + /// 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); + } +} diff --git a/v2/crates/wifi-densepose-signal/src/ruvsense/mod.rs b/v2/crates/wifi-densepose-signal/src/ruvsense/mod.rs index 0f2930c7..888bea7e 100644 --- a/v2/crates/wifi-densepose-signal/src/ruvsense/mod.rs +++ b/v2/crates/wifi-densepose-signal/src/ruvsense/mod.rs @@ -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}; diff --git a/v2/crates/wifi-densepose-signal/src/ruvsense/multistatic.rs b/v2/crates/wifi-densepose-signal/src/ruvsense/multistatic.rs index 182f00a7..e0bc3c18 100644 --- a/v2/crates/wifi-densepose-signal/src/ruvsense/multistatic.rs +++ b/v2/crates/wifi-densepose-signal/src/ruvsense/multistatic.rs @@ -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, Vec, 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::().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 { + 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::().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::() - 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();