Files
wifi-densepose/rust-port/wifi-densepose-rs/crates/wifi-densepose-signal/src/ruvsense/coherence_gate.rs
ruv 37b54d649b feat: implement ADR-029/030/031 — RuvSense multistatic sensing + field model + RuView fusion
12,126 lines of new Rust code across 22 modules with 285 tests:

ADR-029 RuvSense Core (signal crate, 10 modules):
- multiband.rs: Multi-band CSI frame fusion from channel hopping
- phase_align.rs: Cross-channel LO phase rotation correction
- multistatic.rs: Attention-weighted cross-node viewpoint fusion
- coherence.rs: Z-score per-subcarrier coherence scoring
- coherence_gate.rs: Accept/PredictOnly/Reject/Recalibrate gating
- pose_tracker.rs: 17-keypoint Kalman tracker with re-ID
- mod.rs: Pipeline orchestrator

ADR-030 Persistent Field Model (signal crate, 7 modules):
- field_model.rs: SVD-based room eigenstructure, Welford stats
- tomography.rs: Coarse RF tomography from link attenuations (ISTA)
- longitudinal.rs: Personal baseline drift detection over days
- intention.rs: Pre-movement prediction (200-500ms lead signals)
- cross_room.rs: Cross-room identity continuity
- gesture.rs: Gesture classification via DTW template matching
- adversarial.rs: Physically impossible signal detection

ADR-031 RuView (ruvector crate, 5 modules):
- attention.rs: Scaled dot-product with geometric bias
- geometry.rs: Geometric Diversity Index, Cramer-Rao bounds
- coherence.rs: Phase phasor coherence gating
- fusion.rs: MultistaticArray aggregate, fusion orchestrator
- mod.rs: Module exports

Training & Hardware:
- ruview_metrics.rs: 3-metric acceptance test (PCK/OKS, MOTA, vitals)
- esp32/tdm.rs: TDM sensing protocol, sync beacons, drift compensation
- Firmware: channel hopping, NDP injection, NVS config extensions

Security fixes:
- field_model.rs: saturating_sub prevents timestamp underflow
- longitudinal.rs: FIFO eviction note for bounded buffer

README updated with RuvSense section, new feature badges, changelog v3.1.0.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-01 21:39:02 -05:00

366 lines
12 KiB
Rust

//! Coherence-Gated Update Policy (ADR-029 Section 2.6)
//!
//! Applies a threshold-based gating rule to the coherence score, producing
//! a `GateDecision` that controls downstream Kalman filter updates:
//!
//! - **Accept** (coherence > 0.85): Full measurement update with nominal noise.
//! - **PredictOnly** (0.5 < coherence < 0.85): Kalman predict step only,
//! measurement noise inflated 3x.
//! - **Reject** (coherence < 0.5): Discard measurement entirely.
//! - **Recalibrate** (>10s continuous low coherence): Trigger SONA/AETHER
//! recalibration pipeline.
//!
//! The gate operates on the coherence score produced by the `coherence` module
//! and the stale frame counter from `CoherenceState`.
/// Gate decision controlling Kalman filter update behavior.
#[derive(Debug, Clone, PartialEq)]
pub enum GateDecision {
/// Coherence is high. Proceed with full Kalman measurement update.
/// Contains the inflated measurement noise multiplier (1.0 = nominal).
Accept {
/// Measurement noise multiplier (1.0 for full accept).
noise_multiplier: f32,
},
/// Coherence is moderate. Run Kalman predict only (no measurement update).
/// Measurement noise would be inflated 3x if used.
PredictOnly,
/// Coherence is low. Reject this measurement entirely.
Reject,
/// Prolonged low coherence. Trigger environmental recalibration.
/// The pipeline should freeze output at last known good pose and
/// begin the SONA/AETHER TTT adaptation cycle.
Recalibrate {
/// Duration of low coherence in frames.
stale_frames: u64,
},
}
impl GateDecision {
/// Returns true if this decision allows a measurement update.
pub fn allows_update(&self) -> bool {
matches!(self, GateDecision::Accept { .. })
}
/// Returns true if this is a reject or recalibrate decision.
pub fn is_rejected(&self) -> bool {
matches!(self, GateDecision::Reject | GateDecision::Recalibrate { .. })
}
/// Returns the noise multiplier for accepted decisions, or None otherwise.
pub fn noise_multiplier(&self) -> Option<f32> {
match self {
GateDecision::Accept { noise_multiplier } => Some(*noise_multiplier),
_ => None,
}
}
}
/// Configuration for the gate policy thresholds.
#[derive(Debug, Clone)]
pub struct GatePolicyConfig {
/// Coherence threshold above which measurements are accepted.
pub accept_threshold: f32,
/// Coherence threshold below which measurements are rejected.
pub reject_threshold: f32,
/// Maximum stale frames before triggering recalibration.
pub max_stale_frames: u64,
/// Noise inflation factor for PredictOnly zone.
pub predict_only_noise: f32,
/// Whether to use adaptive thresholds based on drift profile.
pub adaptive: bool,
}
impl Default for GatePolicyConfig {
fn default() -> Self {
Self {
accept_threshold: 0.85,
reject_threshold: 0.5,
max_stale_frames: 200, // 10s at 20Hz
predict_only_noise: 3.0,
adaptive: false,
}
}
}
/// Gate policy that maps coherence scores to gate decisions.
#[derive(Debug, Clone)]
pub struct GatePolicy {
/// Accept threshold.
accept_threshold: f32,
/// Reject threshold.
reject_threshold: f32,
/// Maximum stale frames before recalibration.
max_stale_frames: u64,
/// Noise inflation for predict-only zone.
predict_only_noise: f32,
/// Running count of consecutive rejected/predict-only frames.
consecutive_low: u64,
/// Last decision for tracking transitions.
last_decision: Option<GateDecision>,
}
impl GatePolicy {
/// Create a gate policy with the given thresholds.
pub fn new(accept: f32, reject: f32, max_stale: u64) -> Self {
Self {
accept_threshold: accept,
reject_threshold: reject,
max_stale_frames: max_stale,
predict_only_noise: 3.0,
consecutive_low: 0,
last_decision: None,
}
}
/// Create a gate policy from a configuration.
pub fn from_config(config: &GatePolicyConfig) -> Self {
Self {
accept_threshold: config.accept_threshold,
reject_threshold: config.reject_threshold,
max_stale_frames: config.max_stale_frames,
predict_only_noise: config.predict_only_noise,
consecutive_low: 0,
last_decision: None,
}
}
/// Evaluate the gate decision for a given coherence score and stale count.
pub fn evaluate(&mut self, coherence_score: f32, stale_count: u64) -> GateDecision {
let decision = if stale_count >= self.max_stale_frames {
GateDecision::Recalibrate {
stale_frames: stale_count,
}
} else if coherence_score >= self.accept_threshold {
self.consecutive_low = 0;
GateDecision::Accept {
noise_multiplier: 1.0,
}
} else if coherence_score >= self.reject_threshold {
self.consecutive_low += 1;
GateDecision::PredictOnly
} else {
self.consecutive_low += 1;
GateDecision::Reject
};
self.last_decision = Some(decision.clone());
decision
}
/// Return the last gate decision, if any.
pub fn last_decision(&self) -> Option<&GateDecision> {
self.last_decision.as_ref()
}
/// Return the current count of consecutive low-coherence frames.
pub fn consecutive_low_count(&self) -> u64 {
self.consecutive_low
}
/// Return the accept threshold.
pub fn accept_threshold(&self) -> f32 {
self.accept_threshold
}
/// Return the reject threshold.
pub fn reject_threshold(&self) -> f32 {
self.reject_threshold
}
/// Reset the policy state (e.g., after recalibration).
pub fn reset(&mut self) {
self.consecutive_low = 0;
self.last_decision = None;
}
}
impl Default for GatePolicy {
fn default() -> Self {
Self::from_config(&GatePolicyConfig::default())
}
}
/// Compute an adaptive noise multiplier for the PredictOnly zone.
///
/// As coherence drops from accept to reject threshold, the noise
/// multiplier increases from 1.0 to `max_inflation`.
pub fn adaptive_noise_multiplier(
coherence: f32,
accept: f32,
reject: f32,
max_inflation: f32,
) -> f32 {
if coherence >= accept {
return 1.0;
}
if coherence <= reject {
return max_inflation;
}
let range = accept - reject;
if range < 1e-6 {
return max_inflation;
}
let t = (accept - coherence) / range;
1.0 + t * (max_inflation - 1.0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn accept_high_coherence() {
let mut gate = GatePolicy::new(0.85, 0.5, 200);
let decision = gate.evaluate(0.95, 0);
assert!(matches!(decision, GateDecision::Accept { noise_multiplier } if (noise_multiplier - 1.0).abs() < f32::EPSILON));
assert!(decision.allows_update());
assert!(!decision.is_rejected());
}
#[test]
fn predict_only_moderate_coherence() {
let mut gate = GatePolicy::new(0.85, 0.5, 200);
let decision = gate.evaluate(0.7, 0);
assert!(matches!(decision, GateDecision::PredictOnly));
assert!(!decision.allows_update());
assert!(!decision.is_rejected());
}
#[test]
fn reject_low_coherence() {
let mut gate = GatePolicy::new(0.85, 0.5, 200);
let decision = gate.evaluate(0.3, 0);
assert!(matches!(decision, GateDecision::Reject));
assert!(!decision.allows_update());
assert!(decision.is_rejected());
}
#[test]
fn recalibrate_after_stale_timeout() {
let mut gate = GatePolicy::new(0.85, 0.5, 200);
let decision = gate.evaluate(0.3, 200);
assert!(matches!(decision, GateDecision::Recalibrate { stale_frames: 200 }));
assert!(decision.is_rejected());
}
#[test]
fn recalibrate_overrides_accept() {
let mut gate = GatePolicy::new(0.85, 0.5, 100);
// Even with high coherence, stale count triggers recalibration
let decision = gate.evaluate(0.95, 100);
assert!(matches!(decision, GateDecision::Recalibrate { .. }));
}
#[test]
fn consecutive_low_counter() {
let mut gate = GatePolicy::new(0.85, 0.5, 200);
gate.evaluate(0.3, 0);
assert_eq!(gate.consecutive_low_count(), 1);
gate.evaluate(0.6, 0);
assert_eq!(gate.consecutive_low_count(), 2);
gate.evaluate(0.9, 0); // accepted -> resets
assert_eq!(gate.consecutive_low_count(), 0);
}
#[test]
fn last_decision_tracked() {
let mut gate = GatePolicy::new(0.85, 0.5, 200);
assert!(gate.last_decision().is_none());
gate.evaluate(0.9, 0);
assert!(gate.last_decision().is_some());
}
#[test]
fn reset_clears_state() {
let mut gate = GatePolicy::new(0.85, 0.5, 200);
gate.evaluate(0.3, 0);
gate.evaluate(0.3, 0);
gate.reset();
assert_eq!(gate.consecutive_low_count(), 0);
assert!(gate.last_decision().is_none());
}
#[test]
fn noise_multiplier_accessor() {
let accept = GateDecision::Accept { noise_multiplier: 2.5 };
assert_eq!(accept.noise_multiplier(), Some(2.5));
let reject = GateDecision::Reject;
assert_eq!(reject.noise_multiplier(), None);
let predict = GateDecision::PredictOnly;
assert_eq!(predict.noise_multiplier(), None);
}
#[test]
fn adaptive_noise_at_boundaries() {
assert!((adaptive_noise_multiplier(0.9, 0.85, 0.5, 3.0) - 1.0).abs() < f32::EPSILON);
assert!((adaptive_noise_multiplier(0.3, 0.85, 0.5, 3.0) - 3.0).abs() < f32::EPSILON);
}
#[test]
fn adaptive_noise_midpoint() {
let mid = adaptive_noise_multiplier(0.675, 0.85, 0.5, 3.0);
assert!((mid - 2.0).abs() < 0.01, "Midpoint noise should be ~2.0, got {}", mid);
}
#[test]
fn adaptive_noise_tiny_range() {
// When accept == reject, coherence >= accept returns 1.0
let val = adaptive_noise_multiplier(0.5, 0.5, 0.5, 3.0);
assert!((val - 1.0).abs() < f32::EPSILON);
// Below both thresholds should return max_inflation
let val2 = adaptive_noise_multiplier(0.4, 0.5, 0.5, 3.0);
assert!((val2 - 3.0).abs() < f32::EPSILON);
}
#[test]
fn default_config_values() {
let cfg = GatePolicyConfig::default();
assert!((cfg.accept_threshold - 0.85).abs() < f32::EPSILON);
assert!((cfg.reject_threshold - 0.5).abs() < f32::EPSILON);
assert_eq!(cfg.max_stale_frames, 200);
assert!((cfg.predict_only_noise - 3.0).abs() < f32::EPSILON);
assert!(!cfg.adaptive);
}
#[test]
fn from_config_construction() {
let cfg = GatePolicyConfig {
accept_threshold: 0.9,
reject_threshold: 0.4,
max_stale_frames: 100,
predict_only_noise: 5.0,
adaptive: true,
};
let gate = GatePolicy::from_config(&cfg);
assert!((gate.accept_threshold() - 0.9).abs() < f32::EPSILON);
assert!((gate.reject_threshold() - 0.4).abs() < f32::EPSILON);
}
#[test]
fn boundary_at_exact_accept_threshold() {
let mut gate = GatePolicy::new(0.85, 0.5, 200);
let decision = gate.evaluate(0.85, 0);
assert!(matches!(decision, GateDecision::Accept { .. }));
}
#[test]
fn boundary_at_exact_reject_threshold() {
let mut gate = GatePolicy::new(0.85, 0.5, 200);
let decision = gate.evaluate(0.5, 0);
assert!(matches!(decision, GateDecision::PredictOnly));
}
#[test]
fn boundary_just_below_reject_threshold() {
let mut gate = GatePolicy::new(0.85, 0.5, 200);
let decision = gate.evaluate(0.499, 0);
assert!(matches!(decision, GateDecision::Reject));
}
}