Files
wifi-densepose/rust-port/wifi-densepose-rs/crates/wifi-densepose-ruvector/src/viewpoint/coherence.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

384 lines
12 KiB
Rust

//! Coherence gating for environment stability (ADR-031).
//!
//! Phase coherence determines whether the wireless environment is sufficiently
//! stable for a model update. When multipath conditions change rapidly (e.g.
//! doors opening, people entering), phase becomes incoherent and fusion
//! quality degrades. The coherence gate prevents model updates during these
//! transient periods.
//!
//! The core computation is the complex mean of unit phasors:
//!
//! ```text
//! coherence = |mean(exp(j * delta_phi))|
//! = sqrt((mean(cos(delta_phi)))^2 + (mean(sin(delta_phi)))^2)
//! ```
//!
//! A coherence value near 1.0 indicates consistent phase; near 0.0 indicates
//! random phase (incoherent environment).
// ---------------------------------------------------------------------------
// CoherenceState
// ---------------------------------------------------------------------------
/// Rolling coherence state tracking phase consistency over a sliding window.
///
/// Maintains a circular buffer of phase differences and incrementally updates
/// the coherence estimate as new measurements arrive.
#[derive(Debug, Clone)]
pub struct CoherenceState {
/// Circular buffer of phase differences (radians).
phase_diffs: Vec<f32>,
/// Write position in the circular buffer.
write_pos: usize,
/// Number of valid entries in the buffer (may be less than capacity
/// during warm-up).
count: usize,
/// Running sum of cos(phase_diff).
sum_cos: f64,
/// Running sum of sin(phase_diff).
sum_sin: f64,
}
impl CoherenceState {
/// Create a new coherence state with the given window size.
///
/// # Arguments
///
/// - `window_size`: number of phase measurements to retain. Larger windows
/// are more stable but respond more slowly to environment changes.
/// Must be at least 1.
pub fn new(window_size: usize) -> Self {
let size = window_size.max(1);
CoherenceState {
phase_diffs: vec![0.0; size],
write_pos: 0,
count: 0,
sum_cos: 0.0,
sum_sin: 0.0,
}
}
/// Push a new phase difference measurement into the rolling window.
///
/// If the buffer is full, the oldest measurement is evicted and its
/// contribution is subtracted from the running sums.
pub fn push(&mut self, phase_diff: f32) {
let cap = self.phase_diffs.len();
// If buffer is full, subtract the evicted entry.
if self.count == cap {
let old = self.phase_diffs[self.write_pos];
self.sum_cos -= old.cos() as f64;
self.sum_sin -= old.sin() as f64;
} else {
self.count += 1;
}
// Write new entry.
self.phase_diffs[self.write_pos] = phase_diff;
self.sum_cos += phase_diff.cos() as f64;
self.sum_sin += phase_diff.sin() as f64;
self.write_pos = (self.write_pos + 1) % cap;
}
/// Current coherence value in `[0, 1]`.
///
/// Returns 0.0 if no measurements have been pushed yet.
pub fn coherence(&self) -> f32 {
if self.count == 0 {
return 0.0;
}
let n = self.count as f64;
let mean_cos = self.sum_cos / n;
let mean_sin = self.sum_sin / n;
(mean_cos * mean_cos + mean_sin * mean_sin).sqrt() as f32
}
/// Number of measurements currently in the buffer.
pub fn len(&self) -> usize {
self.count
}
/// Returns `true` if no measurements have been pushed.
pub fn is_empty(&self) -> bool {
self.count == 0
}
/// Window capacity.
pub fn capacity(&self) -> usize {
self.phase_diffs.len()
}
/// Reset the coherence state, clearing all measurements.
pub fn reset(&mut self) {
self.write_pos = 0;
self.count = 0;
self.sum_cos = 0.0;
self.sum_sin = 0.0;
}
}
// ---------------------------------------------------------------------------
// CoherenceGate
// ---------------------------------------------------------------------------
/// Coherence gate that controls model updates based on phase stability.
///
/// Only allows model updates when the coherence exceeds a configurable
/// threshold. Provides hysteresis to avoid rapid gate toggling near the
/// threshold boundary.
#[derive(Debug, Clone)]
pub struct CoherenceGate {
/// Coherence threshold for opening the gate.
pub threshold: f32,
/// Hysteresis band: gate opens at `threshold` and closes at
/// `threshold - hysteresis`.
pub hysteresis: f32,
/// Current gate state: `true` = open (updates allowed).
gate_open: bool,
/// Total number of gate evaluations.
total_evaluations: u64,
/// Number of times the gate was open.
open_count: u64,
}
impl CoherenceGate {
/// Create a new coherence gate with the given threshold.
///
/// # Arguments
///
/// - `threshold`: coherence level required for the gate to open (typically 0.7).
/// - `hysteresis`: band below the threshold where the gate stays in its
/// current state (typically 0.05).
pub fn new(threshold: f32, hysteresis: f32) -> Self {
CoherenceGate {
threshold: threshold.clamp(0.0, 1.0),
hysteresis: hysteresis.clamp(0.0, threshold),
gate_open: false,
total_evaluations: 0,
open_count: 0,
}
}
/// Create a gate with default parameters (threshold=0.7, hysteresis=0.05).
pub fn default_params() -> Self {
Self::new(0.7, 0.05)
}
/// Evaluate the gate against the current coherence value.
///
/// Returns `true` if the gate is open (model update allowed).
pub fn evaluate(&mut self, coherence: f32) -> bool {
self.total_evaluations += 1;
if self.gate_open {
// Gate is open: close if coherence drops below threshold - hysteresis.
if coherence < self.threshold - self.hysteresis {
self.gate_open = false;
}
} else {
// Gate is closed: open if coherence exceeds threshold.
if coherence >= self.threshold {
self.gate_open = true;
}
}
if self.gate_open {
self.open_count += 1;
}
self.gate_open
}
/// Whether the gate is currently open.
pub fn is_open(&self) -> bool {
self.gate_open
}
/// Fraction of evaluations where the gate was open.
pub fn duty_cycle(&self) -> f32 {
if self.total_evaluations == 0 {
return 0.0;
}
self.open_count as f32 / self.total_evaluations as f32
}
/// Reset the gate state and counters.
pub fn reset(&mut self) {
self.gate_open = false;
self.total_evaluations = 0;
self.open_count = 0;
}
}
/// Stateless coherence gate function matching the ADR-031 specification.
///
/// Computes the complex mean of unit phasors from the given phase differences
/// and returns `true` when coherence exceeds the threshold.
///
/// # Arguments
///
/// - `phase_diffs`: delta-phi over T recent frames (radians).
/// - `threshold`: coherence threshold (typically 0.7).
///
/// # Returns
///
/// `true` if the phase coherence exceeds the threshold.
pub fn coherence_gate(phase_diffs: &[f32], threshold: f32) -> bool {
if phase_diffs.is_empty() {
return false;
}
let (sum_cos, sum_sin) = phase_diffs
.iter()
.fold((0.0_f32, 0.0_f32), |(c, s), &dp| {
(c + dp.cos(), s + dp.sin())
});
let n = phase_diffs.len() as f32;
let coherence = ((sum_cos / n).powi(2) + (sum_sin / n).powi(2)).sqrt();
coherence > threshold
}
/// Compute the raw coherence value from phase differences.
///
/// Returns a value in `[0, 1]` where 1.0 = perfectly coherent phase.
pub fn compute_coherence(phase_diffs: &[f32]) -> f32 {
if phase_diffs.is_empty() {
return 0.0;
}
let (sum_cos, sum_sin) = phase_diffs
.iter()
.fold((0.0_f32, 0.0_f32), |(c, s), &dp| {
(c + dp.cos(), s + dp.sin())
});
let n = phase_diffs.len() as f32;
((sum_cos / n).powi(2) + (sum_sin / n).powi(2)).sqrt()
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn coherent_phase_returns_high_value() {
// All phase diffs are the same -> coherence ~ 1.0
let phase_diffs = vec![0.5_f32; 100];
let c = compute_coherence(&phase_diffs);
assert!(c > 0.99, "identical phases should give coherence ~ 1.0, got {c}");
}
#[test]
fn random_phase_returns_low_value() {
// Uniformly spaced phases around the circle -> coherence ~ 0.0
let n = 1000;
let phase_diffs: Vec<f32> = (0..n)
.map(|i| 2.0 * std::f32::consts::PI * i as f32 / n as f32)
.collect();
let c = compute_coherence(&phase_diffs);
assert!(c < 0.05, "uniformly spread phases should give coherence ~ 0.0, got {c}");
}
#[test]
fn coherence_gate_opens_above_threshold() {
let coherent = vec![0.3_f32; 50]; // same phase -> high coherence
assert!(coherence_gate(&coherent, 0.7));
}
#[test]
fn coherence_gate_closed_below_threshold() {
let n = 500;
let incoherent: Vec<f32> = (0..n)
.map(|i| 2.0 * std::f32::consts::PI * i as f32 / n as f32)
.collect();
assert!(!coherence_gate(&incoherent, 0.7));
}
#[test]
fn coherence_gate_empty_returns_false() {
assert!(!coherence_gate(&[], 0.5));
}
#[test]
fn coherence_state_rolling_window() {
let mut state = CoherenceState::new(10);
// Push coherent measurements.
for _ in 0..10 {
state.push(1.0);
}
let c1 = state.coherence();
assert!(c1 > 0.9, "coherent window should give high coherence");
// Push incoherent measurements to replace the window.
for i in 0..10 {
state.push(i as f32 * 0.628);
}
let c2 = state.coherence();
assert!(c2 < c1, "incoherent updates should reduce coherence");
}
#[test]
fn coherence_state_empty_returns_zero() {
let state = CoherenceState::new(10);
assert_eq!(state.coherence(), 0.0);
assert!(state.is_empty());
}
#[test]
fn gate_hysteresis_prevents_toggling() {
let mut gate = CoherenceGate::new(0.7, 0.1);
// Open the gate.
assert!(gate.evaluate(0.8));
assert!(gate.is_open());
// Coherence drops to 0.65 (below threshold but within hysteresis band).
assert!(gate.evaluate(0.65));
assert!(gate.is_open(), "gate should stay open within hysteresis band");
// Coherence drops below hysteresis boundary (0.7 - 0.1 = 0.6).
assert!(!gate.evaluate(0.55));
assert!(!gate.is_open(), "gate should close below hysteresis boundary");
}
#[test]
fn gate_duty_cycle_tracks_correctly() {
let mut gate = CoherenceGate::new(0.5, 0.0);
gate.evaluate(0.6); // open
gate.evaluate(0.6); // open
gate.evaluate(0.3); // close
gate.evaluate(0.3); // close
let duty = gate.duty_cycle();
assert!(
(duty - 0.5).abs() < 1e-5,
"duty cycle should be 0.5, got {duty}"
);
}
#[test]
fn gate_reset_clears_state() {
let mut gate = CoherenceGate::new(0.5, 0.0);
gate.evaluate(0.6);
assert!(gate.is_open());
gate.reset();
assert!(!gate.is_open());
assert_eq!(gate.duty_cycle(), 0.0);
}
#[test]
fn coherence_state_push_and_len() {
let mut state = CoherenceState::new(5);
assert_eq!(state.len(), 0);
state.push(0.1);
state.push(0.2);
assert_eq!(state.len(), 2);
// Fill past capacity.
for i in 0..10 {
state.push(i as f32 * 0.1);
}
assert_eq!(state.len(), 5, "count should be capped at window size");
}
}