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>
384 lines
12 KiB
Rust
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");
|
|
}
|
|
}
|