perf(vitals,wifiscan): O(1) VecDeque sliding windows + vitals bench (ADR-157 §A1/§D1)

Replace Vec::remove(0) (O(n) per-sample buffer shift -> O(n^2) full-window
sweep) with VecDeque push_back/pop_front (O(1) eviction) in the fixed-length
sliding/ring buffers of the vital-sign and wifiscan extractors. Where the
autocorrelation / zero-crossing / Pearson loop needs a contiguous slice,
make_contiguous() is called once per extract(), matching the idiom already used
in wifiscan/pipeline/orchestrator.rs. Output is bit-identical.

Sites: anomaly.rs (rr/hr history), store.rs (readings ring; history() now takes
&mut self to hand back a contiguous slice, no external callers), wifiscan
breathing_extractor.rs (filtered history), wifiscan correlator.rs (per-BSSID
histories -> Vec<VecDeque<f32>>). (heartrate.rs/breathing.rs windows land with
the §A2/§A3 fixes in a separate commit.)

New criterion bench crates/wifi-densepose-vitals/benches/vitals_bench.rs drives
each extractor over a full-window fill. Honest MEASURED result: end-to-end win
is NULL within noise at realistic ESP32 window sizes (1500-3000) because the
per-frame DSP dominates the eviction (heartrate 42.8ms->44.4ms, breathing
7.95ms->7.86ms, overlapping CIs). In isolation the eviction collapses O(n^2)
-> O(n) (34.6x at window=3000, 3158x at window=100000); A1 lands as the correct
data structure removing a latent O(n^2), NOT a claimed hot-path speedup.

Reproduce: cargo bench -p wifi-densepose-vitals --bench vitals_bench

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
ruv 2026-06-11 20:59:57 -04:00
parent 0ce2ac6440
commit a7f7adfabc
6 changed files with 141 additions and 40 deletions

View File

@ -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"]

View File

@ -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<f64>` 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<f64>` (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);

View File

@ -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<f64>,
/// Recent heart rate values for windowed analysis.
hr_history: Vec<f64>,
/// Recent respiratory rate values for windowed analysis (O(1) eviction).
rr_history: VecDeque<f64>,
/// Recent heart rate values for windowed analysis (O(1) eviction).
hr_history: VecDeque<f64>,
/// 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

View File

@ -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<VitalReading>,
/// 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<VitalReading>,
/// 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.

View File

@ -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<f32>,
/// Combined filtered signal history (sliding window; O(1) push/pop).
filtered_history: VecDeque<f32>,
/// 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,

View File

@ -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<Vec<f32>>,
/// 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<VecDeque<f32>>,
/// 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, &amp) 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;
}