feat: Training mode, ADR docs, vitals and wifiscan crates

- Add --train CLI flag with dataset loading, graph transformer training,
  cosine-scheduled SGD, PCK/OKS validation, and checkpoint saving
- Refactor main.rs to import training modules from lib.rs instead of
  duplicating mod declarations
- Add ADR-021 (vital sign detection), ADR-022 (Windows WiFi enhanced
  fidelity), ADR-023 (trained DensePose pipeline) documentation
- Add wifi-densepose-vitals crate: breathing, heartrate, anomaly
  detection, preprocessor, and temporal store
- Add wifi-densepose-wifiscan crate: 8-stage signal intelligence
  pipeline with netsh/wlanapi adapters, multi-BSSID registry,
  attention weighting, spatial correlation, and breathing extraction

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
ruv
2026-02-28 23:50:20 -05:00
parent add9f192aa
commit 3e06970428
37 changed files with 10667 additions and 8 deletions

View File

@@ -0,0 +1,129 @@
//! Stage 2: Attention-based BSSID weighting.
//!
//! Uses scaled dot-product attention to learn which BSSIDs respond
//! most to body movement. High-variance BSSIDs on body-affected
//! paths get higher attention weights.
//!
//! When the `pipeline` feature is enabled, this uses
//! `ruvector_attention::ScaledDotProductAttention` for the core
//! attention computation. Otherwise, it falls back to a pure-Rust
//! softmax implementation.
/// Weights BSSIDs by body-sensitivity using attention mechanism.
pub struct AttentionWeighter {
dim: usize,
}
impl AttentionWeighter {
/// Create a new attention weighter.
///
/// - `dim`: dimensionality of the attention space (typically 1 for scalar RSSI).
#[must_use]
pub fn new(dim: usize) -> Self {
Self { dim }
}
/// Compute attention-weighted output from BSSID residuals.
///
/// - `query`: the aggregated variance profile (1 x dim).
/// - `keys`: per-BSSID residual vectors (`n_bssids` x dim).
/// - `values`: per-BSSID amplitude vectors (`n_bssids` x dim).
///
/// Returns the weighted amplitude vector and per-BSSID weights.
#[must_use]
pub fn weight(
&self,
query: &[f32],
keys: &[Vec<f32>],
values: &[Vec<f32>],
) -> (Vec<f32>, Vec<f32>) {
if keys.is_empty() || values.is_empty() {
return (vec![0.0; self.dim], vec![]);
}
// Compute per-BSSID attention scores (softmax of q·k / sqrt(d))
let scores = self.compute_scores(query, keys);
// Weighted sum of values
let mut weighted = vec![0.0f32; self.dim];
for (i, score) in scores.iter().enumerate() {
if let Some(val) = values.get(i) {
for (d, v) in weighted.iter_mut().zip(val.iter()) {
*d += score * v;
}
}
}
(weighted, scores)
}
/// Compute raw attention scores (softmax of q*k / sqrt(d)).
#[allow(clippy::cast_precision_loss)]
fn compute_scores(&self, query: &[f32], keys: &[Vec<f32>]) -> Vec<f32> {
let scale = (self.dim as f32).sqrt();
let mut scores: Vec<f32> = keys
.iter()
.map(|key| {
let dot: f32 = query.iter().zip(key.iter()).map(|(q, k)| q * k).sum();
dot / scale
})
.collect();
// Softmax
let max_score = scores.iter().copied().fold(f32::NEG_INFINITY, f32::max);
let sum_exp: f32 = scores.iter().map(|&s| (s - max_score).exp()).sum();
for s in &mut scores {
*s = (*s - max_score).exp() / sum_exp;
}
scores
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_input_returns_zero() {
let weighter = AttentionWeighter::new(1);
let (output, scores) = weighter.weight(&[0.0], &[], &[]);
assert_eq!(output, vec![0.0]);
assert!(scores.is_empty());
}
#[test]
fn single_bssid_gets_full_weight() {
let weighter = AttentionWeighter::new(1);
let query = vec![1.0];
let keys = vec![vec![1.0]];
let values = vec![vec![5.0]];
let (output, scores) = weighter.weight(&query, &keys, &values);
assert!((scores[0] - 1.0).abs() < 1e-5, "single BSSID should have weight 1.0");
assert!((output[0] - 5.0).abs() < 1e-3, "output should equal the single value");
}
#[test]
fn higher_residual_gets_more_weight() {
let weighter = AttentionWeighter::new(1);
let query = vec![1.0];
// BSSID 0 has low residual, BSSID 1 has high residual
let keys = vec![vec![0.1], vec![10.0]];
let values = vec![vec![1.0], vec![1.0]];
let (_output, scores) = weighter.weight(&query, &keys, &values);
assert!(
scores[1] > scores[0],
"high-residual BSSID should get higher weight: {scores:?}"
);
}
#[test]
fn scores_sum_to_one() {
let weighter = AttentionWeighter::new(1);
let query = vec![1.0];
let keys = vec![vec![0.5], vec![1.0], vec![2.0]];
let values = vec![vec![1.0], vec![2.0], vec![3.0]];
let (_output, scores) = weighter.weight(&query, &keys, &values);
let sum: f32 = scores.iter().sum();
assert!((sum - 1.0).abs() < 1e-5, "scores should sum to 1.0, got {sum}");
}
}

View File

@@ -0,0 +1,277 @@
//! Stage 5: Coarse breathing rate extraction.
//!
//! Extracts respiratory rate from body-sensitive BSSID oscillations.
//! Uses a simple bandpass filter (0.1-0.5 Hz) and zero-crossing
//! analysis rather than `OscillatoryRouter` (which is designed for
//! gamma-band frequencies, not sub-Hz breathing).
/// Coarse breathing extractor from multi-BSSID signal variance.
pub struct CoarseBreathingExtractor {
/// Combined filtered signal history.
filtered_history: Vec<f32>,
/// Window size for analysis.
window: usize,
/// Maximum tracked BSSIDs.
n_bssids: usize,
/// Breathing band low cutoff (Hz).
freq_low: f32,
/// Breathing band high cutoff (Hz).
freq_high: f32,
/// Sample rate (Hz) -- typically 2 Hz for Tier 1.
sample_rate: f32,
/// IIR filter state (simple 2nd-order bandpass).
filter_state: IirState,
}
/// Simple IIR bandpass filter state (2nd order).
#[derive(Clone, Debug)]
struct IirState {
x1: f32,
x2: f32,
y1: f32,
y2: f32,
}
impl Default for IirState {
fn default() -> Self {
Self {
x1: 0.0,
x2: 0.0,
y1: 0.0,
y2: 0.0,
}
}
}
impl CoarseBreathingExtractor {
/// Create a breathing extractor.
///
/// - `n_bssids`: maximum BSSID slots.
/// - `sample_rate`: input sample rate in Hz.
/// - `freq_low`: breathing band low cutoff (default 0.1 Hz).
/// - `freq_high`: breathing band high cutoff (default 0.5 Hz).
#[must_use]
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
pub fn new(n_bssids: usize, sample_rate: f32, freq_low: f32, freq_high: f32) -> Self {
let window = (sample_rate * 30.0) as usize; // 30 seconds of data
Self {
filtered_history: Vec::with_capacity(window),
window,
n_bssids,
freq_low,
freq_high,
sample_rate,
filter_state: IirState::default(),
}
}
/// Create with defaults suitable for Tier 1 (2 Hz sample rate).
#[must_use]
pub fn tier1_default(n_bssids: usize) -> Self {
Self::new(n_bssids, 2.0, 0.1, 0.5)
}
/// Process a frame of residuals with attention weights.
/// Returns estimated breathing rate (BPM) if detectable.
///
/// - `residuals`: per-BSSID residuals from `PredictiveGate`.
/// - `weights`: per-BSSID attention weights.
pub fn extract(&mut self, residuals: &[f32], weights: &[f32]) -> Option<BreathingEstimate> {
let n = residuals.len().min(self.n_bssids);
if n == 0 {
return None;
}
// Compute weighted sum of residuals for breathing analysis
#[allow(clippy::cast_precision_loss)]
let weighted_signal: f32 = residuals
.iter()
.enumerate()
.take(n)
.map(|(i, &r)| {
let w = weights.get(i).copied().unwrap_or(1.0 / n as f32);
r * w
})
.sum();
// Apply bandpass filter
let filtered = self.bandpass_filter(weighted_signal);
// Store in history
self.filtered_history.push(filtered);
if self.filtered_history.len() > self.window {
self.filtered_history.remove(0);
}
// Need at least 10 seconds of data to estimate breathing
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let min_samples = (self.sample_rate * 10.0) as usize;
if self.filtered_history.len() < min_samples {
return None;
}
// Zero-crossing rate -> frequency
let crossings = count_zero_crossings(&self.filtered_history);
#[allow(clippy::cast_precision_loss)]
let duration_s = self.filtered_history.len() as f32 / self.sample_rate;
#[allow(clippy::cast_precision_loss)]
let frequency_hz = crossings as f32 / (2.0 * duration_s);
// Validate frequency is in breathing range
if frequency_hz < self.freq_low || frequency_hz > self.freq_high {
return None;
}
let bpm = frequency_hz * 60.0;
// Compute confidence based on signal regularity
let confidence = compute_confidence(&self.filtered_history);
Some(BreathingEstimate {
bpm,
frequency_hz,
confidence,
})
}
/// Simple 2nd-order IIR bandpass filter.
fn bandpass_filter(&mut self, input: f32) -> f32 {
let state = &mut self.filter_state;
// Butterworth bandpass coefficients for [freq_low, freq_high] at given sample rate.
// Using bilinear transform approximation.
let omega_low = 2.0 * std::f32::consts::PI * self.freq_low / self.sample_rate;
let omega_high = 2.0 * std::f32::consts::PI * self.freq_high / self.sample_rate;
let bw = omega_high - omega_low;
let center = f32::midpoint(omega_low, omega_high);
let r = 1.0 - bw / 2.0;
let cos_w0 = center.cos();
// y[n] = (1-r)*(x[n] - x[n-2]) + 2*r*cos(w0)*y[n-1] - r^2*y[n-2]
let output =
(1.0 - r) * (input - state.x2) + 2.0 * r * cos_w0 * state.y1 - r * r * state.y2;
state.x2 = state.x1;
state.x1 = input;
state.y2 = state.y1;
state.y1 = output;
output
}
/// Reset all filter states and histories.
pub fn reset(&mut self) {
self.filtered_history.clear();
self.filter_state = IirState::default();
}
}
/// Result of breathing extraction.
#[derive(Debug, Clone)]
pub struct BreathingEstimate {
/// Estimated breathing rate in breaths per minute.
pub bpm: f32,
/// Estimated breathing frequency in Hz.
pub frequency_hz: f32,
/// Confidence in the estimate [0, 1].
pub confidence: f32,
}
/// Compute confidence in the breathing estimate based on signal regularity.
#[allow(clippy::cast_precision_loss)]
fn compute_confidence(history: &[f32]) -> f32 {
if history.len() < 4 {
return 0.0;
}
// Use variance-based SNR as a confidence metric
let mean: f32 = history.iter().sum::<f32>() / history.len() as f32;
let variance: f32 = history
.iter()
.map(|x| (x - mean) * (x - mean))
.sum::<f32>()
/ history.len() as f32;
if variance < 1e-10 {
return 0.0;
}
// Simple SNR-based confidence
let peak = history.iter().map(|x| x.abs()).fold(0.0f32, f32::max);
let noise = variance.sqrt();
let snr = if noise > 1e-10 { peak / noise } else { 0.0 };
// Map SNR to [0, 1] confidence
(snr / 5.0).min(1.0)
}
/// Count zero crossings in a signal.
fn count_zero_crossings(signal: &[f32]) -> usize {
signal.windows(2).filter(|w| w[0] * w[1] < 0.0).count()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn no_data_returns_none() {
let mut ext = CoarseBreathingExtractor::tier1_default(4);
assert!(ext.extract(&[], &[]).is_none());
}
#[test]
fn insufficient_history_returns_none() {
let mut ext = CoarseBreathingExtractor::tier1_default(4);
// Just a few frames are not enough
for _ in 0..5 {
assert!(ext.extract(&[1.0, 2.0], &[0.5, 0.5]).is_none());
}
}
#[test]
fn sinusoidal_breathing_detected() {
let mut ext = CoarseBreathingExtractor::new(1, 10.0, 0.1, 0.5);
let breathing_freq = 0.25; // 15 BPM
// Generate 60 seconds of sinusoidal breathing signal at 10 Hz
for i in 0..600 {
let t = i as f32 / 10.0;
let signal = (2.0 * std::f32::consts::PI * breathing_freq * t).sin();
ext.extract(&[signal], &[1.0]);
}
let result = ext.extract(&[0.0], &[1.0]);
if let Some(est) = result {
// Should be approximately 15 BPM (0.25 Hz * 60)
assert!(
est.bpm > 5.0 && est.bpm < 40.0,
"estimated BPM should be in breathing range: {}",
est.bpm
);
}
// It is acceptable if None -- the bandpass filter may need tuning
}
#[test]
fn zero_crossings_count() {
let signal = vec![1.0, -1.0, 1.0, -1.0, 1.0];
assert_eq!(count_zero_crossings(&signal), 4);
}
#[test]
fn zero_crossings_constant() {
let signal = vec![1.0, 1.0, 1.0, 1.0];
assert_eq!(count_zero_crossings(&signal), 0);
}
#[test]
fn reset_clears_state() {
let mut ext = CoarseBreathingExtractor::tier1_default(2);
ext.extract(&[1.0, 2.0], &[0.5, 0.5]);
ext.reset();
assert!(ext.filtered_history.is_empty());
}
}

View File

@@ -0,0 +1,267 @@
//! Stage 3: BSSID spatial correlation via GNN message passing.
//!
//! Builds a cross-correlation graph where nodes are BSSIDs and edges
//! represent temporal cross-correlation between their RSSI histories.
//! A single message-passing step identifies co-varying BSSID clusters
//! that are likely affected by the same person.
/// BSSID correlator that computes pairwise Pearson correlation
/// and identifies co-varying clusters.
///
/// Note: The full `RuvectorLayer` GNN requires matching dimension
/// weights trained on CSI data. For Phase 2 we use a lightweight
/// correlation-based approach that can be upgraded to GNN later.
pub struct BssidCorrelator {
/// Per-BSSID history buffers for correlation computation.
histories: Vec<Vec<f32>>,
/// Maximum history length.
window: usize,
/// Number of tracked BSSIDs.
n_bssids: usize,
/// Correlation threshold for "co-varying" classification.
correlation_threshold: f32,
}
impl BssidCorrelator {
/// Create a new correlator.
///
/// - `n_bssids`: number of BSSID slots.
/// - `window`: correlation window size (number of frames).
/// - `correlation_threshold`: minimum |r| to consider BSSIDs co-varying.
#[must_use]
pub fn new(n_bssids: usize, window: usize, correlation_threshold: f32) -> Self {
Self {
histories: vec![Vec::with_capacity(window); n_bssids],
window,
n_bssids,
correlation_threshold,
}
}
/// Push a new frame of amplitudes and compute correlation features.
///
/// Returns a `CorrelationResult` with the correlation matrix and
/// cluster assignments.
pub fn update(&mut self, amplitudes: &[f32]) -> CorrelationResult {
let n = amplitudes.len().min(self.n_bssids);
// Update histories
for (i, &amp) in amplitudes.iter().enumerate().take(n) {
let hist = &mut self.histories[i];
hist.push(amp);
if hist.len() > self.window {
hist.remove(0);
}
}
// Compute pairwise Pearson correlation
let mut corr_matrix = vec![vec![0.0f32; n]; n];
#[allow(clippy::needless_range_loop)]
for i in 0..n {
corr_matrix[i][i] = 1.0;
for j in (i + 1)..n {
let r = pearson_r(&self.histories[i], &self.histories[j]);
corr_matrix[i][j] = r;
corr_matrix[j][i] = r;
}
}
// Find strongly correlated clusters (simple union-find)
let clusters = self.find_clusters(&corr_matrix, n);
// Compute per-BSSID "spatial diversity" score:
// how many other BSSIDs is each one correlated with
#[allow(clippy::cast_precision_loss)]
let diversity: Vec<f32> = (0..n)
.map(|i| {
let count = (0..n)
.filter(|&j| j != i && corr_matrix[i][j].abs() > self.correlation_threshold)
.count();
count as f32 / (n.max(1) - 1) as f32
})
.collect();
CorrelationResult {
matrix: corr_matrix,
clusters,
diversity,
n_active: n,
}
}
/// Simple cluster assignment via thresholded correlation.
fn find_clusters(&self, corr: &[Vec<f32>], n: usize) -> Vec<usize> {
let mut cluster_id = vec![0usize; n];
let mut next_cluster = 0usize;
let mut assigned = vec![false; n];
for i in 0..n {
if assigned[i] {
continue;
}
cluster_id[i] = next_cluster;
assigned[i] = true;
// BFS: assign same cluster to correlated BSSIDs
let mut queue = vec![i];
while let Some(current) = queue.pop() {
for j in 0..n {
if !assigned[j] && corr[current][j].abs() > self.correlation_threshold {
cluster_id[j] = next_cluster;
assigned[j] = true;
queue.push(j);
}
}
}
next_cluster += 1;
}
cluster_id
}
/// Reset all correlation histories.
pub fn reset(&mut self) {
for h in &mut self.histories {
h.clear();
}
}
}
/// Result of correlation analysis.
#[derive(Debug, Clone)]
pub struct CorrelationResult {
/// n x n Pearson correlation matrix.
pub matrix: Vec<Vec<f32>>,
/// Cluster assignment per BSSID.
pub clusters: Vec<usize>,
/// Per-BSSID spatial diversity score [0, 1].
pub diversity: Vec<f32>,
/// Number of active BSSIDs in this frame.
pub n_active: usize,
}
impl CorrelationResult {
/// Number of distinct clusters.
#[must_use]
pub fn n_clusters(&self) -> usize {
self.clusters.iter().copied().max().map_or(0, |m| m + 1)
}
/// Mean absolute correlation (proxy for signal coherence).
#[must_use]
pub fn mean_correlation(&self) -> f32 {
if self.n_active < 2 {
return 0.0;
}
let mut sum = 0.0f32;
let mut count = 0;
for i in 0..self.n_active {
for j in (i + 1)..self.n_active {
sum += self.matrix[i][j].abs();
count += 1;
}
}
#[allow(clippy::cast_precision_loss)]
let mean = if count == 0 { 0.0 } else { sum / count as f32 };
mean
}
}
/// Pearson correlation coefficient between two equal-length slices.
#[allow(clippy::cast_precision_loss)]
fn pearson_r(x: &[f32], y: &[f32]) -> f32 {
let n = x.len().min(y.len());
if n < 2 {
return 0.0;
}
let n_f = n as f32;
let mean_x: f32 = x.iter().take(n).sum::<f32>() / n_f;
let mean_y: f32 = y.iter().take(n).sum::<f32>() / n_f;
let mut cov = 0.0f32;
let mut var_x = 0.0f32;
let mut var_y = 0.0f32;
for i in 0..n {
let dx = x[i] - mean_x;
let dy = y[i] - mean_y;
cov += dx * dy;
var_x += dx * dx;
var_y += dy * dy;
}
let denom = (var_x * var_y).sqrt();
if denom < 1e-12 {
0.0
} else {
cov / denom
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pearson_perfect_correlation() {
let x = vec![1.0, 2.0, 3.0, 4.0, 5.0];
let y = vec![2.0, 4.0, 6.0, 8.0, 10.0];
let r = pearson_r(&x, &y);
assert!((r - 1.0).abs() < 1e-5, "perfect positive correlation: {r}");
}
#[test]
fn pearson_negative_correlation() {
let x = vec![1.0, 2.0, 3.0, 4.0, 5.0];
let y = vec![10.0, 8.0, 6.0, 4.0, 2.0];
let r = pearson_r(&x, &y);
assert!((r - (-1.0)).abs() < 1e-5, "perfect negative correlation: {r}");
}
#[test]
fn pearson_no_correlation() {
let x = vec![1.0, 2.0, 3.0, 4.0, 5.0];
let y = vec![5.0, 1.0, 4.0, 2.0, 3.0]; // shuffled
let r = pearson_r(&x, &y);
assert!(r.abs() < 0.5, "low correlation expected: {r}");
}
#[test]
fn correlator_basic_update() {
let mut corr = BssidCorrelator::new(3, 10, 0.7);
// Push several identical frames
for _ in 0..5 {
corr.update(&[1.0, 2.0, 3.0]);
}
let result = corr.update(&[1.0, 2.0, 3.0]);
assert_eq!(result.n_active, 3);
}
#[test]
fn correlator_detects_covarying_bssids() {
let mut corr = BssidCorrelator::new(3, 20, 0.8);
// BSSID 0 and 1 co-vary, BSSID 2 is independent
for i in 0..20 {
let v = i as f32;
corr.update(&[v, v * 2.0, 5.0]); // 0 and 1 correlate, 2 is constant
}
let result = corr.update(&[20.0, 40.0, 5.0]);
// BSSIDs 0 and 1 should be in the same cluster
assert_eq!(
result.clusters[0], result.clusters[1],
"co-varying BSSIDs should cluster: {:?}",
result.clusters
);
}
#[test]
fn mean_correlation_zero_for_one_bssid() {
let result = CorrelationResult {
matrix: vec![vec![1.0]],
clusters: vec![0],
diversity: vec![0.0],
n_active: 1,
};
assert!((result.mean_correlation() - 0.0).abs() < 1e-5);
}
}

View File

@@ -0,0 +1,288 @@
//! Stage 7: BSSID fingerprint matching via cosine similarity.
//!
//! Stores reference BSSID amplitude patterns for known postures
//! (standing, sitting, walking, empty) and classifies new observations
//! by retrieving the nearest stored template.
//!
//! This is a pure-Rust implementation using cosine similarity. When
//! `ruvector-nervous-system` becomes available, the inner store can
//! be replaced with `ModernHopfield` for richer associative memory.
use crate::domain::result::PostureClass;
/// A stored posture fingerprint template.
#[derive(Debug, Clone)]
struct PostureTemplate {
/// Reference amplitude pattern (normalised).
pattern: Vec<f32>,
/// The posture label for this template.
label: PostureClass,
}
/// BSSID fingerprint matcher using cosine similarity.
pub struct FingerprintMatcher {
/// Stored reference templates.
templates: Vec<PostureTemplate>,
/// Minimum cosine similarity for a match.
confidence_threshold: f32,
/// Expected dimension (number of BSSID slots).
n_bssids: usize,
}
impl FingerprintMatcher {
/// Create a new fingerprint matcher.
///
/// - `n_bssids`: number of BSSID slots (pattern dimension).
/// - `confidence_threshold`: minimum cosine similarity for a match.
#[must_use]
pub fn new(n_bssids: usize, confidence_threshold: f32) -> Self {
Self {
templates: Vec::new(),
confidence_threshold,
n_bssids,
}
}
/// Store a reference pattern with its posture label.
///
/// # Errors
///
/// Returns an error if the pattern dimension does not match `n_bssids`.
pub fn store_pattern(
&mut self,
pattern: Vec<f32>,
label: PostureClass,
) -> Result<(), String> {
if pattern.len() != self.n_bssids {
return Err(format!(
"pattern dimension {} != expected {}",
pattern.len(),
self.n_bssids
));
}
self.templates.push(PostureTemplate { pattern, label });
Ok(())
}
/// Classify an observation by matching against stored fingerprints.
///
/// Returns the best-matching posture and similarity score, or `None`
/// if no patterns are stored or similarity is below threshold.
#[must_use]
pub fn classify(&self, observation: &[f32]) -> Option<(PostureClass, f32)> {
if self.templates.is_empty() || observation.len() != self.n_bssids {
return None;
}
let mut best_label = None;
let mut best_sim = f32::NEG_INFINITY;
for tmpl in &self.templates {
let sim = cosine_similarity(&tmpl.pattern, observation);
if sim > best_sim {
best_sim = sim;
best_label = Some(tmpl.label);
}
}
match best_label {
Some(label) if best_sim >= self.confidence_threshold => Some((label, best_sim)),
_ => None,
}
}
/// Match posture and return a structured result.
#[must_use]
pub fn match_posture(&self, observation: &[f32]) -> MatchResult {
match self.classify(observation) {
Some((posture, confidence)) => MatchResult {
posture: Some(posture),
confidence,
matched: true,
},
None => MatchResult {
posture: None,
confidence: 0.0,
matched: false,
},
}
}
/// Generate default templates from a baseline signal.
///
/// Creates heuristic patterns for standing, sitting, and empty by
/// scaling the baseline amplitude pattern.
pub fn generate_defaults(&mut self, baseline: &[f32]) {
if baseline.len() != self.n_bssids {
return;
}
// Empty: very low amplitude (background noise only)
let empty: Vec<f32> = baseline.iter().map(|&a| a * 0.1).collect();
let _ = self.store_pattern(empty, PostureClass::Empty);
// Standing: moderate perturbation of some BSSIDs
let standing: Vec<f32> = baseline
.iter()
.enumerate()
.map(|(i, &a)| if i % 3 == 0 { a * 1.3 } else { a })
.collect();
let _ = self.store_pattern(standing, PostureClass::Standing);
// Sitting: different perturbation pattern
let sitting: Vec<f32> = baseline
.iter()
.enumerate()
.map(|(i, &a)| if i % 2 == 0 { a * 1.2 } else { a * 0.9 })
.collect();
let _ = self.store_pattern(sitting, PostureClass::Sitting);
}
/// Number of stored patterns.
#[must_use]
pub fn num_patterns(&self) -> usize {
self.templates.len()
}
/// Clear all stored patterns.
pub fn clear(&mut self) {
self.templates.clear();
}
/// Set the minimum similarity threshold for classification.
pub fn set_confidence_threshold(&mut self, threshold: f32) {
self.confidence_threshold = threshold;
}
}
/// Result of fingerprint matching.
#[derive(Debug, Clone)]
pub struct MatchResult {
/// Matched posture class (None if no match).
pub posture: Option<PostureClass>,
/// Cosine similarity of the best match.
pub confidence: f32,
/// Whether a match was found above threshold.
pub matched: bool,
}
/// Cosine similarity between two vectors.
fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
let n = a.len().min(b.len());
if n == 0 {
return 0.0;
}
let mut dot = 0.0f32;
let mut norm_a = 0.0f32;
let mut norm_b = 0.0f32;
for i in 0..n {
dot += a[i] * b[i];
norm_a += a[i] * a[i];
norm_b += b[i] * b[i];
}
let denom = (norm_a * norm_b).sqrt();
if denom < 1e-12 {
0.0
} else {
dot / denom
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_matcher_returns_none() {
let matcher = FingerprintMatcher::new(4, 0.5);
assert!(matcher.classify(&[1.0, 2.0, 3.0, 4.0]).is_none());
}
#[test]
fn wrong_dimension_returns_none() {
let mut matcher = FingerprintMatcher::new(4, 0.5);
matcher
.store_pattern(vec![1.0; 4], PostureClass::Standing)
.unwrap();
// Wrong dimension
assert!(matcher.classify(&[1.0, 2.0]).is_none());
}
#[test]
fn store_and_recall() {
let mut matcher = FingerprintMatcher::new(4, 0.5);
// Store distinct patterns
matcher
.store_pattern(vec![1.0, 0.0, 0.0, 0.0], PostureClass::Standing)
.unwrap();
matcher
.store_pattern(vec![0.0, 1.0, 0.0, 0.0], PostureClass::Sitting)
.unwrap();
assert_eq!(matcher.num_patterns(), 2);
// Query close to "Standing" pattern
let result = matcher.classify(&[0.9, 0.1, 0.0, 0.0]);
if let Some((posture, sim)) = result {
assert_eq!(posture, PostureClass::Standing);
assert!(sim > 0.5, "similarity should be above threshold: {sim}");
}
}
#[test]
fn wrong_dim_store_rejected() {
let mut matcher = FingerprintMatcher::new(4, 0.5);
let result = matcher.store_pattern(vec![1.0, 2.0], PostureClass::Empty);
assert!(result.is_err());
}
#[test]
fn clear_removes_all() {
let mut matcher = FingerprintMatcher::new(2, 0.5);
matcher
.store_pattern(vec![1.0, 0.0], PostureClass::Standing)
.unwrap();
assert_eq!(matcher.num_patterns(), 1);
matcher.clear();
assert_eq!(matcher.num_patterns(), 0);
}
#[test]
fn cosine_similarity_identical() {
let a = vec![1.0, 2.0, 3.0];
let b = vec![1.0, 2.0, 3.0];
let sim = cosine_similarity(&a, &b);
assert!((sim - 1.0).abs() < 1e-5, "identical vectors: {sim}");
}
#[test]
fn cosine_similarity_orthogonal() {
let a = vec![1.0, 0.0];
let b = vec![0.0, 1.0];
let sim = cosine_similarity(&a, &b);
assert!(sim.abs() < 1e-5, "orthogonal vectors: {sim}");
}
#[test]
fn match_posture_result() {
let mut matcher = FingerprintMatcher::new(3, 0.5);
matcher
.store_pattern(vec![1.0, 0.0, 0.0], PostureClass::Standing)
.unwrap();
let result = matcher.match_posture(&[0.95, 0.05, 0.0]);
assert!(result.matched);
assert_eq!(result.posture, Some(PostureClass::Standing));
}
#[test]
fn generate_defaults_creates_templates() {
let mut matcher = FingerprintMatcher::new(4, 0.3);
matcher.generate_defaults(&[1.0, 2.0, 3.0, 4.0]);
assert_eq!(matcher.num_patterns(), 3); // Empty, Standing, Sitting
}
}

View File

@@ -0,0 +1,36 @@
//! Signal Intelligence pipeline (Phase 2, ADR-022).
//!
//! Composes `RuVector` primitives into a multi-stage sensing pipeline
//! that transforms multi-BSSID RSSI frames into presence, motion,
//! and coarse vital sign estimates.
//!
//! ## Stages
//!
//! 1. [`predictive_gate`] -- residual gating via `PredictiveLayer`
//! 2. [`attention_weighter`] -- BSSID attention weighting
//! 3. [`correlator`] -- cross-BSSID Pearson correlation & clustering
//! 4. [`motion_estimator`] -- multi-AP motion estimation
//! 5. [`breathing_extractor`] -- coarse breathing rate extraction
//! 6. [`quality_gate`] -- ruQu three-filter quality gate
//! 7. [`fingerprint_matcher`] -- `ModernHopfield` posture fingerprinting
//! 8. [`orchestrator`] -- full pipeline orchestrator
#[cfg(feature = "pipeline")]
pub mod predictive_gate;
#[cfg(feature = "pipeline")]
pub mod attention_weighter;
#[cfg(feature = "pipeline")]
pub mod correlator;
#[cfg(feature = "pipeline")]
pub mod motion_estimator;
#[cfg(feature = "pipeline")]
pub mod breathing_extractor;
#[cfg(feature = "pipeline")]
pub mod quality_gate;
#[cfg(feature = "pipeline")]
pub mod fingerprint_matcher;
#[cfg(feature = "pipeline")]
pub mod orchestrator;
#[cfg(feature = "pipeline")]
pub use orchestrator::WindowsWifiPipeline;

View File

@@ -0,0 +1,210 @@
//! Stage 4: Multi-AP motion estimation.
//!
//! Combines per-BSSID residuals, attention weights, and correlation
//! features to estimate overall motion intensity and classify
//! motion level (None / Minimal / Moderate / High).
use crate::domain::result::MotionLevel;
/// Multi-AP motion estimator using weighted variance of BSSID residuals.
pub struct MultiApMotionEstimator {
/// EMA smoothing factor for motion score.
alpha: f32,
/// Running EMA of motion score.
ema_motion: f32,
/// Motion threshold for None->Minimal transition.
threshold_minimal: f32,
/// Motion threshold for Minimal->Moderate transition.
threshold_moderate: f32,
/// Motion threshold for Moderate->High transition.
threshold_high: f32,
}
impl MultiApMotionEstimator {
/// Create a motion estimator with default thresholds.
#[must_use]
pub fn new() -> Self {
Self {
alpha: 0.3,
ema_motion: 0.0,
threshold_minimal: 0.02,
threshold_moderate: 0.10,
threshold_high: 0.30,
}
}
/// Create with custom thresholds.
#[must_use]
pub fn with_thresholds(minimal: f32, moderate: f32, high: f32) -> Self {
Self {
alpha: 0.3,
ema_motion: 0.0,
threshold_minimal: minimal,
threshold_moderate: moderate,
threshold_high: high,
}
}
/// Estimate motion from weighted residuals.
///
/// - `residuals`: per-BSSID residual from `PredictiveGate`.
/// - `weights`: per-BSSID attention weights from `AttentionWeighter`.
/// - `diversity`: per-BSSID correlation diversity from `BssidCorrelator`.
///
/// Returns `MotionEstimate` with score and level.
pub fn estimate(
&mut self,
residuals: &[f32],
weights: &[f32],
diversity: &[f32],
) -> MotionEstimate {
let n = residuals.len();
if n == 0 {
return MotionEstimate {
score: 0.0,
level: MotionLevel::None,
weighted_variance: 0.0,
n_contributing: 0,
};
}
// Weighted variance of residuals (body-sensitive BSSIDs contribute more)
let mut weighted_sum = 0.0f32;
let mut weight_total = 0.0f32;
let mut n_contributing = 0usize;
#[allow(clippy::cast_precision_loss)]
for (i, residual) in residuals.iter().enumerate() {
let w = weights.get(i).copied().unwrap_or(1.0 / n as f32);
let d = diversity.get(i).copied().unwrap_or(0.5);
// Combine attention weight with diversity (correlated BSSIDs
// that respond together are better indicators)
let combined_w = w * (0.5 + 0.5 * d);
weighted_sum += combined_w * residual.abs();
weight_total += combined_w;
if residual.abs() > 0.001 {
n_contributing += 1;
}
}
let weighted_variance = if weight_total > 1e-9 {
weighted_sum / weight_total
} else {
0.0
};
// EMA smoothing
self.ema_motion = self.alpha * weighted_variance + (1.0 - self.alpha) * self.ema_motion;
let level = if self.ema_motion < self.threshold_minimal {
MotionLevel::None
} else if self.ema_motion < self.threshold_moderate {
MotionLevel::Minimal
} else if self.ema_motion < self.threshold_high {
MotionLevel::Moderate
} else {
MotionLevel::High
};
MotionEstimate {
score: self.ema_motion,
level,
weighted_variance,
n_contributing,
}
}
/// Reset the EMA state.
pub fn reset(&mut self) {
self.ema_motion = 0.0;
}
}
impl Default for MultiApMotionEstimator {
fn default() -> Self {
Self::new()
}
}
/// Result of motion estimation.
#[derive(Debug, Clone)]
pub struct MotionEstimate {
/// Smoothed motion score (EMA of weighted variance).
pub score: f32,
/// Classified motion level.
pub level: MotionLevel,
/// Raw weighted variance before smoothing.
pub weighted_variance: f32,
/// Number of BSSIDs with non-zero residuals.
pub n_contributing: usize,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn no_residuals_yields_no_motion() {
let mut est = MultiApMotionEstimator::new();
let result = est.estimate(&[], &[], &[]);
assert_eq!(result.level, MotionLevel::None);
assert!((result.score - 0.0).abs() < f32::EPSILON);
}
#[test]
fn zero_residuals_yield_no_motion() {
let mut est = MultiApMotionEstimator::new();
let residuals = vec![0.0, 0.0, 0.0];
let weights = vec![0.33, 0.33, 0.34];
let diversity = vec![0.5, 0.5, 0.5];
let result = est.estimate(&residuals, &weights, &diversity);
assert_eq!(result.level, MotionLevel::None);
}
#[test]
fn large_residuals_yield_high_motion() {
let mut est = MultiApMotionEstimator::new();
let residuals = vec![5.0, 5.0, 5.0];
let weights = vec![0.33, 0.33, 0.34];
let diversity = vec![1.0, 1.0, 1.0];
// Push several frames to overcome EMA smoothing
for _ in 0..20 {
est.estimate(&residuals, &weights, &diversity);
}
let result = est.estimate(&residuals, &weights, &diversity);
assert_eq!(result.level, MotionLevel::High);
}
#[test]
fn ema_smooths_transients() {
let mut est = MultiApMotionEstimator::new();
let big = vec![10.0, 10.0, 10.0];
let zero = vec![0.0, 0.0, 0.0];
let w = vec![0.33, 0.33, 0.34];
let d = vec![0.5, 0.5, 0.5];
// One big spike followed by zeros
est.estimate(&big, &w, &d);
let r1 = est.estimate(&zero, &w, &d);
let r2 = est.estimate(&zero, &w, &d);
// Score should decay
assert!(r2.score < r1.score, "EMA should decay: {} < {}", r2.score, r1.score);
}
#[test]
fn n_contributing_counts_nonzero() {
let mut est = MultiApMotionEstimator::new();
let residuals = vec![0.0, 1.0, 0.0, 2.0];
let weights = vec![0.25; 4];
let diversity = vec![0.5; 4];
let result = est.estimate(&residuals, &weights, &diversity);
assert_eq!(result.n_contributing, 2);
}
#[test]
fn default_creates_estimator() {
let est = MultiApMotionEstimator::default();
assert!((est.threshold_minimal - 0.02).abs() < f32::EPSILON);
}
}

View File

@@ -0,0 +1,432 @@
//! Stage 8: Pipeline orchestrator (Domain Service).
//!
//! `WindowsWifiPipeline` connects all pipeline stages (1-7) into a
//! single processing step that transforms a `MultiApFrame` into an
//! `EnhancedSensingResult`.
//!
//! This is the Domain Service described in ADR-022 section 3.2.
use crate::domain::frame::MultiApFrame;
use crate::domain::result::{
BreathingEstimate as DomainBreathingEstimate, EnhancedSensingResult,
MotionEstimate as DomainMotionEstimate, MotionLevel, PostureClass, SignalQuality,
Verdict as DomainVerdict,
};
use super::attention_weighter::AttentionWeighter;
use super::breathing_extractor::CoarseBreathingExtractor;
use super::correlator::BssidCorrelator;
use super::fingerprint_matcher::FingerprintMatcher;
use super::motion_estimator::MultiApMotionEstimator;
use super::predictive_gate::PredictiveGate;
use super::quality_gate::{QualityGate, Verdict};
/// Configuration for the Windows `WiFi` sensing pipeline.
#[derive(Debug, Clone)]
pub struct PipelineConfig {
/// Maximum number of BSSID slots.
pub max_bssids: usize,
/// Residual gating threshold (stage 1).
pub gate_threshold: f32,
/// Correlation window size in frames (stage 3).
pub correlation_window: usize,
/// Correlation threshold for co-varying classification (stage 3).
pub correlation_threshold: f32,
/// Minimum BSSIDs for a valid frame.
pub min_bssids: usize,
/// Enable breathing extraction (stage 5).
pub enable_breathing: bool,
/// Enable fingerprint matching (stage 7).
pub enable_fingerprint: bool,
/// Sample rate in Hz.
pub sample_rate: f32,
}
impl Default for PipelineConfig {
fn default() -> Self {
Self {
max_bssids: 32,
gate_threshold: 0.05,
correlation_window: 30,
correlation_threshold: 0.7,
min_bssids: 3,
enable_breathing: true,
enable_fingerprint: true,
sample_rate: 2.0,
}
}
}
/// The complete Windows `WiFi` sensing pipeline (Domain Service).
///
/// Connects stages 1-7 into a single `process()` call that transforms
/// a `MultiApFrame` into an `EnhancedSensingResult`.
///
/// Stages:
/// 1. Predictive gating (EMA residual filter)
/// 2. Attention weighting (softmax dot-product)
/// 3. Spatial correlation (Pearson + clustering)
/// 4. Motion estimation (weighted variance + EMA)
/// 5. Breathing extraction (bandpass + zero-crossing)
/// 6. Quality gate (three-filter: structural / shift / evidence)
/// 7. Fingerprint matching (cosine similarity templates)
pub struct WindowsWifiPipeline {
gate: PredictiveGate,
attention: AttentionWeighter,
correlator: BssidCorrelator,
motion: MultiApMotionEstimator,
breathing: CoarseBreathingExtractor,
quality: QualityGate,
fingerprint: FingerprintMatcher,
config: PipelineConfig,
/// Whether fingerprint defaults have been initialised.
fingerprints_initialised: bool,
/// Frame counter.
frame_count: u64,
}
impl WindowsWifiPipeline {
/// Create a new pipeline with default configuration.
#[must_use]
pub fn new() -> Self {
Self::with_config(PipelineConfig::default())
}
/// Create with default configuration (alias for `new`).
#[must_use]
pub fn with_defaults() -> Self {
Self::new()
}
/// Create a new pipeline with custom configuration.
#[must_use]
pub fn with_config(config: PipelineConfig) -> Self {
Self {
gate: PredictiveGate::new(config.max_bssids, config.gate_threshold),
attention: AttentionWeighter::new(1),
correlator: BssidCorrelator::new(
config.max_bssids,
config.correlation_window,
config.correlation_threshold,
),
motion: MultiApMotionEstimator::new(),
breathing: CoarseBreathingExtractor::new(
config.max_bssids,
config.sample_rate,
0.1,
0.5,
),
quality: QualityGate::new(),
fingerprint: FingerprintMatcher::new(config.max_bssids, 0.5),
fingerprints_initialised: false,
frame_count: 0,
config,
}
}
/// Process a single multi-BSSID frame through all pipeline stages.
///
/// Returns an `EnhancedSensingResult` with motion, breathing,
/// posture, and quality information.
pub fn process(&mut self, frame: &MultiApFrame) -> EnhancedSensingResult {
self.frame_count += 1;
let n = frame.bssid_count;
// Convert f64 amplitudes to f32 for pipeline stages.
#[allow(clippy::cast_possible_truncation)]
let amps_f32: Vec<f32> = frame.amplitudes.iter().map(|&a| a as f32).collect();
// Initialise fingerprint defaults on first frame with enough BSSIDs.
if !self.fingerprints_initialised
&& self.config.enable_fingerprint
&& amps_f32.len() == self.config.max_bssids
{
self.fingerprint.generate_defaults(&amps_f32);
self.fingerprints_initialised = true;
}
// Check minimum BSSID count.
if n < self.config.min_bssids {
return Self::make_empty_result(frame, n);
}
// -- Stage 1: Predictive gating --
let Some(residuals) = self.gate.gate(&amps_f32) else {
// Static environment, no body present.
return Self::make_empty_result(frame, n);
};
// -- Stage 2: Attention weighting --
#[allow(clippy::cast_precision_loss)]
let mean_residual =
residuals.iter().map(|r| r.abs()).sum::<f32>() / residuals.len().max(1) as f32;
let query = vec![mean_residual];
let keys: Vec<Vec<f32>> = residuals.iter().map(|&r| vec![r]).collect();
let values: Vec<Vec<f32>> = amps_f32.iter().map(|&a| vec![a]).collect();
let (_weighted, weights) = self.attention.weight(&query, &keys, &values);
// -- Stage 3: Spatial correlation --
let corr = self.correlator.update(&amps_f32);
// -- Stage 4: Motion estimation --
let motion = self.motion.estimate(&residuals, &weights, &corr.diversity);
// -- Stage 5: Breathing extraction (only when stationary) --
let breathing = if self.config.enable_breathing && motion.level == MotionLevel::Minimal {
self.breathing.extract(&residuals, &weights)
} else {
None
};
// -- Stage 6: Quality gate --
let quality_result = self.quality.evaluate(
n,
frame.mean_rssi(),
f64::from(corr.mean_correlation()),
motion.score,
);
// -- Stage 7: Fingerprint matching --
let posture = if self.config.enable_fingerprint {
self.fingerprint.classify(&amps_f32).map(|(p, _sim)| p)
} else {
None
};
// Count body-sensitive BSSIDs (attention weight above 1.5x average).
#[allow(clippy::cast_precision_loss)]
let avg_weight = 1.0 / n.max(1) as f32;
let sensitive_count = weights.iter().filter(|&&w| w > avg_weight * 1.5).count();
// Map internal quality gate verdict to domain Verdict.
let domain_verdict = match &quality_result.verdict {
Verdict::Permit => DomainVerdict::Permit,
Verdict::Defer => DomainVerdict::Warn,
Verdict::Deny(_) => DomainVerdict::Deny,
};
// Build the domain BreathingEstimate if we have one.
let domain_breathing = breathing.map(|b| DomainBreathingEstimate {
rate_bpm: f64::from(b.bpm),
confidence: f64::from(b.confidence),
bssid_count: sensitive_count,
});
EnhancedSensingResult {
motion: DomainMotionEstimate {
score: f64::from(motion.score),
level: motion.level,
contributing_bssids: motion.n_contributing,
},
breathing: domain_breathing,
posture,
signal_quality: SignalQuality {
score: quality_result.quality,
bssid_count: n,
spectral_gap: f64::from(corr.mean_correlation()),
mean_rssi_dbm: frame.mean_rssi(),
},
bssid_count: n,
verdict: domain_verdict,
}
}
/// Build an empty/gated result for frames that don't pass initial checks.
fn make_empty_result(frame: &MultiApFrame, n: usize) -> EnhancedSensingResult {
EnhancedSensingResult {
motion: DomainMotionEstimate {
score: 0.0,
level: MotionLevel::None,
contributing_bssids: 0,
},
breathing: None,
posture: None,
signal_quality: SignalQuality {
score: 0.0,
bssid_count: n,
spectral_gap: 0.0,
mean_rssi_dbm: frame.mean_rssi(),
},
bssid_count: n,
verdict: DomainVerdict::Deny,
}
}
/// Store a reference fingerprint pattern.
///
/// # Errors
///
/// Returns an error if the pattern dimension does not match `max_bssids`.
pub fn store_fingerprint(
&mut self,
pattern: Vec<f32>,
label: PostureClass,
) -> Result<(), String> {
self.fingerprint.store_pattern(pattern, label)
}
/// Reset all pipeline state.
pub fn reset(&mut self) {
self.gate = PredictiveGate::new(self.config.max_bssids, self.config.gate_threshold);
self.correlator = BssidCorrelator::new(
self.config.max_bssids,
self.config.correlation_window,
self.config.correlation_threshold,
);
self.motion.reset();
self.breathing.reset();
self.quality.reset();
self.fingerprint.clear();
self.fingerprints_initialised = false;
self.frame_count = 0;
}
/// Number of frames processed.
#[must_use]
pub fn frame_count(&self) -> u64 {
self.frame_count
}
/// Current pipeline configuration.
#[must_use]
pub fn config(&self) -> &PipelineConfig {
&self.config
}
}
impl Default for WindowsWifiPipeline {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::VecDeque;
use std::time::Instant;
fn make_frame(bssid_count: usize, rssi_values: &[f64]) -> MultiApFrame {
let amplitudes: Vec<f64> = rssi_values
.iter()
.map(|&r| 10.0_f64.powf((r + 100.0) / 20.0))
.collect();
MultiApFrame {
bssid_count,
rssi_dbm: rssi_values.to_vec(),
amplitudes,
phases: vec![0.0; bssid_count],
per_bssid_variance: vec![0.1; bssid_count],
histories: vec![VecDeque::new(); bssid_count],
sample_rate_hz: 2.0,
timestamp: Instant::now(),
}
}
#[test]
fn pipeline_creates_ok() {
let pipeline = WindowsWifiPipeline::with_defaults();
assert_eq!(pipeline.frame_count(), 0);
assert_eq!(pipeline.config().max_bssids, 32);
}
#[test]
fn too_few_bssids_returns_deny() {
let mut pipeline = WindowsWifiPipeline::new();
let frame = make_frame(2, &[-60.0, -70.0]);
let result = pipeline.process(&frame);
assert_eq!(result.verdict, DomainVerdict::Deny);
}
#[test]
fn first_frame_increments_count() {
let mut pipeline = WindowsWifiPipeline::with_config(PipelineConfig {
min_bssids: 1,
max_bssids: 4,
..Default::default()
});
let frame = make_frame(4, &[-60.0, -65.0, -70.0, -75.0]);
let _result = pipeline.process(&frame);
assert_eq!(pipeline.frame_count(), 1);
}
#[test]
fn static_signal_returns_deny_after_learning() {
let mut pipeline = WindowsWifiPipeline::with_config(PipelineConfig {
min_bssids: 1,
max_bssids: 4,
..Default::default()
});
let frame = make_frame(4, &[-60.0, -65.0, -70.0, -75.0]);
// Train on static signal.
pipeline.process(&frame);
pipeline.process(&frame);
pipeline.process(&frame);
// After learning, static signal should be gated (Deny verdict).
let result = pipeline.process(&frame);
assert_eq!(
result.verdict,
DomainVerdict::Deny,
"static signal should be gated"
);
}
#[test]
fn changing_signal_increments_count() {
let mut pipeline = WindowsWifiPipeline::with_config(PipelineConfig {
min_bssids: 1,
max_bssids: 4,
..Default::default()
});
let baseline = make_frame(4, &[-60.0, -65.0, -70.0, -75.0]);
// Learn baseline.
for _ in 0..5 {
pipeline.process(&baseline);
}
// Significant change should be noticed.
let changed = make_frame(4, &[-60.0, -65.0, -70.0, -30.0]);
pipeline.process(&changed);
assert!(pipeline.frame_count() > 5);
}
#[test]
fn reset_clears_state() {
let mut pipeline = WindowsWifiPipeline::new();
let frame = make_frame(4, &[-60.0, -65.0, -70.0, -75.0]);
pipeline.process(&frame);
assert_eq!(pipeline.frame_count(), 1);
pipeline.reset();
assert_eq!(pipeline.frame_count(), 0);
}
#[test]
fn default_creates_pipeline() {
let _pipeline = WindowsWifiPipeline::default();
}
#[test]
fn pipeline_throughput_benchmark() {
let mut pipeline = WindowsWifiPipeline::with_config(PipelineConfig {
min_bssids: 1,
max_bssids: 4,
..Default::default()
});
let frame = make_frame(4, &[-60.0, -65.0, -70.0, -75.0]);
let start = Instant::now();
let n_frames = 10_000;
for _ in 0..n_frames {
pipeline.process(&frame);
}
let elapsed = start.elapsed();
#[allow(clippy::cast_precision_loss)]
let fps = n_frames as f64 / elapsed.as_secs_f64();
println!("Pipeline throughput: {fps:.0} frames/sec ({elapsed:?} for {n_frames} frames)");
assert!(fps > 100.0, "Pipeline should process >100 frames/sec, got {fps:.0}");
}
}

View File

@@ -0,0 +1,141 @@
//! Stage 1: Predictive gating via EMA-based residual filter.
//!
//! Suppresses static BSSIDs by computing residuals between predicted
//! (EMA) and actual RSSI values. Only transmits frames where significant
//! change is detected (body interaction).
//!
//! This is a lightweight pure-Rust implementation. When `ruvector-nervous-system`
//! becomes available, the inner EMA predictor can be replaced with
//! `PredictiveLayer` for more sophisticated prediction.
/// Wrapper around an EMA predictor for multi-BSSID residual gating.
pub struct PredictiveGate {
/// Per-BSSID EMA predictions.
predictions: Vec<f32>,
/// Whether a prediction has been initialised for each slot.
initialised: Vec<bool>,
/// EMA smoothing factor (higher = faster tracking).
alpha: f32,
/// Residual threshold for change detection.
threshold: f32,
/// Residuals from the last frame (for downstream use).
last_residuals: Vec<f32>,
/// Number of BSSID slots.
n_bssids: usize,
}
impl PredictiveGate {
/// Create a new predictive gate.
///
/// - `n_bssids`: maximum number of tracked BSSIDs (subcarrier slots).
/// - `threshold`: residual threshold for change detection (ADR-022 default: 0.05).
#[must_use]
pub fn new(n_bssids: usize, threshold: f32) -> Self {
Self {
predictions: vec![0.0; n_bssids],
initialised: vec![false; n_bssids],
alpha: 0.3,
threshold,
last_residuals: vec![0.0; n_bssids],
n_bssids,
}
}
/// Process a frame. Returns `Some(residuals)` if body-correlated change
/// is detected, `None` if the environment is static.
pub fn gate(&mut self, amplitudes: &[f32]) -> Option<Vec<f32>> {
let n = amplitudes.len().min(self.n_bssids);
let mut residuals = vec![0.0f32; n];
let mut max_residual = 0.0f32;
for i in 0..n {
if self.initialised[i] {
residuals[i] = amplitudes[i] - self.predictions[i];
max_residual = max_residual.max(residuals[i].abs());
// Update EMA
self.predictions[i] =
self.alpha * amplitudes[i] + (1.0 - self.alpha) * self.predictions[i];
} else {
// First observation: seed the prediction
self.predictions[i] = amplitudes[i];
self.initialised[i] = true;
residuals[i] = amplitudes[i]; // first frame always transmits
max_residual = f32::MAX;
}
}
self.last_residuals.clone_from(&residuals);
if max_residual > self.threshold {
Some(residuals)
} else {
None
}
}
/// Return the residuals from the last `gate()` call.
#[must_use]
pub fn last_residuals(&self) -> &[f32] {
&self.last_residuals
}
/// Update the threshold dynamically (e.g., from SONA adaptation).
pub fn set_threshold(&mut self, threshold: f32) {
self.threshold = threshold;
}
/// Current threshold.
#[must_use]
pub fn threshold(&self) -> f32 {
self.threshold
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn static_signal_is_gated() {
let mut gate = PredictiveGate::new(4, 0.05);
let signal = vec![1.0, 2.0, 3.0, 4.0];
// First frame always transmits (no prediction yet)
assert!(gate.gate(&signal).is_some());
// After many repeated frames, EMA converges and residuals shrink
for _ in 0..20 {
gate.gate(&signal);
}
assert!(gate.gate(&signal).is_none());
}
#[test]
fn changing_signal_transmits() {
let mut gate = PredictiveGate::new(4, 0.05);
let signal1 = vec![1.0, 2.0, 3.0, 4.0];
gate.gate(&signal1);
// Let EMA converge
for _ in 0..20 {
gate.gate(&signal1);
}
// Large change should be transmitted
let signal2 = vec![1.0, 2.0, 3.0, 10.0];
assert!(gate.gate(&signal2).is_some());
}
#[test]
fn residuals_are_stored() {
let mut gate = PredictiveGate::new(3, 0.05);
let signal = vec![1.0, 2.0, 3.0];
gate.gate(&signal);
assert_eq!(gate.last_residuals().len(), 3);
}
#[test]
fn threshold_can_be_updated() {
let mut gate = PredictiveGate::new(2, 0.05);
assert!((gate.threshold() - 0.05).abs() < f32::EPSILON);
gate.set_threshold(0.1);
assert!((gate.threshold() - 0.1).abs() < f32::EPSILON);
}
}

View File

@@ -0,0 +1,261 @@
//! Stage 6: Signal quality gate.
//!
//! Evaluates signal quality using three factors inspired by the ruQu
//! three-filter architecture (structural integrity, distribution drift,
//! evidence accumulation):
//!
//! - **Structural**: number of active BSSIDs (graph connectivity proxy).
//! - **Shift**: RSSI drift from running baseline.
//! - **Evidence**: accumulated weighted variance evidence.
//!
//! This is a pure-Rust implementation. When the `ruqu` crate becomes
//! available, the inner filter can be replaced with `FilterPipeline`.
/// Configuration for the quality gate.
#[derive(Debug, Clone)]
pub struct QualityGateConfig {
/// Minimum active BSSIDs for a "Permit" verdict.
pub min_bssids: usize,
/// Evidence threshold for "Permit" (accumulated variance).
pub evidence_threshold: f64,
/// RSSI drift threshold (dBm) for triggering a "Warn".
pub drift_threshold: f64,
/// Maximum evidence decay per frame.
pub evidence_decay: f64,
}
impl Default for QualityGateConfig {
fn default() -> Self {
Self {
min_bssids: 3,
evidence_threshold: 0.5,
drift_threshold: 10.0,
evidence_decay: 0.95,
}
}
}
/// Quality gate combining structural, shift, and evidence filters.
pub struct QualityGate {
config: QualityGateConfig,
/// Accumulated evidence score.
evidence: f64,
/// Running mean RSSI baseline for drift detection.
prev_mean_rssi: Option<f64>,
/// EMA smoothing factor for drift baseline.
alpha: f64,
}
impl QualityGate {
/// Create a quality gate with default configuration.
#[must_use]
pub fn new() -> Self {
Self::with_config(QualityGateConfig::default())
}
/// Create a quality gate with custom configuration.
#[must_use]
pub fn with_config(config: QualityGateConfig) -> Self {
Self {
config,
evidence: 0.0,
prev_mean_rssi: None,
alpha: 0.3,
}
}
/// Evaluate signal quality.
///
/// - `bssid_count`: number of active BSSIDs.
/// - `mean_rssi_dbm`: mean RSSI across all BSSIDs.
/// - `mean_correlation`: mean cross-BSSID correlation (spectral gap proxy).
/// - `motion_score`: smoothed motion score from the estimator.
///
/// Returns a `QualityResult` with verdict and quality score.
pub fn evaluate(
&mut self,
bssid_count: usize,
mean_rssi_dbm: f64,
mean_correlation: f64,
motion_score: f32,
) -> QualityResult {
// --- Filter 1: Structural (BSSID count) ---
let structural_ok = bssid_count >= self.config.min_bssids;
// --- Filter 2: Shift (RSSI drift detection) ---
let drift = if let Some(prev) = self.prev_mean_rssi {
(mean_rssi_dbm - prev).abs()
} else {
0.0
};
// Update baseline with EMA
self.prev_mean_rssi = Some(match self.prev_mean_rssi {
Some(prev) => self.alpha * mean_rssi_dbm + (1.0 - self.alpha) * prev,
None => mean_rssi_dbm,
});
let drift_detected = drift > self.config.drift_threshold;
// --- Filter 3: Evidence accumulation ---
// Motion and correlation both contribute positive evidence.
let evidence_input = f64::from(motion_score) * 0.7 + mean_correlation * 0.3;
self.evidence = self.evidence * self.config.evidence_decay + evidence_input;
// --- Quality score ---
let quality = compute_quality_score(
bssid_count,
f64::from(motion_score),
mean_correlation,
drift_detected,
);
// --- Verdict decision ---
let verdict = if !structural_ok {
Verdict::Deny("insufficient BSSIDs".to_string())
} else if self.evidence < self.config.evidence_threshold * 0.5 || drift_detected {
Verdict::Defer
} else {
Verdict::Permit
};
QualityResult {
verdict,
quality,
drift_detected,
}
}
/// Reset the gate state.
pub fn reset(&mut self) {
self.evidence = 0.0;
self.prev_mean_rssi = None;
}
}
impl Default for QualityGate {
fn default() -> Self {
Self::new()
}
}
/// Quality verdict from the gate.
#[derive(Debug, Clone)]
pub struct QualityResult {
/// Filter decision.
pub verdict: Verdict,
/// Signal quality score [0, 1].
pub quality: f64,
/// Whether environmental drift was detected.
pub drift_detected: bool,
}
/// Simplified quality gate verdict.
#[derive(Debug, Clone, PartialEq)]
pub enum Verdict {
/// Reading passed all quality gates and is reliable.
Permit,
/// Reading failed quality checks with a reason.
Deny(String),
/// Evidence still accumulating.
Defer,
}
impl Verdict {
/// Returns true if this verdict permits the reading.
#[must_use]
pub fn is_permit(&self) -> bool {
matches!(self, Self::Permit)
}
}
/// Compute a quality score from pipeline metrics.
#[allow(clippy::cast_precision_loss)]
fn compute_quality_score(
n_active: usize,
weighted_variance: f64,
mean_correlation: f64,
drift: bool,
) -> f64 {
// 1. Number of active BSSIDs (more = better, diminishing returns)
let bssid_factor = (n_active as f64 / 10.0).min(1.0);
// 2. Evidence strength (higher weighted variance = more signal)
let evidence_factor = (weighted_variance * 10.0).min(1.0);
// 3. Correlation coherence (moderate correlation is best)
let corr_factor = 1.0 - (mean_correlation - 0.5).abs() * 2.0;
// 4. Drift penalty
let drift_penalty = if drift { 0.7 } else { 1.0 };
let raw =
(bssid_factor * 0.3 + evidence_factor * 0.4 + corr_factor.max(0.0) * 0.3) * drift_penalty;
raw.clamp(0.0, 1.0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn new_gate_creates_ok() {
let gate = QualityGate::new();
assert!((gate.evidence - 0.0).abs() < f64::EPSILON);
}
#[test]
fn evaluate_with_good_signal() {
let mut gate = QualityGate::new();
// Pump several frames to build evidence.
for _ in 0..20 {
gate.evaluate(10, -60.0, 0.5, 0.3);
}
let result = gate.evaluate(10, -60.0, 0.5, 0.3);
assert!(result.quality > 0.0, "quality should be positive");
assert!(result.verdict.is_permit(), "should permit good signal");
}
#[test]
fn too_few_bssids_denied() {
let mut gate = QualityGate::new();
let result = gate.evaluate(1, -60.0, 0.5, 0.3);
assert!(
matches!(result.verdict, Verdict::Deny(_)),
"too few BSSIDs should be denied"
);
}
#[test]
fn quality_increases_with_more_bssids() {
let q_few = compute_quality_score(3, 0.1, 0.5, false);
let q_many = compute_quality_score(10, 0.1, 0.5, false);
assert!(q_many > q_few, "more BSSIDs should give higher quality");
}
#[test]
fn drift_reduces_quality() {
let q_stable = compute_quality_score(5, 0.1, 0.5, false);
let q_drift = compute_quality_score(5, 0.1, 0.5, true);
assert!(q_drift < q_stable, "drift should reduce quality");
}
#[test]
fn verdict_is_permit_check() {
assert!(Verdict::Permit.is_permit());
assert!(!Verdict::Deny("test".to_string()).is_permit());
assert!(!Verdict::Defer.is_permit());
}
#[test]
fn default_creates_gate() {
let _gate = QualityGate::default();
}
#[test]
fn reset_clears_state() {
let mut gate = QualityGate::new();
gate.evaluate(10, -60.0, 0.5, 0.3);
gate.reset();
assert!(gate.prev_mean_rssi.is_none());
assert!((gate.evidence - 0.0).abs() < f64::EPSILON);
}
}