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>
This commit is contained in:
@@ -0,0 +1,464 @@
|
||||
//! Coherence Metric Computation (ADR-029 Section 2.5)
|
||||
//!
|
||||
//! Per-link coherence quantifies consistency of the current CSI observation
|
||||
//! with a running reference template. The metric is computed as a weighted
|
||||
//! mean of per-subcarrier Gaussian likelihoods:
|
||||
//!
|
||||
//! score = sum(w_i * exp(-0.5 * z_i^2)) / sum(w_i)
|
||||
//!
|
||||
//! where z_i = |current_i - reference_i| / sqrt(variance_i) and
|
||||
//! w_i = 1 / (variance_i + epsilon).
|
||||
//!
|
||||
//! Low-variance (stable) subcarriers dominate the score, making it
|
||||
//! sensitive to environmental drift while tolerant of body-motion
|
||||
//! subcarrier fluctuations.
|
||||
//!
|
||||
//! # RuVector Integration
|
||||
//!
|
||||
//! Uses `ruvector-solver` concepts for static/dynamic decomposition
|
||||
//! of the CSI signal into environmental drift and body motion components.
|
||||
|
||||
/// Errors from coherence computation.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum CoherenceError {
|
||||
/// Input vectors are empty.
|
||||
#[error("Empty input for coherence computation")]
|
||||
EmptyInput,
|
||||
|
||||
/// Length mismatch between current, reference, and variance vectors.
|
||||
#[error("Length mismatch: current={current}, reference={reference}, variance={variance}")]
|
||||
LengthMismatch {
|
||||
current: usize,
|
||||
reference: usize,
|
||||
variance: usize,
|
||||
},
|
||||
|
||||
/// Invalid decay rate (must be in (0, 1)).
|
||||
#[error("Invalid EMA decay rate: {0} (must be in (0, 1))")]
|
||||
InvalidDecay(f32),
|
||||
}
|
||||
|
||||
/// Drift profile classification for environmental changes.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DriftProfile {
|
||||
/// Environment is stable (no significant baseline drift).
|
||||
Stable,
|
||||
/// Slow linear drift (temperature, humidity changes).
|
||||
Linear,
|
||||
/// Sudden step change (door opened, furniture moved).
|
||||
StepChange,
|
||||
}
|
||||
|
||||
/// Aggregate root for coherence state.
|
||||
///
|
||||
/// Maintains a running reference template (exponential moving average of
|
||||
/// accepted CSI observations) and per-subcarrier variance estimates.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CoherenceState {
|
||||
/// Per-subcarrier reference amplitude (EMA).
|
||||
reference: Vec<f32>,
|
||||
/// Per-subcarrier variance over recent window.
|
||||
variance: Vec<f32>,
|
||||
/// EMA decay rate for reference update (default 0.95).
|
||||
decay: f32,
|
||||
/// Current coherence score (0.0-1.0).
|
||||
current_score: f32,
|
||||
/// Frames since last accepted (coherent) measurement.
|
||||
stale_count: u64,
|
||||
/// Current drift profile classification.
|
||||
drift_profile: DriftProfile,
|
||||
/// Accept threshold for coherence score.
|
||||
accept_threshold: f32,
|
||||
/// Whether the reference has been initialized.
|
||||
initialized: bool,
|
||||
}
|
||||
|
||||
impl CoherenceState {
|
||||
/// Create a new coherence state for the given number of subcarriers.
|
||||
pub fn new(n_subcarriers: usize, accept_threshold: f32) -> Self {
|
||||
Self {
|
||||
reference: vec![0.0; n_subcarriers],
|
||||
variance: vec![1.0; n_subcarriers],
|
||||
decay: 0.95,
|
||||
current_score: 1.0,
|
||||
stale_count: 0,
|
||||
drift_profile: DriftProfile::Stable,
|
||||
accept_threshold,
|
||||
initialized: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create with a custom EMA decay rate.
|
||||
pub fn with_decay(
|
||||
n_subcarriers: usize,
|
||||
accept_threshold: f32,
|
||||
decay: f32,
|
||||
) -> std::result::Result<Self, CoherenceError> {
|
||||
if decay <= 0.0 || decay >= 1.0 {
|
||||
return Err(CoherenceError::InvalidDecay(decay));
|
||||
}
|
||||
let mut state = Self::new(n_subcarriers, accept_threshold);
|
||||
state.decay = decay;
|
||||
Ok(state)
|
||||
}
|
||||
|
||||
/// Return the current coherence score.
|
||||
pub fn score(&self) -> f32 {
|
||||
self.current_score
|
||||
}
|
||||
|
||||
/// Return the number of frames since last accepted measurement.
|
||||
pub fn stale_count(&self) -> u64 {
|
||||
self.stale_count
|
||||
}
|
||||
|
||||
/// Return the current drift profile.
|
||||
pub fn drift_profile(&self) -> DriftProfile {
|
||||
self.drift_profile
|
||||
}
|
||||
|
||||
/// Return a reference to the current reference template.
|
||||
pub fn reference(&self) -> &[f32] {
|
||||
&self.reference
|
||||
}
|
||||
|
||||
/// Return a reference to the current variance estimates.
|
||||
pub fn variance(&self) -> &[f32] {
|
||||
&self.variance
|
||||
}
|
||||
|
||||
/// Return whether the reference has been initialized.
|
||||
pub fn is_initialized(&self) -> bool {
|
||||
self.initialized
|
||||
}
|
||||
|
||||
/// Initialize the reference from a calibration observation.
|
||||
///
|
||||
/// Should be called with a static-environment CSI frame before
|
||||
/// sensing begins.
|
||||
pub fn initialize(&mut self, calibration: &[f32]) {
|
||||
self.reference = calibration.to_vec();
|
||||
self.variance = vec![1.0; calibration.len()];
|
||||
self.current_score = 1.0;
|
||||
self.stale_count = 0;
|
||||
self.initialized = true;
|
||||
}
|
||||
|
||||
/// Update the coherence state with a new observation.
|
||||
///
|
||||
/// Computes the coherence score, updates the reference template if
|
||||
/// the observation is accepted, and tracks staleness.
|
||||
pub fn update(
|
||||
&mut self,
|
||||
current: &[f32],
|
||||
) -> std::result::Result<f32, CoherenceError> {
|
||||
if current.is_empty() {
|
||||
return Err(CoherenceError::EmptyInput);
|
||||
}
|
||||
|
||||
if !self.initialized {
|
||||
self.initialize(current);
|
||||
return Ok(1.0);
|
||||
}
|
||||
|
||||
if current.len() != self.reference.len() {
|
||||
return Err(CoherenceError::LengthMismatch {
|
||||
current: current.len(),
|
||||
reference: self.reference.len(),
|
||||
variance: self.variance.len(),
|
||||
});
|
||||
}
|
||||
|
||||
// Compute coherence score
|
||||
let score = coherence_score(current, &self.reference, &self.variance);
|
||||
self.current_score = score;
|
||||
|
||||
// Update reference if accepted
|
||||
if score >= self.accept_threshold {
|
||||
self.update_reference(current);
|
||||
self.stale_count = 0;
|
||||
} else {
|
||||
self.stale_count += 1;
|
||||
}
|
||||
|
||||
// Update drift profile
|
||||
self.drift_profile = classify_drift(score, self.stale_count);
|
||||
|
||||
Ok(score)
|
||||
}
|
||||
|
||||
/// Update the reference template with EMA.
|
||||
fn update_reference(&mut self, observation: &[f32]) {
|
||||
let alpha = 1.0 - self.decay;
|
||||
for i in 0..self.reference.len() {
|
||||
let old_ref = self.reference[i];
|
||||
self.reference[i] = self.decay * old_ref + alpha * observation[i];
|
||||
|
||||
// Update variance with Welford-style online estimate
|
||||
let diff = observation[i] - old_ref;
|
||||
self.variance[i] = self.decay * self.variance[i] + alpha * diff * diff;
|
||||
// Ensure variance does not collapse to zero
|
||||
if self.variance[i] < 1e-6 {
|
||||
self.variance[i] = 1e-6;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset the stale counter (e.g., after recalibration).
|
||||
pub fn reset_stale(&mut self) {
|
||||
self.stale_count = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute the coherence score between a current observation and a
|
||||
/// reference template.
|
||||
///
|
||||
/// Uses z-score per subcarrier with variance-inverse weighting:
|
||||
///
|
||||
/// score = sum(w_i * exp(-0.5 * z_i^2)) / sum(w_i)
|
||||
///
|
||||
/// where z_i = |current_i - reference_i| / sqrt(variance_i)
|
||||
/// and w_i = 1 / (variance_i + epsilon).
|
||||
///
|
||||
/// Returns a value in [0.0, 1.0] where 1.0 means perfect agreement.
|
||||
pub fn coherence_score(
|
||||
current: &[f32],
|
||||
reference: &[f32],
|
||||
variance: &[f32],
|
||||
) -> f32 {
|
||||
let n = current.len().min(reference.len()).min(variance.len());
|
||||
if n == 0 {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
let epsilon = 1e-6_f32;
|
||||
let mut weighted_sum = 0.0_f32;
|
||||
let mut weight_sum = 0.0_f32;
|
||||
|
||||
for i in 0..n {
|
||||
let var = variance[i].max(epsilon);
|
||||
let z = (current[i] - reference[i]).abs() / var.sqrt();
|
||||
let weight = 1.0 / (var + epsilon);
|
||||
let likelihood = (-0.5 * z * z).exp();
|
||||
weighted_sum += likelihood * weight;
|
||||
weight_sum += weight;
|
||||
}
|
||||
|
||||
if weight_sum < epsilon {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
(weighted_sum / weight_sum).clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
/// Classify drift profile based on coherence history.
|
||||
fn classify_drift(score: f32, stale_count: u64) -> DriftProfile {
|
||||
if score >= 0.85 {
|
||||
DriftProfile::Stable
|
||||
} else if stale_count < 10 {
|
||||
// Brief coherence loss -> likely step change
|
||||
DriftProfile::StepChange
|
||||
} else {
|
||||
// Extended low coherence -> linear drift
|
||||
DriftProfile::Linear
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute per-subcarrier z-scores for diagnostics.
|
||||
///
|
||||
/// Returns a vector of z-scores, one per subcarrier.
|
||||
pub fn per_subcarrier_zscores(
|
||||
current: &[f32],
|
||||
reference: &[f32],
|
||||
variance: &[f32],
|
||||
) -> Vec<f32> {
|
||||
let n = current.len().min(reference.len()).min(variance.len());
|
||||
(0..n)
|
||||
.map(|i| {
|
||||
let var = variance[i].max(1e-6);
|
||||
(current[i] - reference[i]).abs() / var.sqrt()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Identify subcarriers that are outliers (z-score above threshold).
|
||||
///
|
||||
/// Returns indices of outlier subcarriers.
|
||||
pub fn outlier_subcarriers(
|
||||
current: &[f32],
|
||||
reference: &[f32],
|
||||
variance: &[f32],
|
||||
z_threshold: f32,
|
||||
) -> Vec<usize> {
|
||||
let z_scores = per_subcarrier_zscores(current, reference, variance);
|
||||
z_scores
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, &z)| z > z_threshold)
|
||||
.map(|(i, _)| i)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn perfect_coherence() {
|
||||
let current = vec![1.0, 2.0, 3.0, 4.0];
|
||||
let reference = vec![1.0, 2.0, 3.0, 4.0];
|
||||
let variance = vec![0.01, 0.01, 0.01, 0.01];
|
||||
let score = coherence_score(¤t, &reference, &variance);
|
||||
assert!((score - 1.0).abs() < 0.01, "Perfect match should give ~1.0, got {}", score);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_coherence_large_deviation() {
|
||||
let current = vec![100.0, 200.0, 300.0];
|
||||
let reference = vec![0.0, 0.0, 0.0];
|
||||
let variance = vec![0.001, 0.001, 0.001];
|
||||
let score = coherence_score(¤t, &reference, &variance);
|
||||
assert!(score < 0.01, "Large deviation should give ~0.0, got {}", score);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_input_gives_zero() {
|
||||
assert_eq!(coherence_score(&[], &[], &[]), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_initialize_and_score() {
|
||||
let mut state = CoherenceState::new(4, 0.85);
|
||||
assert!(!state.is_initialized());
|
||||
state.initialize(&[1.0, 2.0, 3.0, 4.0]);
|
||||
assert!(state.is_initialized());
|
||||
assert!((state.score() - 1.0).abs() < f32::EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_update_accepted() {
|
||||
let mut state = CoherenceState::new(4, 0.5);
|
||||
state.initialize(&[1.0, 2.0, 3.0, 4.0]);
|
||||
let score = state.update(&[1.01, 2.01, 3.01, 4.01]).unwrap();
|
||||
assert!(score > 0.8, "Small deviation should be accepted, got {}", score);
|
||||
assert_eq!(state.stale_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_update_rejected() {
|
||||
let mut state = CoherenceState::new(4, 0.99);
|
||||
state.initialize(&[1.0, 2.0, 3.0, 4.0]);
|
||||
let _ = state.update(&[10.0, 20.0, 30.0, 40.0]).unwrap();
|
||||
assert!(state.stale_count() > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_initialize_on_first_update() {
|
||||
let mut state = CoherenceState::new(3, 0.85);
|
||||
let score = state.update(&[5.0, 6.0, 7.0]).unwrap();
|
||||
assert!((score - 1.0).abs() < f32::EPSILON);
|
||||
assert!(state.is_initialized());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn length_mismatch_error() {
|
||||
let mut state = CoherenceState::new(4, 0.85);
|
||||
state.initialize(&[1.0, 2.0, 3.0, 4.0]);
|
||||
let result = state.update(&[1.0, 2.0]);
|
||||
assert!(matches!(result, Err(CoherenceError::LengthMismatch { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_update_error() {
|
||||
let mut state = CoherenceState::new(4, 0.85);
|
||||
state.initialize(&[1.0, 2.0, 3.0, 4.0]);
|
||||
assert!(matches!(state.update(&[]), Err(CoherenceError::EmptyInput)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_decay_error() {
|
||||
assert!(matches!(
|
||||
CoherenceState::with_decay(4, 0.85, 0.0),
|
||||
Err(CoherenceError::InvalidDecay(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
CoherenceState::with_decay(4, 0.85, 1.0),
|
||||
Err(CoherenceError::InvalidDecay(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
CoherenceState::with_decay(4, 0.85, -0.5),
|
||||
Err(CoherenceError::InvalidDecay(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_decay() {
|
||||
let state = CoherenceState::with_decay(4, 0.85, 0.9).unwrap();
|
||||
assert!((state.score() - 1.0).abs() < f32::EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drift_classification_stable() {
|
||||
assert_eq!(classify_drift(0.9, 0), DriftProfile::Stable);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drift_classification_step_change() {
|
||||
assert_eq!(classify_drift(0.3, 5), DriftProfile::StepChange);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drift_classification_linear() {
|
||||
assert_eq!(classify_drift(0.3, 20), DriftProfile::Linear);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn per_subcarrier_zscores_correct() {
|
||||
let current = vec![2.0, 4.0];
|
||||
let reference = vec![1.0, 2.0];
|
||||
let variance = vec![1.0, 4.0];
|
||||
let z = per_subcarrier_zscores(¤t, &reference, &variance);
|
||||
assert_eq!(z.len(), 2);
|
||||
assert!((z[0] - 1.0).abs() < 1e-5);
|
||||
assert!((z[1] - 1.0).abs() < 1e-5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outlier_subcarriers_detected() {
|
||||
let current = vec![1.0, 100.0, 1.0, 200.0];
|
||||
let reference = vec![1.0, 1.0, 1.0, 1.0];
|
||||
let variance = vec![1.0, 1.0, 1.0, 1.0];
|
||||
let outliers = outlier_subcarriers(¤t, &reference, &variance, 3.0);
|
||||
assert!(outliers.contains(&1));
|
||||
assert!(outliers.contains(&3));
|
||||
assert!(!outliers.contains(&0));
|
||||
assert!(!outliers.contains(&2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_stale_counter() {
|
||||
let mut state = CoherenceState::new(4, 0.99);
|
||||
state.initialize(&[1.0, 2.0, 3.0, 4.0]);
|
||||
let _ = state.update(&[10.0, 20.0, 30.0, 40.0]).unwrap();
|
||||
assert!(state.stale_count() > 0);
|
||||
state.reset_stale();
|
||||
assert_eq!(state.stale_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_and_variance_accessible() {
|
||||
let state = CoherenceState::new(3, 0.85);
|
||||
assert_eq!(state.reference().len(), 3);
|
||||
assert_eq!(state.variance().len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coherence_score_with_high_variance() {
|
||||
let current = vec![5.0, 6.0, 7.0];
|
||||
let reference = vec![1.0, 2.0, 3.0];
|
||||
let variance = vec![100.0, 100.0, 100.0]; // high variance
|
||||
let score = coherence_score(¤t, &reference, &variance);
|
||||
// With high variance, deviation is relatively small
|
||||
assert!(score > 0.5, "High variance should tolerate deviation, got {}", score);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user