//! 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, /// 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 = (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 = (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"); } }