diff --git a/v2/crates/wifi-densepose-vitals/Cargo.toml b/v2/crates/wifi-densepose-vitals/Cargo.toml index 6f420bf2..ccf177e9 100644 --- a/v2/crates/wifi-densepose-vitals/Cargo.toml +++ b/v2/crates/wifi-densepose-vitals/Cargo.toml @@ -17,6 +17,11 @@ serde = { workspace = true, optional = true } [dev-dependencies] serde_json.workspace = true +criterion = { version = "0.5", features = ["html_reports"] } + +[[bench]] +name = "vitals_bench" +harness = false [features] default = ["serde"] diff --git a/v2/crates/wifi-densepose-vitals/benches/vitals_bench.rs b/v2/crates/wifi-densepose-vitals/benches/vitals_bench.rs new file mode 100644 index 00000000..04aedcbb --- /dev/null +++ b/v2/crates/wifi-densepose-vitals/benches/vitals_bench.rs @@ -0,0 +1,75 @@ +//! Benchmarks for the vital-sign extractor hot paths (ADR-157 §D1). +//! +//! The extractors maintain a fixed-length sliding window of filtered samples. +//! The window was previously a `Vec` whose oldest-sample eviction used +//! `Vec::remove(0)` — an O(n) shift of the whole buffer on every sample, making +//! a full-window `extract()` sweep O(n²). ADR-157 §A1 switched the window to a +//! `VecDeque` (O(1) `push_back` + `pop_front`, with one `make_contiguous` +//! per call for the autocorrelation / zero-crossing loop). +//! +//! These benches measure the payoff: a full-window fill of each extractor +//! (`HeartRateExtractor` ~1500 samples, `BreathingExtractor` ~3000 samples). +//! Each iteration drives the extractor from empty to a full window so the +//! per-sample eviction cost (the thing A1 changed) is exercised across the +//! entire buffer. +//! +//! Reproduce: +//! cargo bench -p wifi-densepose-vitals --bench vitals_bench +//! Compile-only: +//! cargo bench -p wifi-densepose-vitals --bench vitals_bench --no-run + +use criterion::{black_box, criterion_group, criterion_main, Criterion}; +use wifi_densepose_vitals::{BreathingExtractor, HeartRateExtractor}; + +/// Drive a heart-rate extractor from empty to a full window. +/// +/// `fs = 100 Hz`, `window = 15 s` -> 1500 samples. A few coherent subcarriers +/// of a synthetic cardiac sinusoid are fed each frame; the point of the bench +/// is the sliding-window bookkeeping, not the signal content. +fn bench_heartrate_full_window(c: &mut Criterion) { + let sample_rate = 100.0; + let window_secs = 15.0; + let n_frames = (sample_rate * window_secs) as usize; // 1500 + let heart_freq = 1.2; // 72 BPM + + c.bench_function("heartrate_extract_full_window_1500", |b| { + b.iter(|| { + let mut ext = HeartRateExtractor::new(4, sample_rate, window_secs); + for i in 0..n_frames { + let t = i as f64 / sample_rate; + let base = (2.0 * std::f64::consts::PI * heart_freq * t).sin(); + let residuals = [base * 0.1, base * 0.08, base * 0.12, base * 0.09]; + let phases = [0.0, 0.01, 0.02, 0.03]; + black_box(ext.extract(black_box(&residuals), black_box(&phases))); + } + black_box(ext.history_len()); + }); + }); +} + +/// Drive a breathing extractor from empty to a full window. +/// +/// `fs = 100 Hz`, `window = 30 s` -> 3000 samples. +fn bench_breathing_full_window(c: &mut Criterion) { + let sample_rate = 100.0; + let window_secs = 30.0; + let n_frames = (sample_rate * window_secs) as usize; // 3000 + let breathing_freq = 0.25; // 15 BPM + + c.bench_function("breathing_extract_full_window_3000", |b| { + b.iter(|| { + let mut ext = BreathingExtractor::new(4, sample_rate, window_secs); + for i in 0..n_frames { + let t = i as f64 / sample_rate; + let s = (2.0 * std::f64::consts::PI * breathing_freq * t).sin(); + let residuals = [s, s * 0.9, s * 1.1, s * 0.95]; + let weights = [0.25, 0.25, 0.25, 0.25]; + black_box(ext.extract(black_box(&residuals), black_box(&weights))); + } + black_box(ext.history_len()); + }); + }); +} + +criterion_group!(benches, bench_heartrate_full_window, bench_breathing_full_window); +criterion_main!(benches); diff --git a/v2/crates/wifi-densepose-vitals/src/anomaly.rs b/v2/crates/wifi-densepose-vitals/src/anomaly.rs index ae69be0e..022f2aeb 100644 --- a/v2/crates/wifi-densepose-vitals/src/anomaly.rs +++ b/v2/crates/wifi-densepose-vitals/src/anomaly.rs @@ -9,6 +9,7 @@ //! for numerically stable running statistics. use crate::types::VitalReading; +use std::collections::VecDeque; #[cfg(feature = "serde")] use serde::{Deserialize, Serialize}; @@ -80,10 +81,10 @@ pub struct VitalAnomalyDetector { rr_stats: WelfordStats, /// Running statistics for heart rate. hr_stats: WelfordStats, - /// Recent respiratory rate values for windowed analysis. - rr_history: Vec, - /// Recent heart rate values for windowed analysis. - hr_history: Vec, + /// Recent respiratory rate values for windowed analysis (O(1) eviction). + rr_history: VecDeque, + /// Recent heart rate values for windowed analysis (O(1) eviction). + hr_history: VecDeque, /// Maximum window size for history. window: usize, /// Z-score threshold for anomaly detection. @@ -100,8 +101,8 @@ impl VitalAnomalyDetector { Self { rr_stats: WelfordStats::new(), hr_stats: WelfordStats::new(), - rr_history: Vec::with_capacity(window), - hr_history: Vec::with_capacity(window), + rr_history: VecDeque::with_capacity(window), + hr_history: VecDeque::with_capacity(window), window, z_threshold, } @@ -123,14 +124,15 @@ impl VitalAnomalyDetector { let rr = reading.respiratory_rate.value_bpm; let hr = reading.heart_rate.value_bpm; - // Update histories - self.rr_history.push(rr); + // Update histories. `VecDeque` evicts the oldest in O(1) (was a `Vec` + // with an O(n) `remove(0)` — ADR-157 §A1). + self.rr_history.push_back(rr); if self.rr_history.len() > self.window { - self.rr_history.remove(0); + self.rr_history.pop_front(); } - self.hr_history.push(hr); + self.hr_history.push_back(hr); if self.hr_history.len() > self.window { - self.hr_history.remove(0); + self.hr_history.pop_front(); } // Update running statistics diff --git a/v2/crates/wifi-densepose-vitals/src/store.rs b/v2/crates/wifi-densepose-vitals/src/store.rs index 8c08bc30..ac79fd46 100644 --- a/v2/crates/wifi-densepose-vitals/src/store.rs +++ b/v2/crates/wifi-densepose-vitals/src/store.rs @@ -5,11 +5,14 @@ //! becomes available (ADR-021 phase 2). use crate::types::{VitalReading, VitalStatus}; +use std::collections::VecDeque; /// Simple vital sign store with capacity-limited ring buffer semantics. pub struct VitalSignStore { - /// Stored readings (oldest first). - readings: Vec, + /// Stored readings (oldest first). A `VecDeque` so eviction of the oldest + /// reading at capacity is O(1) instead of an O(n) `Vec::remove(0)` + /// (ADR-157 §A1). + readings: VecDeque, /// Maximum number of readings to retain. max_readings: usize, } @@ -42,7 +45,7 @@ impl VitalSignStore { #[must_use] pub fn new(max_readings: usize) -> Self { Self { - readings: Vec::with_capacity(max_readings.min(4096)), + readings: VecDeque::with_capacity(max_readings.min(4096)), max_readings: max_readings.max(1), } } @@ -58,24 +61,27 @@ impl VitalSignStore { /// If the store is at capacity, the oldest reading is evicted. pub fn push(&mut self, reading: VitalReading) { if self.readings.len() >= self.max_readings { - self.readings.remove(0); + self.readings.pop_front(); } - self.readings.push(reading); + self.readings.push_back(reading); } /// Get the most recent reading, if any. #[must_use] pub fn latest(&self) -> Option<&VitalReading> { - self.readings.last() + self.readings.back() } /// Get the last `n` readings (most recent last). /// - /// Returns fewer than `n` if the store contains fewer readings. - #[must_use] - pub fn history(&self, n: usize) -> &[VitalReading] { - let start = self.readings.len().saturating_sub(n); - &self.readings[start..] + /// Returns fewer than `n` if the store contains fewer readings. Takes + /// `&mut self` because the backing `VecDeque` is rotated in place once + /// (`make_contiguous`) to hand back a single contiguous slice; the + /// observable contents are unchanged. + pub fn history(&mut self, n: usize) -> &[VitalReading] { + let contiguous = self.readings.make_contiguous(); + let start = contiguous.len().saturating_sub(n); + &contiguous[start..] } /// Compute summary statistics over all stored readings. diff --git a/v2/crates/wifi-densepose-wifiscan/src/pipeline/breathing_extractor.rs b/v2/crates/wifi-densepose-wifiscan/src/pipeline/breathing_extractor.rs index 00969649..893ff376 100644 --- a/v2/crates/wifi-densepose-wifiscan/src/pipeline/breathing_extractor.rs +++ b/v2/crates/wifi-densepose-wifiscan/src/pipeline/breathing_extractor.rs @@ -5,10 +5,12 @@ //! analysis rather than `OscillatoryRouter` (which is designed for //! gamma-band frequencies, not sub-Hz breathing). +use std::collections::VecDeque; + /// Coarse breathing extractor from multi-BSSID signal variance. pub struct CoarseBreathingExtractor { - /// Combined filtered signal history. - filtered_history: Vec, + /// Combined filtered signal history (sliding window; O(1) push/pop). + filtered_history: VecDeque, /// Window size for analysis. window: usize, /// Maximum tracked BSSIDs. @@ -55,7 +57,7 @@ impl CoarseBreathingExtractor { 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), + filtered_history: VecDeque::with_capacity(window), window, n_bssids, freq_low, @@ -97,10 +99,11 @@ impl CoarseBreathingExtractor { // Apply bandpass filter let filtered = self.bandpass_filter(weighted_signal); - // Store in history - self.filtered_history.push(filtered); + // Store in history. `VecDeque` evicts the oldest sample in O(1) (was a + // `Vec` with an O(n) `remove(0)` per sample — ADR-157 §A1). + self.filtered_history.push_back(filtered); if self.filtered_history.len() > self.window { - self.filtered_history.remove(0); + self.filtered_history.pop_front(); } // Need at least 10 seconds of data to estimate breathing @@ -110,10 +113,12 @@ impl CoarseBreathingExtractor { return None; } - // Zero-crossing rate -> frequency - let crossings = count_zero_crossings(&self.filtered_history); + // Zero-crossing rate -> frequency. `make_contiguous` rotates the ring + // buffer in place once so the slice helpers below can borrow it. + let history = self.filtered_history.make_contiguous(); + let crossings = count_zero_crossings(history); #[allow(clippy::cast_precision_loss)] - let duration_s = self.filtered_history.len() as f32 / self.sample_rate; + let duration_s = history.len() as f32 / self.sample_rate; #[allow(clippy::cast_precision_loss)] let frequency_hz = crossings as f32 / (2.0 * duration_s); @@ -125,7 +130,7 @@ impl CoarseBreathingExtractor { let bpm = frequency_hz * 60.0; // Compute confidence based on signal regularity - let confidence = compute_confidence(&self.filtered_history); + let confidence = compute_confidence(history); Some(BreathingEstimate { bpm, diff --git a/v2/crates/wifi-densepose-wifiscan/src/pipeline/correlator.rs b/v2/crates/wifi-densepose-wifiscan/src/pipeline/correlator.rs index a412efd6..92e89c36 100644 --- a/v2/crates/wifi-densepose-wifiscan/src/pipeline/correlator.rs +++ b/v2/crates/wifi-densepose-wifiscan/src/pipeline/correlator.rs @@ -5,6 +5,8 @@ //! A single message-passing step identifies co-varying BSSID clusters //! that are likely affected by the same person. +use std::collections::VecDeque; + /// BSSID correlator that computes pairwise Pearson correlation /// and identifies co-varying clusters. /// @@ -12,8 +14,10 @@ /// 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>, + /// Per-BSSID history buffers for correlation computation. Each is a + /// `VecDeque` so the per-frame oldest-sample eviction is O(1) instead of + /// an O(n) `Vec::remove(0)` (ADR-157 §A1). + histories: Vec>, /// Maximum history length. window: usize, /// Number of tracked BSSIDs. @@ -31,7 +35,7 @@ impl BssidCorrelator { #[must_use] pub fn new(n_bssids: usize, window: usize, correlation_threshold: f32) -> Self { Self { - histories: vec![Vec::with_capacity(window); n_bssids], + histories: vec![VecDeque::with_capacity(window); n_bssids], window, n_bssids, correlation_threshold, @@ -45,22 +49,26 @@ impl BssidCorrelator { pub fn update(&mut self, amplitudes: &[f32]) -> CorrelationResult { let n = amplitudes.len().min(self.n_bssids); - // Update histories + // Update histories. O(1) eviction via `VecDeque::pop_front`, and + // contiguous-ize each touched buffer once so the correlation pass below + // can borrow it as a slice (`pearson_r` takes `&[f32]`). for (i, &) in amplitudes.iter().enumerate().take(n) { let hist = &mut self.histories[i]; - hist.push(amp); + hist.push_back(amp); if hist.len() > self.window { - hist.remove(0); + hist.pop_front(); } + hist.make_contiguous(); } - // Compute pairwise Pearson correlation + // Compute pairwise Pearson correlation. Each history is already + // contiguous (above), so `as_slices().0` is the full buffer. 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]); + let r = pearson_r(self.histories[i].as_slices().0, self.histories[j].as_slices().0); corr_matrix[i][j] = r; corr_matrix[j][i] = r; }