From 79d1fff99a53e116dfceb01bf0d399875bff5da0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Aug 2026 00:06:39 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20implement=20ADR-288/289/290=20=E2=80=94?= =?UTF-8?q?=20benchmark=20harness,=20wideband=20CSI=20ingest,=20vitals=20g?= =?UTF-8?q?round-truth=20rig?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR-288 (wifi-densepose-train): Widar3.0 Intel-5300 .dat bfee parser (bounded, panic-free), WidarDataset over the CsiDataset trait, deterministic SplitProtocol (cross-subject/environment/orientation + leakage-prone random baseline), LeakageAudit that Errs on subject/environment/recording overlap, train-only MeanPoseBaseline, and EvidenceGrade where Measured requires an embedded reproducer. Criterion bench for parser + split + audit. ADR-289 (wifi-densepose-mat): FeitCSI binary record parser (layout verified against upstream source) with dimension-vs-buffer validation and bounded allocation, DeviceType::FeitCsi replay/stream modes, subcarrier-agnostic frame metadata (bandwidth/band/native->pipeline mapping). Criterion bench at 1992-subcarrier frames. ADR-290 (wifi-densepose-vitals): reference-series CSV ingest, cross-correlation time alignment with optional drift fit, Bland-Altman/MAE/RMSE agreement with mandatory SessionScope, and EvidenceGrade gating Measured on reference device + coverage + reproducer. Criterion bench for hour-scale alignment. All three crate suites green; benches compile. Numbers are SYNTHETIC (in-code fixtures); no hardware claims. Co-Authored-By: claude-flow Claude-Session: https://claude.ai/code/session_015TcKegTS7QqhWPC2L2SzaS --- v2/crates/wifi-densepose-mat/Cargo.toml | 5 + .../benches/feitcsi_bench.rs | 69 + .../src/integration/csi_receiver.rs | 3 + .../src/integration/feitcsi.rs | 983 ++++++++ .../src/integration/hardware_adapter.rs | 443 +++- .../wifi-densepose-mat/src/integration/mod.rs | 17 + v2/crates/wifi-densepose-train/Cargo.toml | 6 + .../benches/benchmark_harness.rs | 127 ++ v2/crates/wifi-densepose-train/src/dataset.rs | 4 + .../wifi-densepose-train/src/dataset/widar.rs | 1200 ++++++++++ v2/crates/wifi-densepose-train/src/error.rs | 102 +- v2/crates/wifi-densepose-train/src/lib.rs | 14 +- .../wifi-densepose-train/src/protocols.rs | 388 ++++ .../src/protocols/leakage.rs | 703 ++++++ v2/crates/wifi-densepose-vitals/Cargo.toml | 4 + .../benches/groundtruth_bench.rs | 136 ++ .../wifi-densepose-vitals/src/groundtruth.rs | 2001 +++++++++++++++++ v2/crates/wifi-densepose-vitals/src/lib.rs | 13 + 18 files changed, 6215 insertions(+), 3 deletions(-) create mode 100644 v2/crates/wifi-densepose-mat/benches/feitcsi_bench.rs create mode 100644 v2/crates/wifi-densepose-mat/src/integration/feitcsi.rs create mode 100644 v2/crates/wifi-densepose-train/benches/benchmark_harness.rs create mode 100644 v2/crates/wifi-densepose-train/src/dataset/widar.rs create mode 100644 v2/crates/wifi-densepose-train/src/protocols.rs create mode 100644 v2/crates/wifi-densepose-train/src/protocols/leakage.rs create mode 100644 v2/crates/wifi-densepose-vitals/benches/groundtruth_bench.rs create mode 100644 v2/crates/wifi-densepose-vitals/src/groundtruth.rs diff --git a/v2/crates/wifi-densepose-mat/Cargo.toml b/v2/crates/wifi-densepose-mat/Cargo.toml index cea80cdf..99b4dc4b 100644 --- a/v2/crates/wifi-densepose-mat/Cargo.toml +++ b/v2/crates/wifi-densepose-mat/Cargo.toml @@ -101,6 +101,11 @@ approx = "0.5" name = "detection_bench" harness = false +# FeitCSI record parse throughput at wideband 802.11ax shapes (ADR-289). +[[bench]] +name = "feitcsi_bench" +harness = false + [package.metadata.docs.rs] all-features = true rustdoc-args = ["--cfg", "docsrs"] diff --git a/v2/crates/wifi-densepose-mat/benches/feitcsi_bench.rs b/v2/crates/wifi-densepose-mat/benches/feitcsi_bench.rs new file mode 100644 index 00000000..b57e9014 --- /dev/null +++ b/v2/crates/wifi-densepose-mat/benches/feitcsi_bench.rs @@ -0,0 +1,69 @@ +//! Criterion benchmark for FeitCSI record parse throughput (ADR-289). +//! +//! Measures `parse_record` over synthetic in-code fixtures at the wideband +//! 802.11ax shapes: 20 MHz (242 tones), 80 MHz (996) and the headline +//! 160 MHz / 1992-subcarrier frames an AX210 delivers. Fixtures are +//! deterministic; no wall-clock or randomness feeds the parsed bytes. + +use criterion::{black_box, criterion_group, criterion_main, Criterion, Throughput}; +use wifi_densepose_mat::integration::feitcsi::{parse_record, synth, FeitCsiStreamReader}; + +fn bench_parse(c: &mut Criterion) { + let mut group = c.benchmark_group("feitcsi_parse"); + + // (label, chan_width_val, HE tone count) + let shapes = [ + ("he20_242sc", 0u32, 242u32), + ("he80_996sc", 2u32, 996u32), + ("he160_1992sc", 3u32, 1992u32), + ]; + + for (label, cw, sc) in shapes { + // 2x1 MIMO, HE modulation, fixed timestamp: deterministic bytes. + let bytes = synth::record_bytes(2, 1, sc, cw, 4, 1_000_000); + group.throughput(Throughput::Bytes(bytes.len() as u64)); + group.bench_function(label, |b| { + b.iter(|| { + let (record, consumed) = + parse_record(black_box(&bytes)).expect("valid synthetic record"); + black_box((record.csi.len(), consumed)) + }) + }); + } + + group.finish(); +} + +/// Stream-reader throughput over an in-memory multi-record capture at the +/// headline 160 MHz / 1992-subcarrier shape. Exercises the reusable payload +/// scratch buffer in `FeitCsiStreamReader` (one raw-byte allocation per +/// stream, not per record). +fn bench_stream(c: &mut Criterion) { + let mut group = c.benchmark_group("feitcsi_stream"); + + const RECORDS: u64 = 16; + let mut capture = Vec::new(); + for ts in 0..RECORDS { + // 2x1 MIMO, 160 MHz HE, deterministic device timestamps. + capture.extend_from_slice(&synth::record_bytes(2, 1, 1992, 3, 4, ts * 1_000)); + } + + group.throughput(Throughput::Bytes(capture.len() as u64)); + group.bench_function("he160_1992sc_x16_records", |b| { + b.iter(|| { + let mut reader = FeitCsiStreamReader::new(std::io::Cursor::new(black_box(&capture[..]))); + let mut records = 0u64; + while let Some(rec) = reader.read_next().expect("valid synthetic capture") { + black_box(rec.csi.len()); + records += 1; + } + assert_eq!(records, RECORDS); + black_box(records) + }) + }); + + group.finish(); +} + +criterion_group!(benches, bench_parse, bench_stream); +criterion_main!(benches); diff --git a/v2/crates/wifi-densepose-mat/src/integration/csi_receiver.rs b/v2/crates/wifi-densepose-mat/src/integration/csi_receiver.rs index 06de25a2..3c1b3014 100644 --- a/v2/crates/wifi-densepose-mat/src/integration/csi_receiver.rs +++ b/v2/crates/wifi-densepose-mat/src/integration/csi_receiver.rs @@ -1293,6 +1293,9 @@ impl From for CsiReadings { rssi: Some(packet.rssi as f64), noise_floor: Some(packet.noise_floor as f64), fc_type: FrameControlType::Data, + // Narrowband receiver formats predate wideband provenance + // metadata; FeitCSI ingest attaches it in feitcsi.rs. + wideband: None, }, } } diff --git a/v2/crates/wifi-densepose-mat/src/integration/feitcsi.rs b/v2/crates/wifi-densepose-mat/src/integration/feitcsi.rs new file mode 100644 index 00000000..8a883170 --- /dev/null +++ b/v2/crates/wifi-densepose-mat/src/integration/feitcsi.rs @@ -0,0 +1,983 @@ +//! Validated parser for FeitCSI binary CSI records (ADR-289). +//! +//! [FeitCSI](https://feitcsi.kuskosoft.com) is an open-source tool +//! () that extracts 802.11ax channel +//! state information from Intel AX200/AX210 NICs at 20/40/80/160 MHz, +//! including the 6 GHz band. FeitCSI is GPL and is used strictly as an +//! *external* capture tool: RuView never links it, never configures the NIC, +//! and only parses the record files/streams its tooling produces. +//! +//! # Record layout (verified against FeitCSI source, `master` @ 2026-08-10) +//! +//! Each record is a packed 272-byte header followed by `csi_data_size` bytes +//! of raw CSI. Layout per `include/Csi.h` (`struct __attribute__((__packed__)) +//! RawHeaderData`) and `Csi::save()` in `src/Csi.cpp`, which writes the raw +//! struct memory followed by the CSI buffer. FeitCSI runs on little-endian +//! x86 hosts and dumps native struct memory, so all fields are little-endian. +//! +//! | Offset | Size | Field | Notes | +//! |-------:|-----:|------------------|-----------------------------------------| +//! | 0 | 4 | `csiDataSize` | u32, bytes of CSI payload after header | +//! | 4 | 4 | reserved | (`space4`) | +//! | 8 | 4 | `ftmClock` | u32 | +//! | 12 | 8 | `timestamp` | u64, device timestamp (microseconds) | +//! | 20 | 26 | reserved | (`space20`) | +//! | 46 | 1 | `numRx` | u8, receive antennas | +//! | 47 | 1 | `numTx` | u8, transmit streams | +//! | 48 | 4 | reserved | (`space48`) | +//! | 52 | 4 | `numSubCarriers` | u32 | +//! | 56 | 4 | reserved | (`space54`; upstream field name lags | +//! | | | | the actual packed offset) | +//! | 60 | 4 | `rssi1` | u32, antenna A RSSI | +//! | 64 | 4 | `rssi2` | u32, antenna B RSSI | +//! | 68 | 6 | `srcMac` | source MAC address | +//! | 74 | 18 | reserved | (`space75`) | +//! | 92 | 4 | `rateNflag` | u32, iwlwifi rate flags (see below) | +//! | 96 | 176 | reserved | (`space96`, 44 × u32) | +//! +//! CSI payload: interleaved little-endian `i16` I/Q pairs, iterated +//! `for rx { for tx { for subcarrier { i16 real, i16 imag } } }` (per the +//! processing loops in `src/Csi.cpp`), i.e. 4 bytes per complex sample and +//! `csiDataSize == numRx * numTx * numSubCarriers * 4`. +//! +//! `rateNflag` uses the iwlwifi rate/flags encoding vendored by FeitCSI in +//! `lib/include/rs.h`: modulation type in bits 8..11 (`RATE_MCS_MOD_TYPE`, +//! 0=CCK, 1=legacy OFDM, 2=HT, 3=VHT, 4=HE, 5=EHT) and channel width in bits +//! 11..14 (`RATE_MCS_CHAN_WIDTH`, 0=20 MHz, 1=40, 2=80, 3=160, 4=320). +//! +//! # Failing loudly on format drift +//! +//! The on-disk format carries **no magic number or version field** (it is the +//! raw iwlwifi notification header), so the "version check" required by +//! ADR-289 is structural and strict: +//! +//! - the declared dimensions must agree exactly with the declared buffer +//! length ([`FeitCsiError::DimensionMismatch`]); +//! - dimensions are hard-capped ([`MAX_SUBCARRIERS`], [`MAX_ANTENNAS`]) so a +//! corrupt length can never cause unbounded allocation +//! ([`FeitCsiError::CapExceeded`]); +//! - `rateNflag` values outside the vendored `rs.h` encoding (unknown +//! modulation type, or a channel width this parser does not support, e.g. +//! 320 MHz EHT) are rejected as [`FeitCsiError::UnsupportedFormat`] instead +//! of being misparsed. +//! +//! All input is untrusted: every read is length-checked, allocation is +//! bounded before it happens, and malformed input yields structured errors, +//! never a panic. + +use super::hardware_adapter::{ + Bandwidth, CsiMetadata, CsiReadings, DeviceType, FrameControlType, SensorCsiReading, + SubcarrierMapping, WidebandMeta, WifiBand, +}; +use super::AdapterError; +use chrono::{DateTime, Utc}; +use num_complex::Complex64; +use std::io::Read; + +/// Size of the packed FeitCSI record header in bytes. +pub const HEADER_LEN: usize = 272; + +/// Hard cap on the declared subcarrier count (802.11ax 160 MHz HE is 1992; +/// 4096 leaves headroom for future 802.11bf truncated-CIR shapes without +/// permitting unbounded allocation from a corrupt length field). +pub const MAX_SUBCARRIERS: u32 = 4096; + +/// Hard cap on declared antenna/stream counts (AX210 is 2x2; 8 is generous). +pub const MAX_ANTENNAS: u8 = 8; + +/// Bytes per complex CSI sample (i16 real + i16 imag). +const BYTES_PER_SAMPLE: usize = 4; + +/// Upper bound on a single record's total size (header + max payload). +/// Used to bound stream-mode buffering. +pub const MAX_RECORD_BYTES: usize = HEADER_LEN + + MAX_ANTENNAS as usize * MAX_ANTENNAS as usize * MAX_SUBCARRIERS as usize * BYTES_PER_SAMPLE; + +// iwlwifi rate flag encoding, per FeitCSI `lib/include/rs.h`. +const RATE_MCS_MOD_TYPE_POS: u32 = 8; +const RATE_MCS_MOD_TYPE_MSK: u32 = 0x7 << RATE_MCS_MOD_TYPE_POS; +const RATE_MCS_CHAN_WIDTH_POS: u32 = 11; +const RATE_MCS_CHAN_WIDTH_MSK: u32 = 0x7 << RATE_MCS_CHAN_WIDTH_POS; + +/// Structured errors for FeitCSI record parsing. Malformed input always +/// yields one of these — the parser never panics on untrusted bytes. +#[derive(Debug, thiserror::Error)] +pub enum FeitCsiError { + /// The buffer ends before the declared record does. + #[error("truncated FeitCSI record: need {needed} bytes, got {got}")] + Truncated { + /// Bytes required to complete the header or record. + needed: usize, + /// Bytes actually available. + got: usize, + }, + + /// The declared CSI buffer length disagrees with the declared dimensions. + #[error( + "FeitCSI dimension mismatch: header declares csi_data_size={declared} \ + but num_rx={num_rx} * num_tx={num_tx} * num_subcarriers={num_subcarriers} \ + * 4 = {expected}" + )] + DimensionMismatch { + /// `csiDataSize` from the header. + declared: u32, + /// Size implied by the dimension fields. + expected: usize, + /// Declared receive antenna count. + num_rx: u8, + /// Declared transmit stream count. + num_tx: u8, + /// Declared subcarrier count. + num_subcarriers: u32, + }, + + /// A dimension field is zero — the record cannot contain CSI. + #[error("FeitCSI record declares zero-sized dimension: {field}")] + ZeroDimension { + /// Which header field was zero. + field: &'static str, + }, + + /// A dimension exceeds its hard cap; parsing stops before any + /// allocation sized from the corrupt value. + #[error("FeitCSI {field}={value} exceeds hard cap {cap} (corrupt or hostile length)")] + CapExceeded { + /// Which header field exceeded its cap. + field: &'static str, + /// The declared value. + value: u64, + /// The enforced cap. + cap: u64, + }, + + /// `rateNflag` encodes a modulation type or channel width outside the + /// layout this parser was written against — fail loudly instead of + /// misparsing a newer/unknown format revision. + #[error( + "unsupported FeitCSI rate flags {rate_n_flags:#010x}: {reason} \ + (layout per FeitCSI master @ 2026-08-10; refusing to guess)" + )] + UnsupportedFormat { + /// Raw `rateNflag` value. + rate_n_flags: u32, + /// Which sub-field was unrecognized. + reason: &'static str, + }, + + /// I/O error while reading a record from a file or stream. + #[error("FeitCSI I/O error: {0}")] + Io(#[from] std::io::Error), +} + +impl From for AdapterError { + fn from(e: FeitCsiError) -> Self { + match e { + FeitCsiError::UnsupportedFormat { .. } => AdapterError::UnsupportedAdapter(e.to_string()), + FeitCsiError::Io(io) => AdapterError::Io(io), + _ => AdapterError::DataFormat(e.to_string()), + } + } +} + +/// Modulation type decoded from `rateNflag` (iwlwifi `RATE_MCS_MOD_TYPE`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FeitCsiModType { + /// Legacy CCK (802.11b) + Cck, + /// Legacy OFDM (802.11a/g) + LegacyOfdm, + /// HT (802.11n) + Ht, + /// VHT (802.11ac) + Vht, + /// HE (802.11ax) + He, + /// EHT (802.11be) + Eht, +} + +/// Channel bandwidth decoded from `rateNflag` (iwlwifi `RATE_MCS_CHAN_WIDTH`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FeitCsiBandwidth { + /// 20 MHz + Bw20, + /// 40 MHz + Bw40, + /// 80 MHz + Bw80, + /// 160 MHz + Bw160, +} + +impl FeitCsiBandwidth { + /// Bandwidth in MHz. + pub fn mhz(&self) -> u16 { + match self { + Self::Bw20 => 20, + Self::Bw40 => 40, + Self::Bw80 => 80, + Self::Bw160 => 160, + } + } + + /// Map to the adapter-level [`Bandwidth`] enum. + pub fn to_bandwidth(&self) -> Bandwidth { + match self { + Self::Bw20 => Bandwidth::HT20, + Self::Bw40 => Bandwidth::HT40, + Self::Bw80 => Bandwidth::VHT80, + Self::Bw160 => Bandwidth::VHT160, + } + } +} + +/// Validated header fields of one FeitCSI record. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FeitCsiHeader { + /// Declared CSI payload size in bytes (already validated against dims). + pub csi_data_size: u32, + /// FTM clock value. + pub ftm_clock: u32, + /// Device timestamp (microseconds, per upstream usage). + pub timestamp_us: u64, + /// Number of receive antennas. + pub num_rx: u8, + /// Number of transmit streams. + pub num_tx: u8, + /// Native subcarrier count of this frame. + pub num_subcarriers: u32, + /// Antenna A RSSI (raw u32 as stored on disk). + pub rssi1: u32, + /// Antenna B RSSI (raw u32 as stored on disk). + pub rssi2: u32, + /// Source MAC address. + pub source_mac: [u8; 6], + /// Raw iwlwifi rate flags. + pub rate_n_flags: u32, + /// Modulation type decoded from `rate_n_flags`. + pub mod_type: FeitCsiModType, + /// Channel bandwidth decoded from `rate_n_flags`. + pub bandwidth: FeitCsiBandwidth, +} + +/// One parsed FeitCSI record: validated header plus complex CSI, kept at +/// native dimensionality (`num_rx * num_tx * num_subcarriers` samples, +/// iterated rx-major, then tx, then subcarrier). +#[derive(Debug, Clone, PartialEq)] +pub struct FeitCsiRecord { + /// Validated header. + pub header: FeitCsiHeader, + /// Complex CSI samples, flattened `[rx][tx][subcarrier]`. + pub csi: Vec, +} + +impl FeitCsiRecord { + /// CSI for one (rx, tx) antenna pair as a subcarrier slice, or `None` + /// when the indices are out of range. + pub fn antenna_pair(&self, rx: u8, tx: u8) -> Option<&[Complex64]> { + if rx >= self.header.num_rx || tx >= self.header.num_tx { + return None; + } + let sc = self.header.num_subcarriers as usize; + let start = (rx as usize * self.header.num_tx as usize + tx as usize) * sc; + self.csi.get(start..start + sc) + } + + /// Convert to adapter-level [`CsiReadings`], one [`SensorCsiReading`] per + /// (rx, tx) antenna pair, carrying native subcarrier count, bandwidth and + /// band as first-class frame metadata (ADR-289). + /// + /// The FeitCSI header does not record channel/band (the capture + /// configuration owns that), so both are supplied by the caller. The + /// timestamp is derived deterministically from the record's own device + /// timestamp, never from wall-clock, so file replay is reproducible. + pub fn to_readings(&self, band: WifiBand, channel: u8) -> CsiReadings { + let sc = self.header.num_subcarriers as usize; + let mut readings = Vec::with_capacity(self.header.num_rx as usize * self.header.num_tx as usize); + + // Interpret the on-disk u32 RSSI as two's-complement dBm (captures + // store negative dBm values in the raw register field). + let rssi_dbm = |raw: u32| raw as i32 as f64; + let rssi = rssi_dbm(self.header.rssi1).max(rssi_dbm(self.header.rssi2)); + + let mac = self.header.source_mac; + let tx_mac = format!( + "{:02X}:{:02X}:{:02X}:{:02X}:{:02X}:{:02X}", + mac[0], mac[1], mac[2], mac[3], mac[4], mac[5] + ); + + for rx in 0..self.header.num_rx { + for tx in 0..self.header.num_tx { + let pair = self + .antenna_pair(rx, tx) + .expect("indices bounded by validated header dims"); + let mut amplitudes = Vec::with_capacity(sc); + let mut phases = Vec::with_capacity(sc); + for c in pair { + amplitudes.push(c.norm()); + phases.push(c.im.atan2(c.re)); + } + readings.push(SensorCsiReading { + sensor_id: format!("feitcsi_rx{rx}_tx{tx}"), + amplitudes, + phases, + rssi, + noise_floor: -92.0, + tx_mac: Some(tx_mac.clone()), + rx_mac: None, + sequence_num: None, + }); + } + } + + // Deterministic timestamp from the device clock (microseconds since + // capture epoch); replay of the same bytes yields the same output. + let timestamp = DateTime::::from_timestamp_micros(self.header.timestamp_us as i64) + .unwrap_or_else(|| DateTime::::from_timestamp(0, 0).expect("epoch is valid")); + + CsiReadings { + timestamp, + readings, + metadata: CsiMetadata { + device_type: DeviceType::FeitCsi, + channel, + bandwidth: self.header.bandwidth.to_bandwidth(), + num_subcarriers: sc, + rssi: Some(rssi), + noise_floor: None, + fc_type: FrameControlType::Data, + wideband: Some(WidebandMeta { + band, + bandwidth_mhz: self.header.bandwidth.mhz(), + native_subcarriers: sc, + mapping: None, + }), + }, + } + } +} + +/// Validate the fixed-size header. Enforces caps and dimension/length +/// consistency BEFORE any allocation is sized from untrusted fields. +fn validate_header(h: &[u8; HEADER_LEN]) -> Result { + let u32_at = |off: usize| u32::from_le_bytes([h[off], h[off + 1], h[off + 2], h[off + 3]]); + + let csi_data_size = u32_at(0); + let ftm_clock = u32_at(8); + let timestamp_us = u64::from_le_bytes([ + h[12], h[13], h[14], h[15], h[16], h[17], h[18], h[19], + ]); + let num_rx = h[46]; + let num_tx = h[47]; + let num_subcarriers = u32_at(52); + let rssi1 = u32_at(60); + let rssi2 = u32_at(64); + let mut source_mac = [0u8; 6]; + source_mac.copy_from_slice(&h[68..74]); + let rate_n_flags = u32_at(92); + + // Zero dimensions cannot carry CSI. + if num_rx == 0 { + return Err(FeitCsiError::ZeroDimension { field: "num_rx" }); + } + if num_tx == 0 { + return Err(FeitCsiError::ZeroDimension { field: "num_tx" }); + } + if num_subcarriers == 0 { + return Err(FeitCsiError::ZeroDimension { + field: "num_subcarriers", + }); + } + + // Hard caps: corrupt lengths must not size any allocation. + if num_rx > MAX_ANTENNAS { + return Err(FeitCsiError::CapExceeded { + field: "num_rx", + value: num_rx as u64, + cap: MAX_ANTENNAS as u64, + }); + } + if num_tx > MAX_ANTENNAS { + return Err(FeitCsiError::CapExceeded { + field: "num_tx", + value: num_tx as u64, + cap: MAX_ANTENNAS as u64, + }); + } + if num_subcarriers > MAX_SUBCARRIERS { + return Err(FeitCsiError::CapExceeded { + field: "num_subcarriers", + value: num_subcarriers as u64, + cap: MAX_SUBCARRIERS as u64, + }); + } + + // Dimensions vs declared buffer length. Capped dims bound this product + // at 8 * 8 * 4096 * 4 = 1 MiB, so the arithmetic cannot overflow usize. + let expected = + num_rx as usize * num_tx as usize * num_subcarriers as usize * BYTES_PER_SAMPLE; + if csi_data_size as usize != expected { + return Err(FeitCsiError::DimensionMismatch { + declared: csi_data_size, + expected, + num_rx, + num_tx, + num_subcarriers, + }); + } + + // Format-revision check on the rate flags: reject encodings outside the + // vendored rs.h layout this parser was written against. + let mod_type = match (rate_n_flags & RATE_MCS_MOD_TYPE_MSK) >> RATE_MCS_MOD_TYPE_POS { + 0 => FeitCsiModType::Cck, + 1 => FeitCsiModType::LegacyOfdm, + 2 => FeitCsiModType::Ht, + 3 => FeitCsiModType::Vht, + 4 => FeitCsiModType::He, + 5 => FeitCsiModType::Eht, + _ => { + return Err(FeitCsiError::UnsupportedFormat { + rate_n_flags, + reason: "unknown modulation type (bits 8..11)", + }) + } + }; + let bandwidth = match (rate_n_flags & RATE_MCS_CHAN_WIDTH_MSK) >> RATE_MCS_CHAN_WIDTH_POS { + 0 => FeitCsiBandwidth::Bw20, + 1 => FeitCsiBandwidth::Bw40, + 2 => FeitCsiBandwidth::Bw80, + 3 => FeitCsiBandwidth::Bw160, + // 4 = 320 MHz (EHT); not supported by this ingest revision. + _ => { + return Err(FeitCsiError::UnsupportedFormat { + rate_n_flags, + reason: "unsupported channel width (bits 11..14; 320 MHz+ not supported)", + }) + } + }; + + Ok(FeitCsiHeader { + csi_data_size, + ftm_clock, + timestamp_us, + num_rx, + num_tx, + num_subcarriers, + rssi1, + rssi2, + source_mac, + rate_n_flags, + mod_type, + bandwidth, + }) +} + +/// Decode a validated CSI payload (interleaved little-endian i16 I/Q pairs) +/// into complex samples. The caller has already validated `payload.len()` +/// against the header dimensions, so this performs exactly one bounded +/// allocation (`chunks_exact` is an exact-size iterator, so `collect` +/// reserves the final length up front) and the conversion loop itself is +/// allocation-free. +#[inline] +fn decode_csi(payload: &[u8]) -> Vec { + payload + .chunks_exact(BYTES_PER_SAMPLE) + .map(|sample| { + let re = i16::from_le_bytes([sample[0], sample[1]]) as f64; + let im = i16::from_le_bytes([sample[2], sample[3]]) as f64; + Complex64::new(re, im) + }) + .collect() +} + +/// Parse one record from the front of `buf`. +/// +/// Returns the record and the number of bytes consumed, so callers can walk +/// a multi-record capture. All validation happens before any allocation is +/// sized from untrusted fields; malformed input yields a structured +/// [`FeitCsiError`], never a panic. The header is read in place (no copy) +/// and the payload is converted directly from the input slice, so the CSI +/// bytes are traversed exactly once. +pub fn parse_record(buf: &[u8]) -> Result<(FeitCsiRecord, usize), FeitCsiError> { + if buf.len() < HEADER_LEN { + return Err(FeitCsiError::Truncated { + needed: HEADER_LEN, + got: buf.len(), + }); + } + let header_bytes: &[u8; HEADER_LEN] = buf[..HEADER_LEN] + .try_into() + .expect("slice length checked above"); + let header = validate_header(header_bytes)?; + + let payload_len = header.csi_data_size as usize; + let total = HEADER_LEN + payload_len; + if buf.len() < total { + return Err(FeitCsiError::Truncated { + needed: total, + got: buf.len(), + }); + } + + // payload_len == validated expected size <= 1 MiB: bounded allocation. + let csi = decode_csi(&buf[HEADER_LEN..total]); + + Ok((FeitCsiRecord { header, csi }, total)) +} + +/// Read one record from a byte stream (file, pipe, socket wrapper). +/// +/// Returns `Ok(None)` on clean EOF (no bytes before end-of-stream); a +/// mid-record EOF is a [`FeitCsiError::Truncated`] error. `read_exact` +/// semantics mean a blocking pipe simply waits for the writer, so the same +/// code path serves file replay and streaming mode. +pub fn read_one_record( + reader: &mut R, +) -> Result, FeitCsiError> { + let mut scratch = Vec::new(); + read_one_record_with_scratch(reader, &mut scratch) +} + +/// [`read_one_record`] with a caller-owned scratch buffer for the raw +/// payload, so long-running replay/stream loops reuse one allocation across +/// records instead of allocating per record. The scratch is only ever +/// resized to the header-validated payload length (<= 1 MiB), never to an +/// untrusted value. +fn read_one_record_with_scratch( + reader: &mut R, + scratch: &mut Vec, +) -> Result, FeitCsiError> { + let mut header_bytes = [0u8; HEADER_LEN]; + let mut filled = 0usize; + while filled < HEADER_LEN { + let n = reader.read(&mut header_bytes[filled..])?; + if n == 0 { + if filled == 0 { + return Ok(None); // clean EOF between records + } + return Err(FeitCsiError::Truncated { + needed: HEADER_LEN, + got: filled, + }); + } + filled += n; + } + + let header = validate_header(&header_bytes)?; + let payload_len = header.csi_data_size as usize; // validated, <= 1 MiB + scratch.resize(payload_len, 0); + reader.read_exact(scratch).map_err(|e| { + if e.kind() == std::io::ErrorKind::UnexpectedEof { + FeitCsiError::Truncated { + needed: HEADER_LEN + payload_len, + got: HEADER_LEN, // header complete, payload short + } + } else { + FeitCsiError::Io(e) + } + })?; + + let csi = decode_csi(scratch); + + Ok(Some((FeitCsiRecord { header, csi }, HEADER_LEN + payload_len))) +} + +/// Streaming reader over any [`Read`] source (recorded capture file, or a +/// path/pipe an external FeitCSI process writes to). RuView never configures +/// the NIC — FeitCSI's own tooling owns capture, per least-authority. +/// +/// Holds a reusable payload scratch buffer so a long-running stream performs +/// one bounded raw-byte allocation total (plus the per-record `Vec` +/// output), rather than one raw-byte allocation per record. +pub struct FeitCsiStreamReader { + inner: R, + scratch: Vec, +} + +impl FeitCsiStreamReader { + /// Wrap a byte source. + pub fn new(inner: R) -> Self { + Self { + inner, + scratch: Vec::new(), + } + } + + /// Read the next record; `Ok(None)` on clean end-of-stream. + pub fn read_next(&mut self) -> Result, FeitCsiError> { + Ok(read_one_record_with_scratch(&mut self.inner, &mut self.scratch)?.map(|(rec, _)| rec)) + } +} + +/// Deterministic file-replay reader for recorded FeitCSI captures. +pub struct FeitCsiFileReader { + stream: FeitCsiStreamReader>, +} + +impl FeitCsiFileReader { + /// Open a recorded capture for sequential replay. + pub fn open(path: &str) -> Result { + let file = std::fs::File::open(path)?; + Ok(Self { + stream: FeitCsiStreamReader::new(std::io::BufReader::new(file)), + }) + } + + /// Read the next record; `Ok(None)` at end of capture. + pub fn read_next(&mut self) -> Result, FeitCsiError> { + self.stream.read_next() + } +} + +/// Convert wideband readings to the pipeline's subcarrier width via the +/// existing interpolation path (`wifi-densepose-signal`'s Catmull-Rom cubic +/// resampler from ADR-027), recording the native → pipeline mapping in frame +/// metadata so downstream consumers know the true spectral resolution +/// (ADR-289 §3). +/// +/// This is the ONLY sanctioned native→pipeline conversion: it is explicit, +/// and the mapping is auditable in `metadata.wideband.mapping`. +pub fn resample_readings_to_pipeline( + readings: &CsiReadings, + pipeline_subcarriers: usize, +) -> Result { + let normalizer = + wifi_densepose_signal::HardwareNormalizer::with_canonical_subcarriers(pipeline_subcarriers) + .map_err(|e| AdapterError::Config(format!("invalid pipeline width: {e}")))?; + + let native = readings.metadata.num_subcarriers; + let mut out = readings.clone(); + for reading in &mut out.readings { + reading.amplitudes = normalizer.resample_to_canonical(&reading.amplitudes); + reading.phases = normalizer.resample_to_canonical(&reading.phases); + } + out.metadata.num_subcarriers = pipeline_subcarriers; + + let mapping = SubcarrierMapping { + native, + pipeline: pipeline_subcarriers, + method: "catmull-rom-cubic", + }; + match &mut out.metadata.wideband { + Some(wb) => wb.mapping = Some(mapping), + None => { + // Preserve provenance even for frames that arrived without + // wideband metadata: native resolution is still recorded. + out.metadata.wideband = Some(WidebandMeta { + band: WifiBand::Band5GHz, + bandwidth_mhz: readings.metadata.bandwidth.mhz(), + native_subcarriers: native, + mapping: Some(mapping), + }); + } + } + Ok(out) +} + +/// Deterministic synthetic-fixture generation for tests and benchmarks. +/// +/// FeitCSI capture fixtures are always generated in code (never checked in +/// as binary files, per repo policy). CSI samples are a pure function of the +/// sample index, so round-trips are checkable and replay is reproducible. +pub mod synth { + use super::{BYTES_PER_SAMPLE, HEADER_LEN, RATE_MCS_CHAN_WIDTH_POS, RATE_MCS_MOD_TYPE_POS}; + + /// Build the bytes of one synthetic FeitCSI record with the given + /// dimensions, `rateNflag` channel-width value (0=20 MHz .. 3=160 MHz), + /// modulation-type value (4=HE), and device timestamp. + pub fn record_bytes( + num_rx: u8, + num_tx: u8, + num_subcarriers: u32, + chan_width_val: u32, + mod_type_val: u32, + timestamp_us: u64, + ) -> Vec { + let samples = num_rx as usize * num_tx as usize * num_subcarriers as usize; + let csi_data_size = (samples * BYTES_PER_SAMPLE) as u32; + + let mut h = vec![0u8; HEADER_LEN]; + h[0..4].copy_from_slice(&csi_data_size.to_le_bytes()); + h[8..12].copy_from_slice(&0xAABBCCDDu32.to_le_bytes()); // ftm_clock + h[12..20].copy_from_slice(×tamp_us.to_le_bytes()); + h[46] = num_rx; + h[47] = num_tx; + h[52..56].copy_from_slice(&num_subcarriers.to_le_bytes()); + h[60..64].copy_from_slice(&(-42i32 as u32).to_le_bytes()); // rssi1 + h[64..68].copy_from_slice(&(-45i32 as u32).to_le_bytes()); // rssi2 + h[68..74].copy_from_slice(&[0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF]); + let rate_n_flags = + (mod_type_val << RATE_MCS_MOD_TYPE_POS) | (chan_width_val << RATE_MCS_CHAN_WIDTH_POS); + h[92..96].copy_from_slice(&rate_n_flags.to_le_bytes()); + + for i in 0..samples { + let re = (i as i64 % 200 - 100) as i16; + let im = (i as i64 % 97 - 48) as i16; + h.extend_from_slice(&re.to_le_bytes()); + h.extend_from_slice(&im.to_le_bytes()); + } + h + } +} + +#[cfg(test)] +mod tests { + use super::synth::record_bytes as make_record_bytes; + use super::*; + + /// HE (802.11ax) records at 20/80/160 MHz shapes parse with correct + /// native dimensions, decoded bandwidth, and sample round-trip. + #[test] + fn test_parse_valid_he_shapes() { + // (chan_width_val, expected bandwidth, HE tone count) + let shapes = [ + (0u32, FeitCsiBandwidth::Bw20, 242u32), + (2u32, FeitCsiBandwidth::Bw80, 996u32), + (3u32, FeitCsiBandwidth::Bw160, 1992u32), + ]; + for (cw, expected_bw, sc) in shapes { + let bytes = make_record_bytes(2, 1, sc, cw, 4, 1_000_000); + let (rec, consumed) = parse_record(&bytes).expect("valid record must parse"); + assert_eq!(consumed, bytes.len()); + assert_eq!(rec.header.num_subcarriers, sc); + assert_eq!(rec.header.bandwidth, expected_bw); + assert_eq!(rec.header.mod_type, FeitCsiModType::He); + assert_eq!(rec.header.num_rx, 2); + assert_eq!(rec.header.num_tx, 1); + assert_eq!(rec.csi.len(), 2 * sc as usize); + // Deterministic sample round-trip: index 5 → re = 5-100... check + // the generator formula directly. + let i = 5usize; + assert_eq!(rec.csi[i].re, (i as i64 % 200 - 100) as f64); + assert_eq!(rec.csi[i].im, (i as i64 % 97 - 48) as f64); + // Antenna-pair accessor yields native-width slices. + assert_eq!(rec.antenna_pair(0, 0).unwrap().len(), sc as usize); + assert_eq!(rec.antenna_pair(1, 0).unwrap().len(), sc as usize); + assert!(rec.antenna_pair(2, 0).is_none()); + } + } + + /// A truncated buffer (header or payload cut short) is a structured + /// error, not a panic. + #[test] + fn test_truncated_buffer() { + let bytes = make_record_bytes(1, 1, 242, 0, 4, 0); + + // Header cut short. + let r = parse_record(&bytes[..100]); + assert!(matches!( + r, + Err(FeitCsiError::Truncated { needed, got: 100 }) if needed == HEADER_LEN + )); + + // Payload cut short. + let r = parse_record(&bytes[..bytes.len() - 1]); + assert!(matches!(r, Err(FeitCsiError::Truncated { .. }))); + + // Empty buffer. + assert!(matches!( + parse_record(&[]), + Err(FeitCsiError::Truncated { .. }) + )); + } + + /// csiDataSize that disagrees with the declared dimensions is rejected. + #[test] + fn test_dimension_mismatch() { + let mut bytes = make_record_bytes(1, 1, 242, 0, 4, 0); + // Corrupt the declared size (off by 4 bytes). + let bad = (242 * BYTES_PER_SAMPLE as u32) + 4; + bytes[0..4].copy_from_slice(&bad.to_le_bytes()); + let r = parse_record(&bytes); + assert!(matches!( + r, + Err(FeitCsiError::DimensionMismatch { + declared, + expected, + .. + }) if declared == bad && expected == 242 * BYTES_PER_SAMPLE + )); + } + + /// Unknown rate-flag encodings fail loudly (the format has no magic, so + /// this is the version/format check): 320 MHz width and out-of-range + /// modulation types are refused rather than misparsed. + #[test] + fn test_unsupported_format_fails_loudly() { + // Channel width value 4 = 320 MHz (EHT) — unsupported. + let bytes = make_record_bytes(1, 1, 242, 4, 5, 0); + assert!(matches!( + parse_record(&bytes), + Err(FeitCsiError::UnsupportedFormat { .. }) + )); + + // Modulation type 7 — outside the vendored rs.h encoding. + let bytes = make_record_bytes(1, 1, 242, 0, 7, 0); + assert!(matches!( + parse_record(&bytes), + Err(FeitCsiError::UnsupportedFormat { .. }) + )); + } + + /// Corrupt dimension fields beyond the hard caps are rejected BEFORE any + /// allocation is sized from them — a hostile length cannot cause + /// unbounded allocation. + #[test] + fn test_allocation_cap_enforcement() { + // Subcarrier count over the cap, with a consistent (huge) size field. + let mut h = vec![0u8; HEADER_LEN]; + let huge_sc: u32 = 100_000; + h[46] = 1; + h[47] = 1; + h[52..56].copy_from_slice(&huge_sc.to_le_bytes()); + h[0..4].copy_from_slice(&(huge_sc * 4).to_le_bytes()); + h[92..96].copy_from_slice(&(4u32 << RATE_MCS_MOD_TYPE_POS).to_le_bytes()); + let r = parse_record(&h); + assert!(matches!( + r, + Err(FeitCsiError::CapExceeded { + field: "num_subcarriers", + value, + cap, + }) if value == huge_sc as u64 && cap == MAX_SUBCARRIERS as u64 + )); + + // Antenna count over the cap. + let mut h = vec![0u8; HEADER_LEN]; + h[46] = 9; // num_rx > MAX_ANTENNAS + h[47] = 1; + h[52..56].copy_from_slice(&242u32.to_le_bytes()); + h[0..4].copy_from_slice(&(9 * 242 * 4u32).to_le_bytes()); + assert!(matches!( + parse_record(&h), + Err(FeitCsiError::CapExceeded { field: "num_rx", .. }) + )); + + // Zero dimension. + let mut h = vec![0u8; HEADER_LEN]; + h[46] = 0; + h[47] = 1; + h[52..56].copy_from_slice(&242u32.to_le_bytes()); + assert!(matches!( + parse_record(&h), + Err(FeitCsiError::ZeroDimension { field: "num_rx" }) + )); + } + + /// Streaming reader over an in-memory multi-record capture: reads all + /// records in order, then clean EOF. + #[test] + fn test_stream_reader_multi_record() { + let mut capture = Vec::new(); + for ts in [10u64, 20, 30] { + capture.extend_from_slice(&make_record_bytes(1, 1, 242, 0, 4, ts)); + } + let mut reader = FeitCsiStreamReader::new(std::io::Cursor::new(capture)); + let mut timestamps = Vec::new(); + while let Some(rec) = reader.read_next().expect("stream parse") { + timestamps.push(rec.header.timestamp_us); + } + assert_eq!(timestamps, vec![10, 20, 30]); + } + + /// A stream that ends mid-record reports Truncated, not clean EOF. + #[test] + fn test_stream_reader_mid_record_eof() { + let bytes = make_record_bytes(1, 1, 242, 0, 4, 0); + let cut = &bytes[..bytes.len() - 10]; + let mut reader = FeitCsiStreamReader::new(std::io::Cursor::new(cut.to_vec())); + assert!(matches!( + reader.read_next(), + Err(FeitCsiError::Truncated { .. }) + )); + } + + /// File replay is deterministic: two independent reads of the same + /// synthetic capture yield byte-identical record sequences and identical + /// converted readings (timestamps derive from the record, not wall-clock). + #[test] + fn test_replay_determinism() { + let mut capture = Vec::new(); + for ts in [1_000u64, 2_000, 3_000] { + capture.extend_from_slice(&make_record_bytes(2, 1, 996, 2, 4, ts)); + } + let path = std::env::temp_dir().join(format!( + "feitcsi_replay_test_{}.dat", + std::process::id() + )); + std::fs::write(&path, &capture).unwrap(); + + let read_all = || -> Vec { + let mut reader = FeitCsiFileReader::open(path.to_str().unwrap()).unwrap(); + let mut out = Vec::new(); + while let Some(rec) = reader.read_next().unwrap() { + out.push(rec); + } + out + }; + + let first = read_all(); + let second = read_all(); + assert_eq!(first.len(), 3); + assert_eq!(first, second, "replay must be deterministic"); + + // Converted readings are also identical, including timestamps. + let r1 = first[0].to_readings(WifiBand::Band6GHz, 37); + let r2 = second[0].to_readings(WifiBand::Band6GHz, 37); + assert_eq!(r1.timestamp, r2.timestamp); + assert_eq!(r1.readings[0].amplitudes, r2.readings[0].amplitudes); + + let _ = std::fs::remove_file(&path); + } + + /// Native → pipeline conversion goes through the explicit interpolation + /// path and records the mapping in frame metadata. + #[test] + fn test_native_to_pipeline_mapping_recorded() { + let bytes = make_record_bytes(1, 1, 1992, 3, 4, 500); + let (rec, _) = parse_record(&bytes).unwrap(); + let native = rec.to_readings(WifiBand::Band6GHz, 37); + + // Native metadata is first-class. + assert_eq!(native.metadata.num_subcarriers, 1992); + let wb = native.metadata.wideband.as_ref().expect("wideband meta"); + assert_eq!(wb.band, WifiBand::Band6GHz); + assert_eq!(wb.bandwidth_mhz, 160); + assert_eq!(wb.native_subcarriers, 1992); + assert!(wb.mapping.is_none(), "no mapping before conversion"); + + // Explicit conversion to pipeline width. + let converted = resample_readings_to_pipeline(&native, 56).unwrap(); + assert_eq!(converted.metadata.num_subcarriers, 56); + assert_eq!(converted.readings[0].amplitudes.len(), 56); + assert_eq!(converted.readings[0].phases.len(), 56); + let wb = converted.metadata.wideband.as_ref().unwrap(); + assert_eq!(wb.native_subcarriers, 1992, "true resolution preserved"); + let mapping = wb.mapping.as_ref().expect("mapping recorded"); + assert_eq!(mapping.native, 1992); + assert_eq!(mapping.pipeline, 56); + assert_eq!(mapping.method, "catmull-rom-cubic"); + + // Original frame is untouched. + assert_eq!(native.metadata.num_subcarriers, 1992); + } + + /// to_readings emits one reading per (rx, tx) pair at native width. + #[test] + fn test_to_readings_antenna_pairs() { + let bytes = make_record_bytes(2, 2, 242, 0, 4, 0); + let (rec, _) = parse_record(&bytes).unwrap(); + let readings = rec.to_readings(WifiBand::Band5GHz, 36); + assert_eq!(readings.readings.len(), 4); + for r in &readings.readings { + assert_eq!(r.amplitudes.len(), 242); + assert_eq!(r.phases.len(), 242); + } + assert_eq!( + readings.readings[0].tx_mac.as_deref(), + Some("AA:BB:CC:DD:EE:FF") + ); + assert!(matches!(readings.metadata.device_type, DeviceType::FeitCsi)); + assert_eq!(readings.metadata.bandwidth, Bandwidth::HT20); + } +} diff --git a/v2/crates/wifi-densepose-mat/src/integration/hardware_adapter.rs b/v2/crates/wifi-densepose-mat/src/integration/hardware_adapter.rs index f0c59268..02f9f5c0 100644 --- a/v2/crates/wifi-densepose-mat/src/integration/hardware_adapter.rs +++ b/v2/crates/wifi-densepose-mat/src/integration/hardware_adapter.rs @@ -129,6 +129,42 @@ impl HardwareConfig { } } + /// Create configuration for deterministic FeitCSI capture replay + /// (wideband 802.11ax records from Intel AX200/AX210, ADR-289). + pub fn feitcsi_replay(file_path: &str) -> Self { + Self::feitcsi(file_path, FeitCsiMode::FileReplay) + } + + /// Create configuration for streaming FeitCSI ingest from a path/pipe an + /// external FeitCSI process writes to (no NIC configuration in-crate). + pub fn feitcsi_stream(path: &str) -> Self { + Self::feitcsi(path, FeitCsiMode::Stream) + } + + fn feitcsi(path: &str, mode: FeitCsiMode) -> Self { + Self { + device_type: DeviceType::FeitCsi, + device_settings: DeviceSettings::FeitCsi(FeitCsiSettings { + path: path.to_string(), + mode, + band: WifiBand::Band5GHz, + channel: 36, + loop_playback: false, + pipeline_subcarriers: None, + }), + buffer_size: 8192, + raw_mode: false, + sample_rate_override: 0, + channel_config: ChannelConfig { + channel: 36, + bandwidth: Bandwidth::VHT160, + // Native width travels with each frame; this is only the + // configured expectation (802.11ax HE 160 MHz = 1992 tones). + num_subcarriers: 1992, + }, + } + } + /// Create configuration for UDP receiver (generic CSI) pub fn udp_receiver(bind_addr: &str, port: u16) -> Self { Self { @@ -160,6 +196,11 @@ pub enum DeviceType { UdpReceiver, /// PCAP file replay PcapFile, + /// FeitCSI wideband 802.11ax records from Intel AX200/AX210 (ADR-289): + /// file replay of a recorded capture, or a path/pipe an external FeitCSI + /// process writes to. RuView never configures the NIC — FeitCSI's own + /// tooling owns capture, per least-authority. + FeitCsi, /// Simulated device (for testing) Simulated, } @@ -186,10 +227,46 @@ pub enum DeviceSettings { Udp(UdpSettings), /// PCAP file settings Pcap(PcapSettings), + /// FeitCSI capture replay / stream settings + FeitCsi(FeitCsiSettings), /// Simulated device (no real hardware) Simulated, } +/// FeitCSI ingest mode (ADR-289). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FeitCsiMode { + /// Deterministic replay of a recorded capture file. + FileReplay, + /// Live stream read from a path/pipe an external FeitCSI process writes + /// to. RuView performs no NIC configuration; the external tool owns it. + Stream, +} + +/// FeitCSI source settings (ADR-289). +/// +/// The FeitCSI record header carries bandwidth (via the iwlwifi rate flags) +/// but not channel/band — the capture configuration owns those — so band and +/// channel are supplied here and stamped into frame metadata. +#[derive(Debug, Clone)] +pub struct FeitCsiSettings { + /// Path to the recorded capture (FileReplay) or the file/FIFO the + /// external FeitCSI process appends records to (Stream). + pub path: String, + /// Ingest mode. + pub mode: FeitCsiMode, + /// Radio band the capture was taken on (2.4/5/6 GHz). + pub band: WifiBand, + /// WiFi channel the capture was taken on. + pub channel: u8, + /// Restart from the beginning when file replay reaches the end. + pub loop_playback: bool, + /// When `Some(n)`, frames are explicitly converted from their native + /// subcarrier count to `n` via the interpolation path, and the mapping is + /// recorded in `CsiMetadata::wideband`. `None` keeps native width. + pub pipeline_subcarriers: Option, +} + /// Serial port configuration #[derive(Debug, Clone)] pub struct SerialSettings { @@ -264,6 +341,55 @@ impl Bandwidth { Bandwidth::VHT160 => 484, } } + + /// Channel bandwidth in MHz. + pub fn mhz(&self) -> u16 { + match self { + Bandwidth::HT20 => 20, + Bandwidth::HT40 => 40, + Bandwidth::VHT80 => 80, + Bandwidth::VHT160 => 160, + } + } +} + +/// WiFi radio band (first-class frame metadata per ADR-289). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WifiBand { + /// 2.4 GHz ISM band + Band2_4GHz, + /// 5 GHz band + Band5GHz, + /// 6 GHz band (802.11ax/Wi-Fi 6E and later) + Band6GHz, +} + +/// Record of an explicit native → pipeline subcarrier conversion, so +/// downstream consumers know the true spectral resolution of a frame and +/// how it was resampled (ADR-289 §3). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SubcarrierMapping { + /// Native subcarrier count as captured. + pub native: usize, + /// Pipeline subcarrier count after conversion. + pub pipeline: usize, + /// Interpolation/decimation method used (e.g. "catmull-rom-cubic"). + pub method: &'static str, +} + +/// Wideband spectral provenance metadata (ADR-289): band, bandwidth, native +/// subcarrier dimensionality, and any native → pipeline mapping applied. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WidebandMeta { + /// Radio band the frame was captured on. + pub band: WifiBand, + /// Channel bandwidth in MHz (20–160). + pub bandwidth_mhz: u16, + /// Native subcarrier count of the capture (true spectral resolution). + pub native_subcarriers: usize, + /// Native → pipeline conversion record; `None` while the frame is still + /// at native width. + pub mapping: Option, } /// Antenna configuration for MIMO @@ -376,6 +502,13 @@ enum DeviceSpecificState { driver: AtherosDriver, csi_buf_ptr: Option, }, + FeitCsi { + /// Byte offset into the capture for deterministic file replay. + replay_offset: u64, + /// Open handle for streaming mode (path/pipe written by an external + /// FeitCSI process); opened lazily on first read. + stream: Option, + }, Other, } @@ -457,6 +590,7 @@ impl HardwareAdapter { DeviceType::Atheros(driver) => self.initialize_atheros(*driver).await?, DeviceType::UdpReceiver => self.initialize_udp().await?, DeviceType::PcapFile => self.initialize_pcap().await?, + DeviceType::FeitCsi => self.initialize_feitcsi().await?, DeviceType::Simulated => self.initialize_simulated().await?, } @@ -662,6 +796,57 @@ impl HardwareAdapter { Ok(()) } + /// Initialize FeitCSI file-replay / stream ingest (ADR-289). + /// + /// No privileged operations: RuView does not configure the NIC; the + /// external FeitCSI tooling owns capture. This only validates the + /// configured path. + async fn initialize_feitcsi(&mut self) -> Result<(), AdapterError> { + let settings = match &self.config.device_settings { + DeviceSettings::FeitCsi(s) => s, + _ => { + return Err(AdapterError::Config( + "FeitCSI requires FeitCSI settings".into(), + )) + } + }; + + tracing::info!( + "Initializing FeitCSI ingest ({:?}) from {}", + settings.mode, + settings.path + ); + + match settings.mode { + FeitCsiMode::FileReplay => { + if !std::path::Path::new(&settings.path).exists() { + return Err(AdapterError::Hardware(format!( + "FeitCSI capture file not found: {}", + settings.path + ))); + } + } + FeitCsiMode::Stream => { + // The external process may create the pipe/file later; warn + // rather than fail so start order is not constrained. + if !std::path::Path::new(&settings.path).exists() { + tracing::warn!( + "FeitCSI stream path {} does not exist yet; will retry on read", + settings.path + ); + } + } + } + + let mut state = self.state.write().await; + state.device_state = DeviceSpecificState::FeitCsi { + replay_offset: 0, + stream: None, + }; + + Ok(()) + } + /// Initialize simulated device async fn initialize_simulated(&mut self) -> Result<(), AdapterError> { tracing::info!("Initializing simulated CSI device"); @@ -764,7 +949,7 @@ impl HardwareAdapter { /// Read a single CSI packet from the device async fn read_csi_packet( config: &HardwareConfig, - _state: &Arc>, + state: &Arc>, ) -> Result { match &config.device_type { DeviceType::Esp32 => Self::read_esp32_csi(config).await, @@ -772,10 +957,141 @@ impl HardwareAdapter { DeviceType::Atheros(driver) => Self::read_atheros_csi(config, *driver).await, DeviceType::UdpReceiver => Self::read_udp_csi(config).await, DeviceType::PcapFile => Self::read_pcap_csi(config).await, + DeviceType::FeitCsi => Self::read_feitcsi_csi(config, state).await, DeviceType::Simulated => Self::generate_simulated_csi(config).await, } } + /// Read one wideband CSI frame from a FeitCSI capture or stream (ADR-289). + /// + /// Frames carry their native subcarrier count, bandwidth (20–160 MHz) and + /// band (2.4/5/6 GHz) as metadata. When `pipeline_subcarriers` is + /// configured, conversion to pipeline width happens explicitly via the + /// interpolation path and the native → pipeline mapping is recorded in + /// `CsiMetadata::wideband`. + async fn read_feitcsi_csi( + config: &HardwareConfig, + state: &Arc>, + ) -> Result { + let settings = match &config.device_settings { + DeviceSettings::FeitCsi(s) => s, + _ => return Err(AdapterError::Config("Invalid settings for FeitCSI".into())), + }; + + let record = match settings.mode { + FeitCsiMode::FileReplay => Self::read_feitcsi_replay(settings, state).await?, + FeitCsiMode::Stream => Self::read_feitcsi_stream(settings, state).await?, + }; + + let readings = record.to_readings(settings.band, settings.channel); + match settings.pipeline_subcarriers { + Some(n) if n != readings.metadata.num_subcarriers => { + super::feitcsi::resample_readings_to_pipeline(&readings, n) + } + _ => Ok(readings), + } + } + + /// Deterministic file replay: reads the record at the current byte offset + /// and advances it, so the capture is walked once from start to end + /// (looping when configured). Same input file ⇒ same record sequence. + async fn read_feitcsi_replay( + settings: &FeitCsiSettings, + state: &Arc>, + ) -> Result { + let offset = { + let st = state.read().await; + match &st.device_state { + DeviceSpecificState::FeitCsi { replay_offset, .. } => *replay_offset, + _ => 0, + } + }; + + let path = settings.path.clone(); + let loop_playback = settings.loop_playback; + let (record, new_offset) = tokio::task::spawn_blocking( + move || -> Result<(super::feitcsi::FeitCsiRecord, u64), AdapterError> { + use std::io::{Seek, SeekFrom}; + let mut file = std::fs::File::open(&path).map_err(|e| { + AdapterError::Hardware(format!("Failed to open FeitCSI capture {path}: {e}")) + })?; + file.seek(SeekFrom::Start(offset)) + .map_err(AdapterError::Io)?; + match super::feitcsi::read_one_record(&mut file)? { + Some((rec, consumed)) => Ok((rec, offset + consumed as u64)), + None if loop_playback && offset != 0 => { + file.seek(SeekFrom::Start(0)).map_err(AdapterError::Io)?; + match super::feitcsi::read_one_record(&mut file)? { + Some((rec, consumed)) => Ok((rec, consumed as u64)), + None => Err(AdapterError::DataFormat(format!( + "FeitCSI capture {path} contains no records" + ))), + } + } + None => Err(AdapterError::HardwareUnavailable(format!( + "End of FeitCSI capture {path} (offset {offset})" + ))), + } + }, + ) + .await + .map_err(|e| AdapterError::Hardware(format!("FeitCSI replay task failed: {e}")))??; + + let mut st = state.write().await; + if let DeviceSpecificState::FeitCsi { replay_offset, .. } = &mut st.device_state { + *replay_offset = new_offset; + } + Ok(record) + } + + /// Streaming mode: hold the open handle across reads (a FIFO cannot be + /// reopened per record) and block until one full record arrives. The + /// blocking read runs on the blocking pool; if the surrounding stream + /// loop is shut down mid-read, the orphaned task finishes on its own and + /// the handle is reopened on the next read. + async fn read_feitcsi_stream( + settings: &FeitCsiSettings, + state: &Arc>, + ) -> Result { + let existing = { + let mut st = state.write().await; + match &mut st.device_state { + DeviceSpecificState::FeitCsi { stream, .. } => stream.take(), + _ => None, + } + }; + + let path = settings.path.clone(); + let result = tokio::task::spawn_blocking( + move || -> Result<(std::fs::File, super::feitcsi::FeitCsiRecord), AdapterError> { + let mut file = match existing { + Some(f) => f, + None => std::fs::File::open(&path).map_err(|e| { + AdapterError::HardwareUnavailable(format!( + "FeitCSI stream {path} unavailable: {e}" + )) + })?, + }; + match super::feitcsi::read_one_record(&mut file) { + Ok(Some((rec, _consumed))) => Ok((file, rec)), + Ok(None) => Err(AdapterError::HardwareUnavailable(format!( + "FeitCSI stream {path} closed (EOF)" + ))), + Err(e) => Err(e.into()), + } + }, + ) + .await + .map_err(|e| AdapterError::Hardware(format!("FeitCSI stream task failed: {e}")))?; + + let (file, record) = result?; + let mut st = state.write().await; + if let DeviceSpecificState::FeitCsi { stream, .. } = &mut st.device_state { + *stream = Some(file); + } + Ok(record) + } + /// Read CSI from ESP32 via serial. /// /// The ESP-CSI firmware emits newline-delimited `CSI_DATA,...` CSV records. @@ -1023,6 +1339,7 @@ impl HardwareAdapter { rssi: Some(-45.0), noise_floor: Some(-92.0), fc_type: FrameControlType::Data, + wideband: None, }, }) } @@ -1039,6 +1356,7 @@ impl HardwareAdapter { DeviceType::Intel5300 | DeviceType::Atheros(_) => self.discover_nic_sensors().await, DeviceType::UdpReceiver => Ok(vec![]), DeviceType::PcapFile => Ok(vec![]), + DeviceType::FeitCsi => Ok(vec![]), DeviceType::Simulated => self.discover_simulated_sensors().await, } } @@ -1165,6 +1483,7 @@ impl HardwareAdapter { rssi: None, noise_floor: None, fc_type: FrameControlType::Data, + wideband: None, }, }) } @@ -1320,6 +1639,10 @@ pub struct CsiMetadata { pub noise_floor: Option, /// Frame control type pub fc_type: FrameControlType, + /// Wideband spectral provenance (ADR-289): band, native subcarrier count + /// and any native → pipeline mapping applied. `None` for legacy + /// narrowband sources that predate wideband metadata. + pub wideband: Option, } /// WiFi frame control types @@ -1640,6 +1963,124 @@ mod tests { let _ = std::fs::remove_file(&path); } + #[test] + fn test_feitcsi_config() { + let config = HardwareConfig::feitcsi_replay("/tmp/capture.dat"); + assert!(matches!(config.device_type, DeviceType::FeitCsi)); + match &config.device_settings { + DeviceSettings::FeitCsi(s) => { + assert_eq!(s.mode, FeitCsiMode::FileReplay); + assert!(s.pipeline_subcarriers.is_none()); + } + other => panic!("unexpected settings: {other:?}"), + } + + let config = HardwareConfig::feitcsi_stream("/tmp/feitcsi.fifo"); + match &config.device_settings { + DeviceSettings::FeitCsi(s) => assert_eq!(s.mode, FeitCsiMode::Stream), + other => panic!("unexpected settings: {other:?}"), + } + } + + /// End-to-end FeitCSI file replay through the adapter read path: + /// initialize, then read the capture record-by-record. Two adapters over + /// the same synthetic capture see identical, order-preserving sequences + /// (replay determinism), and native+wideband metadata is carried. + #[tokio::test] + async fn test_feitcsi_replay_end_to_end_deterministic() { + use crate::integration::feitcsi::synth; + + // Synthetic 3-record HE 80 MHz capture, generated in code. + let mut capture = Vec::new(); + for ts in [100u64, 200, 300] { + capture.extend_from_slice(&synth::record_bytes(2, 1, 996, 2, 4, ts)); + } + let path = std::env::temp_dir().join(format!( + "feitcsi_adapter_test_{}.dat", + std::process::id() + )); + std::fs::write(&path, &capture).unwrap(); + + let run = || async { + let mut config = HardwareConfig::feitcsi_replay(path.to_str().unwrap()); + if let DeviceSettings::FeitCsi(s) = &mut config.device_settings { + s.band = WifiBand::Band6GHz; + s.channel = 37; + } + let mut adapter = HardwareAdapter::with_config(config.clone()); + adapter.initialize().await.unwrap(); + + let mut frames = Vec::new(); + for _ in 0..3 { + let readings = HardwareAdapter::read_csi_packet(&config, &adapter.state) + .await + .expect("replay read"); + frames.push(readings); + } + // Capture exhausted: typed error, not fabricated data. + let end = HardwareAdapter::read_csi_packet(&config, &adapter.state).await; + assert!(matches!(end, Err(AdapterError::HardwareUnavailable(_)))); + frames + }; + + let first = run().await; + let second = run().await; + + assert_eq!(first.len(), 3); + for (a, b) in first.iter().zip(&second) { + assert_eq!(a.timestamp, b.timestamp, "replay must be deterministic"); + assert_eq!(a.readings[0].amplitudes, b.readings[0].amplitudes); + } + + // Native wideband metadata is first-class on every frame. + let meta = &first[0].metadata; + assert!(matches!(meta.device_type, DeviceType::FeitCsi)); + assert_eq!(meta.num_subcarriers, 996); + assert_eq!(meta.bandwidth, Bandwidth::VHT80); + let wb = meta.wideband.as_ref().expect("wideband metadata"); + assert_eq!(wb.band, WifiBand::Band6GHz); + assert_eq!(wb.bandwidth_mhz, 80); + assert_eq!(wb.native_subcarriers, 996); + assert!(wb.mapping.is_none(), "native width: no mapping"); + // 2 rx * 1 tx = 2 antenna-pair readings per frame. + assert_eq!(first[0].readings.len(), 2); + + let _ = std::fs::remove_file(&path); + } + + /// FeitCSI replay with a configured pipeline width converts explicitly + /// through the interpolation path and records the mapping in metadata. + #[tokio::test] + async fn test_feitcsi_replay_pipeline_conversion() { + use crate::integration::feitcsi::synth; + + let capture = synth::record_bytes(1, 1, 1992, 3, 4, 42); + let path = std::env::temp_dir().join(format!( + "feitcsi_pipeline_test_{}.dat", + std::process::id() + )); + std::fs::write(&path, &capture).unwrap(); + + let mut config = HardwareConfig::feitcsi_replay(path.to_str().unwrap()); + if let DeviceSettings::FeitCsi(s) = &mut config.device_settings { + s.pipeline_subcarriers = Some(56); + } + let mut adapter = HardwareAdapter::with_config(config.clone()); + adapter.initialize().await.unwrap(); + + let readings = HardwareAdapter::read_csi_packet(&config, &adapter.state) + .await + .expect("replay read"); + assert_eq!(readings.metadata.num_subcarriers, 56); + assert_eq!(readings.readings[0].amplitudes.len(), 56); + let wb = readings.metadata.wideband.as_ref().unwrap(); + assert_eq!(wb.native_subcarriers, 1992, "true resolution preserved"); + let mapping = wb.mapping.as_ref().expect("mapping recorded"); + assert_eq!((mapping.native, mapping.pipeline), (1992, 56)); + + let _ = std::fs::remove_file(&path); + } + /// Honest hardware gating: Intel 5300 / Atheros return typed /// HardwareUnavailable (no device/driver), never fabricated CSI. #[tokio::test] diff --git a/v2/crates/wifi-densepose-mat/src/integration/mod.rs b/v2/crates/wifi-densepose-mat/src/integration/mod.rs index 5c8c3dee..a6dc4948 100644 --- a/v2/crates/wifi-densepose-mat/src/integration/mod.rs +++ b/v2/crates/wifi-densepose-mat/src/integration/mod.rs @@ -13,6 +13,9 @@ //! - **Intel 5300 NIC**: Using Linux CSI Tool (iwlwifi driver) //! - **Atheros NICs**: Using ath9k/ath10k/ath11k CSI patches //! - **Nexmon**: For Broadcom chips with CSI firmware +//! - **FeitCSI (Intel AX200/AX210)**: Wideband 802.11ax CSI up to 160 MHz / +//! 1992 subcarriers including 6 GHz, ingested from recorded captures or a +//! stream written by the external FeitCSI tool (ADR-289) //! //! # Example Usage //! @@ -37,6 +40,7 @@ //! ``` pub mod csi_receiver; +pub mod feitcsi; mod hardware_adapter; mod neural_adapter; mod signal_adapter; @@ -52,6 +56,9 @@ pub use hardware_adapter::{ CsiStream, DeviceSettings, DeviceType, + // FeitCSI wideband ingest settings (ADR-289) + FeitCsiMode, + FeitCsiSettings, FlowControl, FrameControlType, // Main adapter @@ -73,8 +80,18 @@ pub use hardware_adapter::{ // Serial settings SerialSettings, StreamingStats, + // Wideband spectral provenance (ADR-289) + SubcarrierMapping, // UDP settings UdpSettings, + WidebandMeta, + WifiBand, +}; + +pub use feitcsi::{ + parse_record as parse_feitcsi_record, resample_readings_to_pipeline, FeitCsiBandwidth, + FeitCsiError, FeitCsiFileReader, FeitCsiHeader, FeitCsiModType, FeitCsiRecord, + FeitCsiStreamReader, }; pub use neural_adapter::NeuralAdapter; pub use signal_adapter::SignalAdapter; diff --git a/v2/crates/wifi-densepose-train/Cargo.toml b/v2/crates/wifi-densepose-train/Cargo.toml index ddfeeb1a..7fe32f0c 100644 --- a/v2/crates/wifi-densepose-train/Cargo.toml +++ b/v2/crates/wifi-densepose-train/Cargo.toml @@ -107,3 +107,9 @@ ndarray-npy.workspace = true [[bench]] name = "training_bench" harness = false + +# ADR-288 — bfee-parser throughput and split-assignment benchmarks on +# synthetic, code-generated corpora (no dataset files). +[[bench]] +name = "benchmark_harness" +harness = false diff --git a/v2/crates/wifi-densepose-train/benches/benchmark_harness.rs b/v2/crates/wifi-densepose-train/benches/benchmark_harness.rs new file mode 100644 index 00000000..1dd6aee7 --- /dev/null +++ b/v2/crates/wifi-densepose-train/benches/benchmark_harness.rs @@ -0,0 +1,127 @@ +//! ADR-288 benchmarks: bfee parser throughput and split assignment over +//! synthetic, code-generated corpora (no dataset files are read or written). + +use criterion::{black_box, criterion_group, criterion_main, Criterion, Throughput}; +use wifi_densepose_train::dataset::widar::{encode_bfee_frame, parse_bfee_bytes, WIDAR_SUBCARRIERS}; +use wifi_densepose_train::protocols::leakage::LeakageAudit; +use wifi_densepose_train::protocols::{SampleMeta, SplitPlan, SplitProtocol, SplitSide}; + +/// Deterministic synthetic bfee log: `num_records` framed 3×3 records. +fn synthetic_log(num_records: usize) -> Vec { + let (n_rx, n_tx) = (3u8, 3u8); + let pairs = WIDAR_SUBCARRIERS * n_rx as usize * n_tx as usize; + let mut bytes = Vec::new(); + for t in 0..num_records { + let csi: Vec<(i16, i16)> = (0..pairs) + .map(|i| { + let re = ((t * 37 + i * 13) % 1024) as i16 - 512; + let im = ((t * 17 + i * 7) % 1024) as i16 - 512; + (re, im) + }) + .collect(); + bytes.extend_from_slice(&encode_bfee_frame(t as u32, t as u16, n_rx, n_tx, &csi)); + } + bytes +} + +/// Deterministic synthetic metadata corpus. +fn synthetic_metas(n: usize) -> Vec { + (0..n) + .map(|i| SampleMeta { + subject_id: 1 + (i % 17) as u32, + environment_id: 1 + (i % 3) as u32, + orientation_id: 1 + (i % 5) as u32, + gesture_id: 1 + (i % 6) as u32, + recording_id: (i / 50) as u64, + window_index: (i % 50) as u64, + }) + .collect() +} + +/// Deterministic synthetic corpus whose domain attributes are constant per +/// recording (as real datasets are), so protocol splits keep recordings whole +/// and the leakage audit exercises its full passing path. +fn synthetic_recording_metas(n: usize, windows_per_recording: usize) -> Vec { + (0..n) + .map(|i| { + let recording = (i / windows_per_recording) as u64; + SampleMeta { + subject_id: 1 + (recording % 17) as u32, + environment_id: 1 + (recording % 3) as u32, + orientation_id: 1 + (recording % 5) as u32, + gesture_id: 1 + (recording % 6) as u32, + recording_id: recording, + window_index: (i % windows_per_recording) as u64, + } + }) + .collect() +} + +fn bench_bfee_parser(c: &mut Criterion) { + let bytes = synthetic_log(500); + let mut group = c.benchmark_group("widar_bfee_parse"); + group.throughput(Throughput::Bytes(bytes.len() as u64)); + group.bench_function("500_records_3x3", |b| { + b.iter(|| { + let parse = parse_bfee_bytes(black_box(&bytes)); + assert_eq!(parse.records.len(), 500); + parse + }) + }); + group.finish(); +} + +fn bench_split_assignment(c: &mut Criterion) { + let metas = synthetic_metas(10_000); + let mut group = c.benchmark_group("split_assignment"); + group.throughput(Throughput::Elements(metas.len() as u64)); + for protocol in [ + SplitProtocol::CrossSubject, + SplitProtocol::CrossEnvironment, + SplitProtocol::CrossOrientation, + SplitProtocol::RandomBaseline, + ] { + let plan = SplitPlan::new(protocol, 42, 0.3).expect("valid fraction"); + group.bench_function(protocol.tag(), |b| { + b.iter(|| plan.partition(black_box(&metas))) + }); + } + group.finish(); +} + +fn bench_leakage_audit(c: &mut Criterion) { + // ~10k windows in 200 recordings; a clean cross-subject split so the + // audit runs every check (recording crossing + claimed disjointness) to + // completion instead of failing fast. + let metas = synthetic_recording_metas(10_000, 50); + let plan = SplitPlan::new(SplitProtocol::CrossSubject, 42, 0.3).expect("valid fraction"); + let mut train = Vec::new(); + let mut test = Vec::new(); + for meta in &metas { + match plan.assign(meta) { + SplitSide::Train => train.push(*meta), + SplitSide::Test => test.push(*meta), + } + } + assert!(!train.is_empty() && !test.is_empty(), "degenerate corpus"); + + let audit = LeakageAudit::for_protocol(SplitProtocol::CrossSubject); + let mut group = c.benchmark_group("leakage_audit"); + group.throughput(Throughput::Elements(metas.len() as u64)); + group.bench_function("cross_subject_10k_windows", |b| { + b.iter(|| { + audit + .audit(black_box(&train), black_box(&test)) + .expect("clean split must pass") + }) + }); + group.finish(); +} + +criterion_group!( + benches, + bench_bfee_parser, + bench_split_assignment, + bench_leakage_audit +); +criterion_main!(benches); diff --git a/v2/crates/wifi-densepose-train/src/dataset.rs b/v2/crates/wifi-densepose-train/src/dataset.rs index d13e8329..b187d7a2 100644 --- a/v2/crates/wifi-densepose-train/src/dataset.rs +++ b/v2/crates/wifi-densepose-train/src/dataset.rs @@ -40,6 +40,10 @@ //! assert_eq!(sample.amplitude.shape(), &[100, 3, 3, 56]); //! ``` +/// Widar3.0 ingest — Intel 5300 `.dat` "bfee" parser and [`CsiDataset`] +/// adapter with split-protocol metadata (ADR-288 §1). +pub mod widar; + use ndarray::{Array1, Array2, Array4}; use ruvector_temporal_tensor::segment as tt_segment; use ruvector_temporal_tensor::{TemporalTensorCompressor, TierPolicy}; diff --git a/v2/crates/wifi-densepose-train/src/dataset/widar.rs b/v2/crates/wifi-densepose-train/src/dataset/widar.rs new file mode 100644 index 00000000..ea451f1d --- /dev/null +++ b/v2/crates/wifi-densepose-train/src/dataset/widar.rs @@ -0,0 +1,1200 @@ +//! Widar3.0 ingest — Intel 5300 `.dat` "bfee" CSI log parser and dataset +//! adapter (ADR-288 §1). +//! +//! The Widar3.0 raw distribution ships CSI captured with the Intel 5300 NIC +//! and the Linux 802.11n CSI Tool, stored as framed binary `.dat` logs. This +//! module provides: +//! +//! - [`parse_bfee_bytes`] — a bounded, panic-free parser for the framed +//! "bfee" record stream. Invalid records are **skipped with a warning**, +//! never a panic: `.dat` files are untrusted input and are validated at the +//! boundary (CLAUDE.md). +//! - [`WidarDataset`] — a [`CsiDataset`] implementation that maps each `.dat` +//! recording into windowed [`CsiSample`]s (with subcarrier interpolation to +//! the training pipeline's target count) and exposes per-window +//! [`SampleMeta`] for the ADR-288 split protocols. +//! - [`encode_bfee_frame`] — a deterministic synthetic-fixture encoder used by +//! unit tests and benches, so no binary dataset files are ever checked in. +//! +//! # Binary record layout (ADR-288) +//! +//! ```text +//! frame : u16 LE field_len | u8 code (code 0xBB = bfee record) +//! field_len counts the code byte plus the payload, so the next +//! frame starts field_len + 2 bytes later. +//! payload : 20-byte bfee header +//! [0..4) timestamp_low u32 LE +//! [4..6) bfee_count u16 LE +//! [6..8) reserved (2 bytes, ignored) +//! [8] n_rx u8 (1..=3) +//! [9] n_tx u8 (1..=3) +//! [10..13) rssi_a/b/c u8 each +//! [13] noise i8 +//! [14] agc u8 +//! [15] antenna_sel u8 +//! [16..18) len u16 LE (packed CSI byte count) +//! [18..20) rate u16 LE +//! then `len` bytes of packed CSI. +//! csi : 10-bit two's-complement components, packed LSB-first with no +//! inter-field padding, in order +//! for sc in 0..30 { for rx in 0..n_rx { for tx in 0..n_tx { +//! real; imag; } } } +//! `len` must equal ceil(30 * n_rx * n_tx * 2 * 10 / 8). +//! ``` +//! +//! This is the layout specified by ADR-288. Note the original Linux CSI Tool +//! writes 8-bit components with per-group shift bits and a big-endian frame +//! length; if raw upstream logs are ingested unconverted, records fail the +//! `len` consistency check and are skipped with a warning rather than being +//! silently misdecoded. +//! +//! # Widar3.0 naming convention (assumed, tolerant) +//! +//! The Widar3.0 site was not reachable from this build environment, so the +//! convention below is **assumed** from the Widar3.0 paper/release notes and +//! the parser is deliberately tolerant (missing fields parse as `0`): +//! +//! ```text +//! /[room1/]/user1/user1-3-1-1-2-r5.dat +//! │ │ │ │ │ └ receiver id (optional) +//! │ │ │ │ └ repetition number +//! │ │ │ └ face orientation (1..=5) +//! │ │ └ torso location (1..=5) +//! │ └ gesture type +//! └ user id +//! ``` +//! +//! The environment/room id is taken from the nearest ancestor directory named +//! `room` (case-insensitive); when absent (the raw release groups by +//! capture date instead) it defaults to `0` and cross-environment splits over +//! such a tree will fail the leakage audit rather than silently pass. + +use ndarray::{Array1, Array2, Array3, Array4}; +use num_complex::Complex; +use std::path::{Path, PathBuf}; +use tracing::{debug, info, warn}; + +use crate::dataset::{CsiDataset, CsiSample}; +use crate::error::DatasetError; +use crate::protocols::SampleMeta; +use crate::subcarrier::interpolate_subcarriers; + +/// Complex CSI component type used by the parser. +pub type Complex32 = Complex; + +// --------------------------------------------------------------------------- +// Format constants +// --------------------------------------------------------------------------- + +/// Record code identifying a beamforming-feedback ("bfee") CSI record. +pub const BFEE_CODE: u8 = 0xBB; + +/// Bytes in the per-frame header (`u16` length + `u8` code). +const FRAME_HEADER_LEN: usize = 3; + +/// Bytes in the fixed bfee header that precedes the packed CSI payload. +const BFEE_HEADER_LEN: usize = 20; + +/// Number of subcarrier groups reported by the Intel 5300 (30 groups over a +/// 20/40 MHz channel). +pub const WIDAR_SUBCARRIERS: usize = 30; + +/// Bits per packed CSI component (10-bit two's complement). +const CSI_COMPONENT_BITS: usize = 10; + +/// Maximum antenna count on either side (Intel 5300 has 3 antennas). +const MAX_ANTENNAS: usize = 3; + +/// Upper bound on a single frame's `field_len`, derived from the largest +/// possible record (3×3 CSI ≈ 695 bytes) with generous slack. A larger value +/// means framing is lost; the parser stops instead of allocating unboundedly. +const MAX_FIELD_LEN: usize = 4096; + +/// Upper bound on a `.dat` file accepted by [`WidarDataset::discover`]. +/// Bounded allocation at the file boundary; larger files are skipped with a +/// warning. +const MAX_DAT_FILE_BYTES: u64 = 512 * 1024 * 1024; + +/// Number of COCO keypoints emitted in [`CsiSample`]s. Widar is a gesture +/// dataset with no pose ground truth, so keypoints are zero with visibility +/// `0` (COCO "not labelled"). +const NUM_KEYPOINTS: usize = 17; + +/// Packed CSI byte length for a record with the given antenna counts: +/// `ceil(30 × n_rx × n_tx × 2 × 10 / 8)`. +#[must_use] +pub fn packed_csi_len(n_rx: usize, n_tx: usize) -> usize { + (WIDAR_SUBCARRIERS * n_rx * n_tx * 2 * CSI_COMPONENT_BITS).div_ceil(8) +} + +// --------------------------------------------------------------------------- +// BfeeRecord + parser +// --------------------------------------------------------------------------- + +/// One decoded bfee CSI record. +#[derive(Debug, Clone)] +pub struct BfeeRecord { + /// Low 32 bits of the NIC's 1 MHz clock at capture time. + pub timestamp_low: u32, + /// Running count of bfee measurements delivered by the NIC. + pub bfee_count: u16, + /// Number of receive antennas (1..=3). + pub n_rx: u8, + /// Number of transmit antennas (1..=3). + pub n_tx: u8, + /// RSSI at antenna A (dB above an internal reference). + pub rssi_a: u8, + /// RSSI at antenna B. + pub rssi_b: u8, + /// RSSI at antenna C. + pub rssi_c: u8, + /// Noise floor estimate in dBm. + pub noise: i8, + /// Automatic gain control setting. + pub agc: u8, + /// Antenna selection / permutation bits. + pub antenna_sel: u8, + /// Rate/flags field as logged by the driver. + pub rate: u16, + /// Complex CSI, shape `[n_tx, n_rx, 30]`. + pub csi: Array3, +} + +/// Outcome of parsing a byte buffer of framed bfee records. +#[derive(Debug, Clone)] +pub struct BfeeParse { + /// Successfully decoded records, in file order. + pub records: Vec, + /// Number of records skipped because they were truncated or corrupt. + pub skipped: usize, + /// Number of well-framed records with a non-bfee code (ignored, not an + /// error — real logs interleave other record types). + pub non_bfee: usize, +} + +/// Parse a buffer of framed Intel 5300 bfee records (ADR-288 layout — see the +/// module docs for the exact binary format). +/// +/// The parser never panics on malformed input: invalid or truncated records +/// are skipped with a `warn!` and counted in [`BfeeParse::skipped`]. When +/// framing is irrecoverably lost (a `field_len` beyond [`MAX_FIELD_LEN`] or a +/// record extending past the end of the buffer) parsing stops at that point. +#[must_use] +pub fn parse_bfee_bytes(bytes: &[u8]) -> BfeeParse { + // Conservative lower-bound estimate (largest possible frame) so a clean + // log skips the early Vec doublings without ever over-reserving. + let max_frame = 2 + 1 + BFEE_HEADER_LEN + packed_csi_len(MAX_ANTENNAS, MAX_ANTENNAS); + let mut records = Vec::with_capacity(bytes.len() / max_frame); + let mut skipped = 0usize; + let mut non_bfee = 0usize; + let mut cursor = 0usize; + + while cursor + FRAME_HEADER_LEN <= bytes.len() { + let field_len = u16::from_le_bytes([bytes[cursor], bytes[cursor + 1]]) as usize; + if field_len == 0 { + warn!("bfee frame at byte {cursor}: zero field_len, skipping frame header"); + skipped += 1; + cursor += FRAME_HEADER_LEN; + continue; + } + if field_len > MAX_FIELD_LEN { + warn!( + "bfee frame at byte {cursor}: field_len {field_len} exceeds bound \ + {MAX_FIELD_LEN}; framing lost, abandoning remainder of buffer" + ); + skipped += 1; + break; + } + let frame_end = cursor + 2 + field_len; + if frame_end > bytes.len() { + warn!( + "bfee frame at byte {cursor}: truncated (needs {} bytes, {} remain)", + field_len + 2, + bytes.len() - cursor + ); + skipped += 1; + break; + } + + let code = bytes[cursor + 2]; + let payload = &bytes[cursor + FRAME_HEADER_LEN..frame_end]; + cursor = frame_end; + + if code != BFEE_CODE { + debug!("skipping non-bfee record code {code:#04x}"); + non_bfee += 1; + continue; + } + + match parse_bfee_payload(payload) { + Ok(record) => records.push(record), + Err(reason) => { + warn!("skipping corrupt bfee record: {reason}"); + skipped += 1; + } + } + } + + let tail = bytes.len().saturating_sub(cursor); + if tail > 0 && tail < FRAME_HEADER_LEN { + // A dangling partial frame header at EOF is a truncation, not silence. + warn!("bfee buffer ends with {tail} dangling byte(s) (truncated frame header)"); + skipped += 1; + } + + BfeeParse { + records, + skipped, + non_bfee, + } +} + +/// Decode the 20-byte bfee header + packed CSI payload of a single record. +fn parse_bfee_payload(payload: &[u8]) -> Result { + if payload.len() < BFEE_HEADER_LEN { + return Err(format!( + "payload too short: {} < {BFEE_HEADER_LEN} header bytes", + payload.len() + )); + } + + // Header slices are in-bounds by the length check above. + let timestamp_low = u32::from_le_bytes([payload[0], payload[1], payload[2], payload[3]]); + let bfee_count = u16::from_le_bytes([payload[4], payload[5]]); + // payload[6..8] reserved. + let n_rx = payload[8]; + let n_tx = payload[9]; + let rssi_a = payload[10]; + let rssi_b = payload[11]; + let rssi_c = payload[12]; + let noise = payload[13] as i8; + let agc = payload[14]; + let antenna_sel = payload[15]; + let csi_len = u16::from_le_bytes([payload[16], payload[17]]) as usize; + let rate = u16::from_le_bytes([payload[18], payload[19]]); + + if !(1..=MAX_ANTENNAS).contains(&(n_rx as usize)) { + return Err(format!("n_rx {n_rx} out of range 1..=3")); + } + if !(1..=MAX_ANTENNAS).contains(&(n_tx as usize)) { + return Err(format!("n_tx {n_tx} out of range 1..=3")); + } + let expected = packed_csi_len(n_rx as usize, n_tx as usize); + if csi_len != expected { + return Err(format!( + "csi len field {csi_len} does not match {expected} expected for \ + n_rx={n_rx}, n_tx={n_tx}" + )); + } + let body = &payload[BFEE_HEADER_LEN..]; + if body.len() < csi_len { + return Err(format!( + "packed CSI truncated: {} bytes present, {csi_len} declared", + body.len() + )); + } + let body = &body[..csi_len]; + + // Unpack: for sc { for rx { for tx { real; imag } } }, 10 bits each, + // LSB-first. A streaming bit accumulator reads each payload byte exactly + // once (instead of re-assembling a 3-byte window per component), and the + // components are written through the contiguous backing slice — the + // `[n_tx, n_rx, 30]` array is standard C order, so the destination index + // is `(tx * n_rx + rx) * 30 + sc`. + let (n_rx_u, n_tx_u) = (n_rx as usize, n_tx as usize); + let mut csi = Array3::::zeros((n_tx_u, n_rx_u, WIDAR_SUBCARRIERS)); + let flat = csi + .as_slice_mut() + .expect("freshly allocated Array3 is contiguous"); + let mut bits = BitReader::new(body); + for sc in 0..WIDAR_SUBCARRIERS { + for rx in 0..n_rx_u { + for tx in 0..n_tx_u { + let re = bits.next_i10(); + let im = bits.next_i10(); + flat[(tx * n_rx_u + rx) * WIDAR_SUBCARRIERS + sc] = + Complex32::new(re as f32, im as f32); + } + } + } + + Ok(BfeeRecord { + timestamp_low, + bfee_count, + n_rx, + n_tx, + rssi_a, + rssi_b, + rssi_c, + noise, + agc, + antenna_sel, + rate, + csi, + }) +} + +/// Streaming LSB-first bit reader over a packed CSI payload. +/// +/// Each payload byte is loaded into the accumulator exactly once; reads past +/// the slice end yield zero bits — callers bound the total bit count via the +/// `csi_len` consistency check, so that is belt-and-braces, not a format +/// feature. The accumulator never holds more than 17 bits, so `u32` cannot +/// overflow. +struct BitReader<'a> { + body: &'a [u8], + pos: usize, + acc: u32, + acc_bits: u32, +} + +impl<'a> BitReader<'a> { + fn new(body: &'a [u8]) -> Self { + BitReader { + body, + pos: 0, + acc: 0, + acc_bits: 0, + } + } + + /// Next 10-bit two's-complement integer (branchless sign extension). + #[inline] + fn next_i10(&mut self) -> i16 { + while self.acc_bits < CSI_COMPONENT_BITS as u32 { + let byte = self.body.get(self.pos).copied().unwrap_or(0); + self.pos += 1; + self.acc |= (byte as u32) << self.acc_bits; + self.acc_bits += 8; + } + let v = self.acc & 0x3FF; + self.acc >>= CSI_COMPONENT_BITS; + self.acc_bits -= CSI_COMPONENT_BITS as u32; + // Shift the 10-bit value to the top of an i32 and arithmetic-shift + // back down: sign extension without a branch. + (((v << 22) as i32) >> 22) as i16 + } +} + +/// Write a 10-bit two's-complement integer at `bit_off` into a zeroed buffer. +fn write_i10(buf: &mut [u8], bit_off: usize, value: i16) { + let v = (value as i32 & 0x3FF) as u32; + let byte = bit_off >> 3; + let shift = bit_off & 7; + let merged = v << shift; + buf[byte] |= (merged & 0xFF) as u8; + if byte + 1 < buf.len() { + buf[byte + 1] |= ((merged >> 8) & 0xFF) as u8; + } + if byte + 2 < buf.len() { + buf[byte + 2] |= ((merged >> 16) & 0xFF) as u8; + } +} + +/// Encode one framed bfee record from synthetic CSI values — the fixture +/// generator used by unit tests and benches (ADR-288: fixtures are generated +/// in code, never checked in as binary files). +/// +/// `csi` is `(real, imag)` pairs in the packing order +/// `for sc { for rx { for tx { .. } } }` and must contain exactly +/// `30 × n_rx × n_tx` entries with each component in `-512..=511`. +/// +/// # Panics +/// +/// Panics on programmer error: antenna counts outside `1..=3`, a wrong `csi` +/// length, or out-of-range components. This is a fixture builder for trusted +/// test inputs, not a boundary parser. +#[must_use] +pub fn encode_bfee_frame( + timestamp_low: u32, + bfee_count: u16, + n_rx: u8, + n_tx: u8, + csi: &[(i16, i16)], +) -> Vec { + assert!( + (1..=MAX_ANTENNAS).contains(&(n_rx as usize)), + "n_rx must be 1..=3" + ); + assert!( + (1..=MAX_ANTENNAS).contains(&(n_tx as usize)), + "n_tx must be 1..=3" + ); + let expected_pairs = WIDAR_SUBCARRIERS * n_rx as usize * n_tx as usize; + assert_eq!( + csi.len(), + expected_pairs, + "csi must contain 30 × n_rx × n_tx complex pairs" + ); + for &(re, im) in csi { + assert!( + (-512..=511).contains(&re) && (-512..=511).contains(&im), + "10-bit components must be in -512..=511" + ); + } + + let csi_len = packed_csi_len(n_rx as usize, n_tx as usize); + let mut packed = vec![0u8; csi_len]; + let mut bit_off = 0usize; + for &(re, im) in csi { + write_i10(&mut packed, bit_off, re); + bit_off += CSI_COMPONENT_BITS; + write_i10(&mut packed, bit_off, im); + bit_off += CSI_COMPONENT_BITS; + } + + let field_len = 1 + BFEE_HEADER_LEN + csi_len; // code + header + payload + let mut frame = Vec::with_capacity(2 + field_len); + frame.extend_from_slice(&(field_len as u16).to_le_bytes()); + frame.push(BFEE_CODE); + frame.extend_from_slice(×tamp_low.to_le_bytes()); + frame.extend_from_slice(&bfee_count.to_le_bytes()); + frame.extend_from_slice(&[0, 0]); // reserved + frame.push(n_rx); + frame.push(n_tx); + frame.extend_from_slice(&[33, 34, 35]); // rssi a/b/c + frame.push((-92i8) as u8); // noise + frame.push(30); // agc + frame.push(0b0000_0110); // antenna_sel + frame.extend_from_slice(&(csi_len as u16).to_le_bytes()); + frame.extend_from_slice(&0x4404u16.to_le_bytes()); // rate + frame.extend_from_slice(&packed); + frame +} + +// --------------------------------------------------------------------------- +// Widar naming convention +// --------------------------------------------------------------------------- + +/// Domain metadata parsed from a Widar3.0 `.dat` path (see the module docs +/// for the assumed naming convention). Fields the path does not encode are +/// `0`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct WidarFileMeta { + /// User (subject) id, e.g. `1` for `user1-…`. + pub user: u32, + /// Gesture type id (second dash field). + pub gesture: u32, + /// Torso location id (third dash field). + pub location: u32, + /// Face orientation id (fourth dash field). + pub orientation: u32, + /// Repetition number (fifth dash field). + pub repetition: u32, + /// Receiver id from a trailing `-r` field; `0` when absent. + pub receiver: u32, + /// Room/environment id from a `room` ancestor directory; `0` when the + /// tree does not encode one. + pub room: u32, +} + +/// Parse Widar3.0 domain metadata from a `.dat` path. Tolerant: returns +/// `None` only when the file stem yields no user id at all; any other missing +/// field parses as `0`. +#[must_use] +pub fn parse_widar_path(path: &Path) -> Option { + let stem = path.file_stem()?.to_str()?; + let mut fields = stem.split('-'); + + // First field: "user1" / "id1" / bare digits — take the numeric suffix. + let user = trailing_number(fields.next()?)?; + + let mut meta = WidarFileMeta { + user, + ..WidarFileMeta::default() + }; + + let positional: [&mut u32; 4] = [ + &mut meta.gesture, + &mut meta.location, + &mut meta.orientation, + &mut meta.repetition, + ]; + let mut pos = 0usize; + for field in fields { + let lower_r = field.len() >= 2 + && (field.starts_with('r') || field.starts_with('R')) + && field[1..].chars().all(|c| c.is_ascii_digit()); + if lower_r { + meta.receiver = field[1..].parse().unwrap_or(0); + continue; + } + if pos < positional.len() { + *positional[pos] = field.parse().unwrap_or(0); + pos += 1; + } + } + + // Room from the nearest `room` ancestor directory (case-insensitive). + for ancestor in path.ancestors().skip(1) { + if let Some(name) = ancestor.file_name().and_then(|n| n.to_str()) { + let lower = name.to_ascii_lowercase(); + if let Some(digits) = lower.strip_prefix("room") { + if let Ok(room) = digits.parse::() { + meta.room = room; + break; + } + } + } + } + + Some(meta) +} + +/// Numeric suffix of a token like `user1` → `1` (also accepts bare digits). +fn trailing_number(token: &str) -> Option { + let digits: String = token.chars().skip_while(|c| !c.is_ascii_digit()).collect(); + digits.parse().ok() +} + +// --------------------------------------------------------------------------- +// WidarDataset +// --------------------------------------------------------------------------- + +/// An indexed `.dat` recording in the Widar scan. +#[derive(Debug, Clone)] +struct WidarEntry { + path: PathBuf, + meta: WidarFileMeta, + /// Antenna dims established by the first valid record of the file. + n_tx: usize, + n_rx: usize, + /// Number of valid records with matching antenna dims. + num_frames: usize, + window_frames: usize, +} + +impl WidarEntry { + /// Number of stride-1 windows this recording contributes. + fn num_windows(&self) -> usize { + if self.num_frames < self.window_frames { + 0 + } else { + self.num_frames - self.window_frames + 1 + } + } +} + +/// Dataset adapter for Widar3.0 `.dat` recordings (ADR-288 §1). +/// +/// Scanning parses every file once at construction to count valid records; +/// [`CsiDataset::get`] re-reads the file lazily and cuts the requested +/// stride-1 window. Each `.dat` file is treated as **one continuous +/// recording** for the leakage audit ([`crate::protocols::leakage`]): its +/// [`SampleMeta::recording_id`] is the file's index in the sorted scan. +/// +/// Widar has no pose ground truth, so [`CsiSample::keypoints`] are zeros with +/// visibility `0` ("not labelled"); `subject_id` carries the user id and +/// `action_id` the gesture id. +pub struct WidarDataset { + entries: Vec, + /// Prefix-sum of window counts (length = entries.len() + 1). + cumulative: Vec, + window_frames: usize, + target_subcarriers: usize, + /// Root directory stored for display / debug purposes. + #[allow(dead_code)] + root: PathBuf, +} + +impl WidarDataset { + /// Scan `root` recursively for `.dat` recordings and build a window index. + /// + /// Unreadable, oversized, or record-free files are skipped with a + /// warning; a root with no usable recordings is an error. + /// + /// # Errors + /// + /// [`DatasetError::DataNotFound`] when `root` does not exist or yields no + /// usable recording; I/O errors for filesystem access failures. + pub fn discover( + root: &Path, + window_frames: usize, + target_subcarriers: usize, + ) -> Result { + if window_frames == 0 { + return Err(DatasetError::invalid_format( + root, + "window_frames must be > 0", + )); + } + if !root.exists() { + return Err(DatasetError::not_found( + root, + "Widar root directory not found", + )); + } + + let mut dat_paths: Vec = walkdir::WalkDir::new(root) + .into_iter() + .filter_map(|e| e.ok()) + .filter(|e| e.file_type().is_file()) + .map(|e| e.into_path()) + .filter(|p| { + p.extension() + .and_then(|e| e.to_str()) + .map(|e| e.eq_ignore_ascii_case("dat")) + .unwrap_or(false) + }) + .collect(); + dat_paths.sort(); + + let mut entries = Vec::new(); + for path in dat_paths { + match Self::scan_file(&path, window_frames) { + Ok(Some(entry)) => entries.push(entry), + Ok(None) => {} + Err(e) => warn!("Skipping {}: {e}", path.display()), + } + } + + if entries.is_empty() { + return Err(DatasetError::not_found( + root, + "no usable Widar .dat recordings found under root", + )); + } + + let mut cumulative = vec![0usize; entries.len() + 1]; + for (i, e) in entries.iter().enumerate() { + cumulative[i + 1] = cumulative[i] + e.num_windows(); + } + + info!( + "WidarDataset: scanned {} recordings, {} total windows (root={})", + entries.len(), + cumulative.last().copied().unwrap_or(0), + root.display() + ); + + Ok(WidarDataset { + entries, + cumulative, + window_frames, + target_subcarriers, + root: root.to_path_buf(), + }) + } + + /// Scan one `.dat` file: size bound, record count, antenna dims, + /// path metadata. `Ok(None)` means "valid scan, nothing usable". + fn scan_file(path: &Path, window_frames: usize) -> Result, DatasetError> { + let file_len = std::fs::metadata(path) + .map_err(|e| DatasetError::io_error(path, e))? + .len(); + if file_len > MAX_DAT_FILE_BYTES { + warn!( + "Skipping {}: {file_len} bytes exceeds the {MAX_DAT_FILE_BYTES}-byte bound", + path.display() + ); + return Ok(None); + } + + let meta = match parse_widar_path(path) { + Some(m) => m, + None => { + warn!( + "{}: file name does not follow the Widar convention; using zeroed metadata", + path.display() + ); + WidarFileMeta::default() + } + }; + + let bytes = std::fs::read(path).map_err(|e| DatasetError::io_error(path, e))?; + let parse = parse_bfee_bytes(&bytes); + if parse.skipped > 0 { + warn!( + "{}: skipped {} invalid record(s) ({} valid)", + path.display(), + parse.skipped, + parse.records.len() + ); + } + let Some(first) = parse.records.first() else { + warn!("Skipping {}: no valid bfee records", path.display()); + return Ok(None); + }; + let (n_tx, n_rx) = (first.n_tx as usize, first.n_rx as usize); + let num_frames = parse + .records + .iter() + .filter(|r| r.n_tx as usize == n_tx && r.n_rx as usize == n_rx) + .count(); + if num_frames < parse.records.len() { + warn!( + "{}: dropped {} record(s) with antenna dims differing from the first \ + ({n_tx}×{n_rx})", + path.display(), + parse.records.len() - num_frames + ); + } + if num_frames < window_frames { + debug!( + "{}: {} frame(s) < window {window_frames}; contributes no windows", + path.display(), + num_frames + ); + } + Ok(Some(WidarEntry { + path: path.to_path_buf(), + meta, + n_tx, + n_rx, + num_frames, + window_frames, + })) + } + + /// Resolve a global window index to `(entry_index, frame_offset)`. + fn locate(&self, idx: usize) -> Option<(usize, usize)> { + let total = self.cumulative.last().copied().unwrap_or(0); + if idx >= total { + return None; + } + let entry_idx = self + .cumulative + .partition_point(|&c| c <= idx) + .saturating_sub(1); + Some((entry_idx, idx - self.cumulative[entry_idx])) + } + + /// Split-protocol metadata for the window at `idx` (ADR-288 §2): user → + /// subject, room → environment, plus orientation/gesture, and the owning + /// `.dat` file as the continuous `recording_id`. + /// + /// # Errors + /// + /// [`DatasetError::IndexOutOfBounds`] when `idx >= self.len()`. + pub fn sample_meta(&self, idx: usize) -> Result { + let (entry_idx, offset) = self.locate(idx).ok_or(DatasetError::IndexOutOfBounds { + idx, + len: self.cumulative.last().copied().unwrap_or(0), + })?; + let m = &self.entries[entry_idx].meta; + Ok(SampleMeta { + subject_id: m.user, + environment_id: m.room, + orientation_id: m.orientation, + gesture_id: m.gesture, + recording_id: entry_idx as u64, + window_index: offset as u64, + }) + } + + /// [`SampleMeta`] for every window, in index order — the input to + /// [`crate::protocols::SplitPlan::partition`]. + pub fn sample_metas(&self) -> Vec { + (0..self.len()) + .map(|i| { + self.sample_meta(i) + .expect("index < len is always locatable") + }) + .collect() + } + + /// Number of `.dat` recordings behind this dataset. + #[must_use] + pub fn num_recordings(&self) -> usize { + self.entries.len() + } +} + +impl CsiDataset for WidarDataset { + fn len(&self) -> usize { + self.cumulative.last().copied().unwrap_or(0) + } + + fn get(&self, idx: usize) -> Result { + let total = self.len(); + let (entry_idx, offset) = self + .locate(idx) + .ok_or(DatasetError::IndexOutOfBounds { idx, len: total })?; + let entry = &self.entries[entry_idx]; + + let bytes = + std::fs::read(&entry.path).map_err(|e| DatasetError::io_error(&entry.path, e))?; + let parse = parse_bfee_bytes(&bytes); + let records: Vec<&BfeeRecord> = parse + .records + .iter() + .filter(|r| r.n_tx as usize == entry.n_tx && r.n_rx as usize == entry.n_rx) + .collect(); + + let t_end = offset + self.window_frames; + if t_end > records.len() { + // The file changed on disk since discovery. + return Err(DatasetError::invalid_format( + &entry.path, + format!( + "window [{offset}, {t_end}) exceeds {} valid frame(s); \ + file changed since scan?", + records.len() + ), + )); + } + + let (n_tx, n_rx) = (entry.n_tx, entry.n_rx); + let mut amplitude = + Array4::::zeros((self.window_frames, n_tx, n_rx, WIDAR_SUBCARRIERS)); + let mut phase = Array4::::zeros((self.window_frames, n_tx, n_rx, WIDAR_SUBCARRIERS)); + for (t, record) in records[offset..t_end].iter().enumerate() { + for tx in 0..n_tx { + for rx in 0..n_rx { + for sc in 0..WIDAR_SUBCARRIERS { + let c = record.csi[[tx, rx, sc]]; + amplitude[[t, tx, rx, sc]] = c.norm(); + phase[[t, tx, rx, sc]] = c.arg(); + } + } + } + } + + let amplitude = if WIDAR_SUBCARRIERS != self.target_subcarriers { + interpolate_subcarriers(&litude, self.target_subcarriers) + } else { + amplitude + }; + let phase = if WIDAR_SUBCARRIERS != self.target_subcarriers { + interpolate_subcarriers(&phase, self.target_subcarriers) + } else { + phase + }; + + Ok(CsiSample { + amplitude, + phase, + keypoints: Array2::zeros((NUM_KEYPOINTS, 2)), + keypoint_visibility: Array1::zeros(NUM_KEYPOINTS), + subject_id: entry.meta.user, + action_id: entry.meta.gesture, + frame_id: offset as u64, + }) + } + + fn name(&self) -> &str { + "WidarDataset" + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use approx::assert_abs_diff_eq; + + /// Deterministic synthetic CSI pattern for record `t`: values derived + /// from the pair index, folded into the 10-bit range. + fn synthetic_csi(t: usize, n_rx: usize, n_tx: usize) -> Vec<(i16, i16)> { + (0..WIDAR_SUBCARRIERS * n_rx * n_tx) + .map(|i| { + let re = ((t * 37 + i * 13) % 1024) as i16 - 512; + let im = ((t * 17 + i * 7) % 1024) as i16 - 512; + (re, im) + }) + .collect() + } + + fn synthetic_file(num_records: usize, n_rx: u8, n_tx: u8) -> Vec { + let mut bytes = Vec::new(); + for t in 0..num_records { + let csi = synthetic_csi(t, n_rx as usize, n_tx as usize); + bytes.extend_from_slice(&encode_bfee_frame( + 1000 + t as u32, + t as u16, + n_rx, + n_tx, + &csi, + )); + } + bytes + } + + // ----- parser: valid fixtures ------------------------------------------ + + #[test] + fn parse_roundtrips_valid_records() { + let bytes = synthetic_file(5, 3, 2); + let parse = parse_bfee_bytes(&bytes); + assert_eq!(parse.records.len(), 5); + assert_eq!(parse.skipped, 0); + assert_eq!(parse.non_bfee, 0); + + let r = &parse.records[2]; + assert_eq!(r.timestamp_low, 1002); + assert_eq!(r.bfee_count, 2); + assert_eq!(r.n_rx, 3); + assert_eq!(r.n_tx, 2); + assert_eq!(r.noise, -92); + assert_eq!(r.rate, 0x4404); + assert_eq!(r.csi.shape(), &[2, 3, WIDAR_SUBCARRIERS]); + + // Bit-exact roundtrip of every component, including negatives. + let csi = synthetic_csi(2, 3, 2); + let mut i = 0usize; + for sc in 0..WIDAR_SUBCARRIERS { + for rx in 0..3 { + for tx in 0..2 { + let (re, im) = csi[i]; + assert_abs_diff_eq!(r.csi[[tx, rx, sc]].re, re as f32, epsilon = 0.0); + assert_abs_diff_eq!(r.csi[[tx, rx, sc]].im, im as f32, epsilon = 0.0); + i += 1; + } + } + } + } + + #[test] + fn parse_sign_extends_extremes() { + let n = WIDAR_SUBCARRIERS; + let mut csi = vec![(0i16, 0i16); n]; + csi[0] = (-512, 511); + csi[n - 1] = (-1, 1); + let bytes = encode_bfee_frame(7, 1, 1, 1, &csi); + let parse = parse_bfee_bytes(&bytes); + assert_eq!(parse.records.len(), 1); + let r = &parse.records[0]; + assert_eq!(r.csi[[0, 0, 0]], Complex32::new(-512.0, 511.0)); + assert_eq!(r.csi[[0, 0, n - 1]], Complex32::new(-1.0, 1.0)); + } + + #[test] + fn parse_is_deterministic() { + let bytes = synthetic_file(3, 2, 2); + let a = parse_bfee_bytes(&bytes); + let b = parse_bfee_bytes(&bytes); + assert_eq!(a.records.len(), b.records.len()); + for (ra, rb) in a.records.iter().zip(&b.records) { + assert_eq!(ra.csi, rb.csi); + } + } + + // ----- parser: truncated / corrupt fixtures ---------------------------- + + #[test] + fn parse_empty_buffer_is_empty() { + let parse = parse_bfee_bytes(&[]); + assert!(parse.records.is_empty()); + assert_eq!(parse.skipped, 0); + } + + #[test] + fn parse_truncated_record_is_skipped_not_panic() { + let mut bytes = synthetic_file(2, 2, 1); + // Chop the last record mid-payload. + let cut = bytes.len() - 10; + bytes.truncate(cut); + let parse = parse_bfee_bytes(&bytes); + assert_eq!(parse.records.len(), 1); + assert_eq!(parse.skipped, 1); + } + + #[test] + fn parse_dangling_header_bytes_counted() { + let mut bytes = synthetic_file(1, 1, 1); + bytes.extend_from_slice(&[0x07, 0x00]); // 2 dangling bytes < frame header + let parse = parse_bfee_bytes(&bytes); + assert_eq!(parse.records.len(), 1); + assert_eq!(parse.skipped, 1); + } + + #[test] + fn parse_zero_field_len_resyncs() { + let mut bytes = vec![0u8, 0u8, 0xBB]; // zero-length frame + bytes.extend_from_slice(&synthetic_file(1, 1, 1)); + let parse = parse_bfee_bytes(&bytes); + assert_eq!(parse.records.len(), 1); + assert_eq!(parse.skipped, 1); + } + + #[test] + fn parse_oversized_field_len_stops_bounded() { + let mut bytes = Vec::new(); + bytes.extend_from_slice(&u16::MAX.to_le_bytes()); + bytes.push(BFEE_CODE); + bytes.extend_from_slice(&vec![0u8; 64]); + let parse = parse_bfee_bytes(&bytes); + assert!(parse.records.is_empty()); + assert_eq!(parse.skipped, 1); + } + + #[test] + fn parse_non_bfee_code_is_ignored() { + let mut bytes = Vec::new(); + // A well-framed record with a different code. + bytes.extend_from_slice(&4u16.to_le_bytes()); + bytes.push(0xC1); + bytes.extend_from_slice(&[1, 2, 3]); + bytes.extend_from_slice(&synthetic_file(1, 1, 1)); + let parse = parse_bfee_bytes(&bytes); + assert_eq!(parse.records.len(), 1); + assert_eq!(parse.non_bfee, 1); + assert_eq!(parse.skipped, 0); + } + + #[test] + fn parse_corrupt_antenna_count_is_skipped() { + let mut bytes = synthetic_file(2, 2, 2); + // First frame: corrupt n_rx (payload byte 8 → frame offset 3 + 8). + bytes[3 + 8] = 9; + let parse = parse_bfee_bytes(&bytes); + assert_eq!(parse.records.len(), 1); + assert_eq!(parse.skipped, 1); + } + + #[test] + fn parse_len_field_mismatch_is_skipped() { + let mut bytes = synthetic_file(1, 1, 1); + // Corrupt the csi len field (payload bytes 16..18 → frame offset 19). + bytes[3 + 16] = 0xFF; + let parse = parse_bfee_bytes(&bytes); + assert!(parse.records.is_empty()); + assert_eq!(parse.skipped, 1); + } + + #[test] + fn packed_len_matches_formula() { + // 30 × n_rx × n_tx × 2 comps × 10 bits, ceil to bytes. + assert_eq!(packed_csi_len(1, 1), 75); + assert_eq!(packed_csi_len(3, 1), 225); + assert_eq!(packed_csi_len(3, 3), 675); + } + + // ----- naming convention ----------------------------------------------- + + #[test] + fn parses_full_widar_name() { + let m = parse_widar_path(Path::new("/data/room2/20181130/user1/user1-3-1-4-2-r5.dat")) + .unwrap(); + assert_eq!( + m, + WidarFileMeta { + user: 1, + gesture: 3, + location: 1, + orientation: 4, + repetition: 2, + receiver: 5, + room: 2, + } + ); + } + + #[test] + fn parses_name_without_receiver_or_room() { + let m = parse_widar_path(Path::new("user12/user12-6-2-3-1.dat")).unwrap(); + assert_eq!(m.user, 12); + assert_eq!(m.gesture, 6); + assert_eq!(m.orientation, 3); + assert_eq!(m.receiver, 0); + assert_eq!(m.room, 0); + } + + #[test] + fn tolerates_short_names() { + let m = parse_widar_path(Path::new("user3-2.dat")).unwrap(); + assert_eq!(m.user, 3); + assert_eq!(m.gesture, 2); + assert_eq!(m.orientation, 0); + assert!(parse_widar_path(Path::new("nodigits.dat")).is_none()); + } + + // ----- WidarDataset end-to-end on synthetic files ---------------------- + + fn write_synthetic_tree(root: &Path) { + // Two users, one recording each, in room1/room2. + for (user, room) in [(1u32, 1u32), (2, 2)] { + let dir = root.join(format!("room{room}")).join(format!("user{user}")); + std::fs::create_dir_all(&dir).unwrap(); + let file = dir.join(format!("user{user}-1-1-{user}-1-r1.dat")); + std::fs::write(&file, synthetic_file(6, 2, 1)).unwrap(); + } + } + + #[test] + fn widar_dataset_discovers_and_windows() { + let tmp = tempfile::tempdir().unwrap(); + write_synthetic_tree(tmp.path()); + + let ds = WidarDataset::discover(tmp.path(), 4, 56).unwrap(); + assert_eq!(ds.num_recordings(), 2); + // 6 frames, window 4 ⇒ 3 windows per recording. + assert_eq!(ds.len(), 6); + + let s = ds.get(0).unwrap(); + assert_eq!(s.amplitude.shape(), &[4, 1, 2, 56]); + assert_eq!(s.phase.shape(), &[4, 1, 2, 56]); + assert_eq!(s.keypoints.shape(), &[17, 2]); + assert_eq!(s.subject_id, 1); + assert_eq!(s.action_id, 1); + + // Second recording's windows carry the second user's metadata. + let s2 = ds.get(3).unwrap(); + assert_eq!(s2.subject_id, 2); + assert_eq!(s2.frame_id, 0); + + // Out of bounds is an error, not a panic. + assert!(matches!( + ds.get(6), + Err(DatasetError::IndexOutOfBounds { idx: 6, len: 6 }) + )); + } + + #[test] + fn widar_dataset_native_subcarriers_skip_interpolation() { + let tmp = tempfile::tempdir().unwrap(); + write_synthetic_tree(tmp.path()); + let ds = WidarDataset::discover(tmp.path(), 4, WIDAR_SUBCARRIERS).unwrap(); + let s = ds.get(0).unwrap(); + assert_eq!(s.amplitude.shape(), &[4, 1, 2, WIDAR_SUBCARRIERS]); + // Amplitude of the first component must equal |re + j·im| of the fixture. + let csi = synthetic_csi(0, 2, 1); + let (re, im) = csi[0]; + let expected = ((re as f32).powi(2) + (im as f32).powi(2)).sqrt(); + assert_abs_diff_eq!(s.amplitude[[0, 0, 0, 0]], expected, epsilon = 1e-4); + } + + #[test] + fn widar_sample_meta_maps_domains() { + let tmp = tempfile::tempdir().unwrap(); + write_synthetic_tree(tmp.path()); + let ds = WidarDataset::discover(tmp.path(), 4, 56).unwrap(); + + let metas = ds.sample_metas(); + assert_eq!(metas.len(), ds.len()); + // Windows 0..3 belong to recording 0 (user1, room1, orientation 1). + assert_eq!(metas[0].subject_id, 1); + assert_eq!(metas[0].environment_id, 1); + assert_eq!(metas[0].orientation_id, 1); + assert_eq!(metas[0].recording_id, 0); + assert_eq!(metas[2].window_index, 2); + // Windows 3..6 belong to recording 1 (user2, room2, orientation 2). + assert_eq!(metas[3].subject_id, 2); + assert_eq!(metas[3].environment_id, 2); + assert_eq!(metas[3].orientation_id, 2); + assert_eq!(metas[3].recording_id, 1); + + assert!(ds.sample_meta(999).is_err()); + } + + #[test] + fn widar_dataset_skips_corrupt_file_keeps_valid() { + let tmp = tempfile::tempdir().unwrap(); + write_synthetic_tree(tmp.path()); + // A garbage .dat file must not abort discovery. + std::fs::write(tmp.path().join("user9-1-1-1-1.dat"), [0xFFu8; 64]).unwrap(); + let ds = WidarDataset::discover(tmp.path(), 4, 56).unwrap(); + assert_eq!(ds.num_recordings(), 2); + } + + #[test] + fn widar_dataset_missing_root_errors() { + assert!(matches!( + WidarDataset::discover(Path::new("/nonexistent/widar"), 4, 56), + Err(DatasetError::DataNotFound { .. }) + )); + } +} diff --git a/v2/crates/wifi-densepose-train/src/error.rs b/v2/crates/wifi-densepose-train/src/error.rs index 2a4f824c..9bfd35f7 100644 --- a/v2/crates/wifi-densepose-train/src/error.rs +++ b/v2/crates/wifi-densepose-train/src/error.rs @@ -12,7 +12,8 @@ //! ├── ConfigError (config validation / file loading) //! ├── DatasetError (data loading, I/O, format) //! ├── SubcarrierError (frequency-axis resampling) -//! └── MaeError (MAE patchify / masking — ADR-152 §2.3) +//! ├── MaeError (MAE patchify / masking — ADR-152 §2.3) +//! └── ProtocolError (split protocols / leakage audit — ADR-288) //! ``` use std::path::PathBuf; @@ -49,6 +50,10 @@ pub enum TrainError { #[error("MAE pretraining error: {0}")] Mae(#[from] MaeError), + /// A split-protocol / leakage-audit error (ADR-288). + #[error("Protocol error: {0}")] + Protocol(#[from] ProtocolError), + /// JSON (de)serialization error. #[error("JSON error: {0}")] Json(#[from] serde_json::Error), @@ -466,3 +471,98 @@ pub enum MaeError { value: f32, }, } + +// --------------------------------------------------------------------------- +// ProtocolError +// --------------------------------------------------------------------------- + +/// Errors produced by the public-benchmark split protocols and leakage guards +/// ([`crate::protocols`], ADR-288). +/// +/// Every leakage-audit failure is an `Err`, never a warning: a split that +/// leaks subjects, environments, or windows of a continuous recording across +/// the train/test boundary must not be usable for reporting. +#[derive(Debug, Error)] +pub enum ProtocolError { + /// The requested held-out fraction is not a finite value strictly inside + /// `(0, 1)`. + #[error("Invalid test fraction {value}: must be finite and strictly inside (0, 1)")] + InvalidTestFraction { + /// The offending fraction. + value: f64, + }, + + /// A split side contains no samples — a degenerate split cannot support + /// any claim. + #[error("The {side} partition is empty")] + EmptyPartition { + /// Which side is empty (`"train"` or `"test"`). + side: &'static str, + }, + + /// A subject appears on both sides of a split that claims + /// subject-disjointness. + #[error("Subject {subject_id} appears in both train and test (subject leakage)")] + SubjectOverlap { + /// The leaked subject id. + subject_id: u32, + }, + + /// An environment/room appears on both sides of a split that claims + /// environment-disjointness. + #[error("Environment {environment_id} appears in both train and test (environment leakage)")] + EnvironmentOverlap { + /// The leaked environment id. + environment_id: u32, + }, + + /// An orientation appears on both sides of a split that claims + /// orientation-disjointness. + #[error("Orientation {orientation_id} appears in both train and test (orientation leakage)")] + OrientationOverlap { + /// The leaked orientation id. + orientation_id: u32, + }, + + /// Two windows cut from the same continuous recording ended up on + /// opposite sides of the split. Overlapping/adjacent windows are + /// near-identical, so this is window-level leakage regardless of the + /// protocol (the 2024–2025 leakage reckoning; ADR-288 §Context). + #[error( + "Recording {recording_id} has windows on both sides of the split \ + (window-level leakage from a continuous recording)" + )] + RecordingCrossesSplit { + /// The recording whose windows straddle the boundary. + recording_id: u64, + }, + + /// The mean-pose baseline cannot be fitted because the training split + /// contributed no poses. + #[error("Cannot fit mean-pose baseline: the training split contains no poses")] + EmptyTrainingPoses, + + /// A pose array has a different shape from the first pose seen. + #[error("Pose shape mismatch: expected {expected:?}, got {actual:?}")] + PoseShapeMismatch { + /// Shape established by the first pose. + expected: Vec, + /// Offending shape. + actual: Vec, + }, + + /// A `MEASURED` evidence grade was requested without a reproducer + /// command. CLAUDE.md: accuracy statements tagged `MEASURED` require a + /// reproducer; anything else must be `SYNTHETIC` or `CLAIMED`. + #[error("MEASURED evidence requires a non-empty reproducer command string")] + MissingReproducer, + + /// A reported metric is NaN or ±inf. + #[error("Metric `{name}` is not finite: {value}")] + NonFiniteMetric { + /// Name of the offending metric. + name: String, + /// The non-finite value. + value: f64, + }, +} diff --git a/v2/crates/wifi-densepose-train/src/lib.rs b/v2/crates/wifi-densepose-train/src/lib.rs index 31745f85..67c8eff4 100644 --- a/v2/crates/wifi-densepose-train/src/lib.rs +++ b/v2/crates/wifi-densepose-train/src/lib.rs @@ -59,6 +59,11 @@ pub mod mae; /// `oks_canonical`, available **without** the `tch-backend` feature so the /// single metric definition is reachable from the workspace test gate. pub mod metrics_core; +/// Public-benchmark split protocols and leakage guards (ADR-288 §2–3) — +/// deterministic cross-subject / cross-environment / cross-orientation +/// assignment plus the structural [`protocols::leakage::LeakageAudit`], +/// mean-pose baseline, and evidence-graded evaluation reports. +pub mod protocols; pub mod rapid_adapt; pub mod ruview_metrics; pub mod signal_features; @@ -103,7 +108,14 @@ pub use config::TrainingConfig; pub use dataset::{ CsiDataset, CsiSample, DataLoader, MmFiDataset, SyntheticConfig, SyntheticCsiDataset, }; -pub use error::{ConfigError, DatasetError, MaeError, SubcarrierError, TrainError}; +// ADR-288 — Widar3.0 ingest, split protocols, and leakage guards. +pub use dataset::widar::{parse_bfee_bytes, BfeeParse, BfeeRecord, WidarDataset, WidarFileMeta}; +pub use protocols::leakage::{ + EvaluationReport, EvidenceGrade, LeakageAudit, LeakageClaims, MeanPoseBaseline, +}; +pub use protocols::{SampleMeta, SplitPlan, SplitProtocol, SplitSide}; + +pub use error::{ConfigError, DatasetError, MaeError, ProtocolError, SubcarrierError, TrainError}; // TrainResult is the generic Result alias from error.rs; the concrete // TrainResult struct from trainer.rs is accessed via trainer::TrainResult. pub use error::TrainResult as TrainResultAlias; diff --git a/v2/crates/wifi-densepose-train/src/protocols.rs b/v2/crates/wifi-densepose-train/src/protocols.rs new file mode 100644 index 00000000..851d9fbc --- /dev/null +++ b/v2/crates/wifi-densepose-train/src/protocols.rs @@ -0,0 +1,388 @@ +//! Standard public-benchmark split protocols (ADR-288 §2). +//! +//! The field's documented leakage failure is the window-level random split: +//! adjacent windows cut from one continuous recording are near-identical, so +//! splitting them across train/test inflates accuracy (one dataset's F1 +//! collapsed from ~90% to ~22% under subject-disjoint splits — ADR-288 +//! §Context). This module expresses the standard leaderboard evaluations as a +//! [`SplitProtocol`] whose assignment is a **pure function of sample metadata +//! plus a seed** — no RNG state, no iteration-order dependence, byte-identical +//! across runs and platforms. +//! +//! - [`SplitProtocol::CrossSubject`] — MM-Fi-style: held-out subjects. +//! - [`SplitProtocol::CrossEnvironment`] — held-out rooms/environments. +//! - [`SplitProtocol::CrossOrientation`] — Widar-style: held-out orientations. +//! - [`SplitProtocol::RandomBaseline`] — window-level random split, kept +//! **only** as the explicitly leakage-prone comparison point; it makes no +//! disjointness claim and will normally fail the +//! [`leakage::LeakageAudit`]. +//! +//! Structural verification of a produced split lives in [`leakage`]. + +pub mod leakage; + +use serde::{Deserialize, Serialize}; + +use crate::error::ProtocolError; + +// --------------------------------------------------------------------------- +// SampleMeta +// --------------------------------------------------------------------------- + +/// Loader-agnostic per-window metadata consumed by split assignment and the +/// leakage audit. Produced by e.g. +/// [`WidarDataset::sample_meta`](crate::dataset::widar::WidarDataset::sample_meta). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct SampleMeta { + /// Subject/user id. + pub subject_id: u32, + /// Environment/room id (`0` when the dataset tree does not encode one). + pub environment_id: u32, + /// Orientation id (Widar face orientation; `0` when unknown). + pub orientation_id: u32, + /// Gesture/action id. + pub gesture_id: u32, + /// Identifier of the continuous recording this window was cut from. + /// Windows sharing a `recording_id` are temporally correlated and must + /// never straddle a train/test boundary. + pub recording_id: u64, + /// Window offset within the recording. + pub window_index: u64, +} + +// --------------------------------------------------------------------------- +// SplitProtocol +// --------------------------------------------------------------------------- + +/// Which side of a train/test split a sample is assigned to. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum SplitSide { + /// Training partition. + Train, + /// Held-out test partition. + Test, +} + +/// A standard evaluation protocol determining *what* is held out. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum SplitProtocol { + /// Hold out whole subjects (MM-Fi cross-subject protocol). + CrossSubject, + /// Hold out whole environments/rooms (MM-Fi cross-environment protocol). + CrossEnvironment, + /// Hold out whole orientations (Widar3.0 cross-orientation protocol). + CrossOrientation, + /// Window-level random split. **Leakage-prone by construction** — kept + /// only so leaderboard-style numbers can be contrasted against a leaky + /// baseline; it claims no disjointness and normally fails the audit. + RandomBaseline, +} + +impl SplitProtocol { + /// Stable lowercase tag for logs/reports. + #[must_use] + pub fn tag(self) -> &'static str { + match self { + SplitProtocol::CrossSubject => "cross-subject", + SplitProtocol::CrossEnvironment => "cross-environment", + SplitProtocol::CrossOrientation => "cross-orientation", + SplitProtocol::RandomBaseline => "random-baseline-leaky", + } + } + + /// Disjointness this protocol claims and the audit must verify. + #[must_use] + pub fn claims(self) -> leakage::LeakageClaims { + match self { + SplitProtocol::CrossSubject => leakage::LeakageClaims { + subject_disjoint: true, + environment_disjoint: false, + orientation_disjoint: false, + }, + SplitProtocol::CrossEnvironment => leakage::LeakageClaims { + subject_disjoint: false, + environment_disjoint: true, + orientation_disjoint: false, + }, + SplitProtocol::CrossOrientation => leakage::LeakageClaims { + subject_disjoint: false, + environment_disjoint: false, + orientation_disjoint: true, + }, + SplitProtocol::RandomBaseline => leakage::LeakageClaims { + subject_disjoint: false, + environment_disjoint: false, + orientation_disjoint: false, + }, + } + } +} + +// --------------------------------------------------------------------------- +// SplitPlan +// --------------------------------------------------------------------------- + +/// A concrete, seeded instantiation of a [`SplitProtocol`]. +/// +/// [`SplitPlan::assign`] is a pure function: the same `(protocol, seed, +/// test_fraction, meta)` always yields the same side, independent of call +/// order, thread, or platform. +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +pub struct SplitPlan { + /// The evaluation protocol. + pub protocol: SplitProtocol, + /// Seed mixed into every assignment hash. + pub seed: u64, + /// Target fraction of held-out *units* (subjects / environments / + /// orientations / windows, per protocol), strictly inside `(0, 1)`. + pub test_fraction: f64, +} + +impl SplitPlan { + /// Create a plan, validating `test_fraction`. + /// + /// # Errors + /// + /// [`ProtocolError::InvalidTestFraction`] when the fraction is not finite + /// or not strictly inside `(0, 1)`. + pub fn new( + protocol: SplitProtocol, + seed: u64, + test_fraction: f64, + ) -> Result { + if !test_fraction.is_finite() || test_fraction <= 0.0 || test_fraction >= 1.0 { + return Err(ProtocolError::InvalidTestFraction { + value: test_fraction, + }); + } + Ok(SplitPlan { + protocol, + seed, + test_fraction, + }) + } + + /// Assign one sample to a side — pure, deterministic, stateless. + /// + /// The protocol's held-out *unit* (subject, environment, orientation, or + /// individual window) is hashed together with a protocol-specific domain + /// tag and the seed; the unit lands in the test set when its hash falls + /// below `test_fraction` of the hash space. All windows of one unit + /// therefore always land on the same side (except under + /// [`SplitProtocol::RandomBaseline`], which hashes per window — that is + /// its documented leak). + #[must_use] + pub fn assign(&self, meta: &SampleMeta) -> SplitSide { + // Distinct domain tags keep e.g. subject 3 and orientation 3 from + // sharing a hash under the same seed. + const DOMAIN_SUBJECT: u64 = 0x5355424a; // "SUBJ" + const DOMAIN_ENVIRONMENT: u64 = 0x454e5652; // "ENVR" + const DOMAIN_ORIENTATION: u64 = 0x4f524e54; // "ORNT" + const DOMAIN_RANDOM: u64 = 0x524e444d; // "RNDM" + + let unit = match self.protocol { + SplitProtocol::CrossSubject => { + mix2(DOMAIN_SUBJECT, meta.subject_id as u64) + } + SplitProtocol::CrossEnvironment => { + mix2(DOMAIN_ENVIRONMENT, meta.environment_id as u64) + } + SplitProtocol::CrossOrientation => { + mix2(DOMAIN_ORIENTATION, meta.orientation_id as u64) + } + SplitProtocol::RandomBaseline => mix2( + mix2(DOMAIN_RANDOM, meta.recording_id), + meta.window_index, + ), + }; + let h = splitmix64(unit ^ splitmix64(self.seed)); + + // Integer threshold comparison — no float accumulation, identical on + // every platform. + let threshold = (self.test_fraction * (1u128 << 64) as f64) as u128; + if (h as u128) < threshold { + SplitSide::Test + } else { + SplitSide::Train + } + } + + /// Partition metadata into `(train_indices, test_indices)` by + /// [`Self::assign`], preserving input order within each side. + #[must_use] + pub fn partition(&self, metas: &[SampleMeta]) -> (Vec, Vec) { + let mut train = Vec::new(); + let mut test = Vec::new(); + for (i, meta) in metas.iter().enumerate() { + match self.assign(meta) { + SplitSide::Train => train.push(i), + SplitSide::Test => test.push(i), + } + } + (train, test) + } +} + +/// SplitMix64 finalizer — a well-distributed 64-bit mixing function. +fn splitmix64(mut x: u64) -> u64 { + x = x.wrapping_add(0x9E37_79B9_7F4A_7C15); + x = (x ^ (x >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + x = (x ^ (x >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + x ^ (x >> 31) +} + +/// Order-sensitive combination of two words through SplitMix64. +fn mix2(a: u64, b: u64) -> u64 { + splitmix64(splitmix64(a) ^ b.rotate_left(32)) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + /// 4 subjects × 2 environments × 4 orientations, 2 recordings each with + /// 5 windows — a deterministic synthetic corpus. + fn corpus() -> Vec { + let mut metas = Vec::new(); + let mut recording = 0u64; + for subject in 1..=4u32 { + for environment in 1..=2u32 { + for orientation in 1..=4u32 { + for _ in 0..2 { + for window in 0..5u64 { + metas.push(SampleMeta { + subject_id: subject, + environment_id: environment, + orientation_id: orientation, + gesture_id: 1 + (recording % 6) as u32, + recording_id: recording, + window_index: window, + }); + } + recording += 1; + } + } + } + } + metas + } + + #[test] + fn plan_rejects_bad_fractions() { + for bad in [0.0, 1.0, -0.2, 1.7, f64::NAN, f64::INFINITY] { + assert!(matches!( + SplitPlan::new(SplitProtocol::CrossSubject, 1, bad), + Err(ProtocolError::InvalidTestFraction { .. }) + )); + } + assert!(SplitPlan::new(SplitProtocol::CrossSubject, 1, 0.25).is_ok()); + } + + #[test] + fn assignment_is_deterministic_across_calls_and_order() { + let metas = corpus(); + let plan = SplitPlan::new(SplitProtocol::CrossSubject, 42, 0.3).unwrap(); + let (tr1, te1) = plan.partition(&metas); + let (tr2, te2) = plan.partition(&metas); + assert_eq!(tr1, tr2); + assert_eq!(te1, te2); + + // Pure per-sample function: reversing iteration order changes nothing. + let reversed: Vec = metas.iter().rev().copied().collect(); + for (meta, rev) in metas.iter().zip(reversed.iter().rev()) { + assert_eq!(plan.assign(meta), plan.assign(rev)); + } + } + + #[test] + fn different_seeds_change_the_split() { + let metas = corpus(); + let a = SplitPlan::new(SplitProtocol::CrossSubject, 1, 0.5).unwrap(); + let b = SplitPlan::new(SplitProtocol::CrossSubject, 2, 0.5).unwrap(); + // With 4 subjects at 50% some seed pair must differ; these two do — + // and if the hash ever changes this test flags the compat break. + let (_, te_a) = a.partition(&metas); + let (_, te_b) = b.partition(&metas); + assert_ne!(te_a, te_b, "seeds 1 and 2 should hold out different subjects"); + } + + #[test] + fn cross_subject_keeps_subjects_whole() { + let metas = corpus(); + let plan = SplitPlan::new(SplitProtocol::CrossSubject, 7, 0.4).unwrap(); + let mut side_by_subject = std::collections::BTreeMap::new(); + for meta in &metas { + let side = plan.assign(meta); + let prev = side_by_subject.insert(meta.subject_id, side); + if let Some(prev) = prev { + assert_eq!(prev, side, "subject {} split across sides", meta.subject_id); + } + } + } + + #[test] + fn cross_environment_keeps_environments_whole() { + let metas = corpus(); + let plan = SplitPlan::new(SplitProtocol::CrossEnvironment, 11, 0.5).unwrap(); + let mut side_by_env = std::collections::BTreeMap::new(); + for meta in &metas { + let side = plan.assign(meta); + if let Some(prev) = side_by_env.insert(meta.environment_id, side) { + assert_eq!(prev, side); + } + } + } + + #[test] + fn cross_orientation_keeps_orientations_whole() { + let metas = corpus(); + let plan = SplitPlan::new(SplitProtocol::CrossOrientation, 13, 0.5).unwrap(); + let mut side_by_orient = std::collections::BTreeMap::new(); + for meta in &metas { + let side = plan.assign(meta); + if let Some(prev) = side_by_orient.insert(meta.orientation_id, side) { + assert_eq!(prev, side); + } + } + } + + #[test] + fn random_baseline_splits_within_recordings() { + // The leaky baseline must (for some recording) place windows of the + // same recording on both sides — that is the leak it demonstrates. + let metas = corpus(); + let plan = SplitPlan::new(SplitProtocol::RandomBaseline, 3, 0.5).unwrap(); + let mut crossing = false; + let mut side_by_recording = std::collections::BTreeMap::new(); + for meta in &metas { + let side = plan.assign(meta); + if let Some(prev) = side_by_recording.insert(meta.recording_id, side) { + if prev != side { + crossing = true; + } + } + } + assert!(crossing, "window-level split should cross recordings"); + } + + #[test] + fn protocol_claims_match_semantics() { + assert!(SplitProtocol::CrossSubject.claims().subject_disjoint); + assert!(SplitProtocol::CrossEnvironment.claims().environment_disjoint); + assert!(SplitProtocol::CrossOrientation.claims().orientation_disjoint); + let random = SplitProtocol::RandomBaseline.claims(); + assert!(!random.subject_disjoint); + assert!(!random.environment_disjoint); + assert!(!random.orientation_disjoint); + } + + #[test] + fn tags_are_stable() { + assert_eq!(SplitProtocol::CrossSubject.tag(), "cross-subject"); + assert_eq!(SplitProtocol::RandomBaseline.tag(), "random-baseline-leaky"); + } +} diff --git a/v2/crates/wifi-densepose-train/src/protocols/leakage.rs b/v2/crates/wifi-densepose-train/src/protocols/leakage.rs new file mode 100644 index 00000000..24b0ed13 --- /dev/null +++ b/v2/crates/wifi-densepose-train/src/protocols/leakage.rs @@ -0,0 +1,703 @@ +//! Structural leakage guards, mean-pose baseline, and evidence-graded +//! evaluation reports (ADR-288 §3). +//! +//! Three enforcement points, all `Err`-on-failure (never a warning): +//! +//! 1. [`LeakageAudit`] verifies a proposed train/test split structurally: +//! subject-disjointness and environment/orientation-disjointness **where +//! the protocol claims them**, and — unconditionally — that no two windows +//! cut from the same continuous recording straddle the boundary. +//! 2. [`MeanPoseBaseline`] is fitted from the *training* split only; PCK / +//! MPJPE numbers are meaningless without it (CLAUDE.md: pose PCK requires +//! the mean-pose baseline). +//! 3. [`EvaluationReport`] pairs the model metric with the baseline metric +//! and carries an [`EvidenceGrade`]; `MEASURED` cannot be constructed +//! without an embedded reproducer command string. + +use ndarray::Array2; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeSet; + +use crate::error::ProtocolError; +use crate::protocols::{SampleMeta, SplitProtocol}; + +// --------------------------------------------------------------------------- +// LeakageAudit +// --------------------------------------------------------------------------- + +/// Disjointness properties a protocol claims; the audit verifies each claimed +/// one. Obtained from [`SplitProtocol::claims`], or constructed directly for +/// custom protocols. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct LeakageClaims { + /// Train and test must share no subject. + pub subject_disjoint: bool, + /// Train and test must share no environment/room. + pub environment_disjoint: bool, + /// Train and test must share no orientation. + pub orientation_disjoint: bool, +} + +/// Summary returned by a **passing** audit — counts for reporting, no claim +/// stronger than what was structurally checked. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct LeakageAuditPass { + /// Claims that were verified. + pub claims: LeakageClaims, + /// Number of training windows. + pub train_windows: usize, + /// Number of test windows. + pub test_windows: usize, + /// Distinct subjects in train. + pub train_subjects: usize, + /// Distinct subjects in test. + pub test_subjects: usize, + /// Distinct continuous recordings in train. + pub train_recordings: usize, + /// Distinct continuous recordings in test. + pub test_recordings: usize, +} + +/// Structural train/test-split auditor (ADR-288 §3). +/// +/// A failed audit is an [`Err`], not a warning: leaky splits must be unusable +/// for reporting, not merely frowned upon. +#[derive(Debug, Clone, Copy)] +pub struct LeakageAudit { + claims: LeakageClaims, +} + +impl LeakageAudit { + /// Auditor for an explicit set of claims. + #[must_use] + pub fn new(claims: LeakageClaims) -> Self { + LeakageAudit { claims } + } + + /// Auditor for the claims a standard protocol makes. + #[must_use] + pub fn for_protocol(protocol: SplitProtocol) -> Self { + LeakageAudit { + claims: protocol.claims(), + } + } + + /// Verify a proposed split. + /// + /// Checks, in order: + /// 1. both partitions are non-empty; + /// 2. no continuous recording has windows on both sides (unconditional — + /// overlapping windows of one recording are near-duplicates); + /// 3. subject-disjointness, when claimed; + /// 4. environment-disjointness, when claimed; + /// 5. orientation-disjointness, when claimed. + /// + /// # Errors + /// + /// The [`ProtocolError`] variant describing the **first** violation found. + pub fn audit( + &self, + train: &[SampleMeta], + test: &[SampleMeta], + ) -> Result { + if train.is_empty() { + return Err(ProtocolError::EmptyPartition { side: "train" }); + } + if test.is_empty() { + return Err(ProtocolError::EmptyPartition { side: "test" }); + } + + // (2) Recording windows must never cross the boundary. + let train_recordings: BTreeSet = train.iter().map(|m| m.recording_id).collect(); + let test_recordings: BTreeSet = test.iter().map(|m| m.recording_id).collect(); + if let Some(&recording_id) = train_recordings.intersection(&test_recordings).next() { + return Err(ProtocolError::RecordingCrossesSplit { recording_id }); + } + + // (3–5) Claimed disjointness. + let train_subjects: BTreeSet = train.iter().map(|m| m.subject_id).collect(); + let test_subjects: BTreeSet = test.iter().map(|m| m.subject_id).collect(); + if self.claims.subject_disjoint { + if let Some(&subject_id) = train_subjects.intersection(&test_subjects).next() { + return Err(ProtocolError::SubjectOverlap { subject_id }); + } + } + if self.claims.environment_disjoint { + let train_envs: BTreeSet = train.iter().map(|m| m.environment_id).collect(); + let test_envs: BTreeSet = test.iter().map(|m| m.environment_id).collect(); + if let Some(&environment_id) = train_envs.intersection(&test_envs).next() { + return Err(ProtocolError::EnvironmentOverlap { environment_id }); + } + } + if self.claims.orientation_disjoint { + let train_orients: BTreeSet = train.iter().map(|m| m.orientation_id).collect(); + let test_orients: BTreeSet = test.iter().map(|m| m.orientation_id).collect(); + if let Some(&orientation_id) = train_orients.intersection(&test_orients).next() { + return Err(ProtocolError::OrientationOverlap { orientation_id }); + } + } + + Ok(LeakageAuditPass { + claims: self.claims, + train_windows: train.len(), + test_windows: test.len(), + train_subjects: train_subjects.len(), + test_subjects: test_subjects.len(), + train_recordings: train_recordings.len(), + test_recordings: test_recordings.len(), + }) + } +} + +// --------------------------------------------------------------------------- +// MeanPoseBaseline +// --------------------------------------------------------------------------- + +/// The mean-pose baseline: predicts the per-joint mean of the **training** +/// poses for every test sample (CLAUDE.md: pose PCK requires this baseline — +/// a model must beat "always predict the average pose" before any number +/// means anything). +#[derive(Debug, Clone, PartialEq)] +pub struct MeanPoseBaseline { + mean_pose: Array2, + num_train_poses: usize, +} + +impl MeanPoseBaseline { + /// Fit the baseline from training-split poses only. Each pose is + /// `[num_joints, 2]` (normalised x, y); all poses must share one shape. + /// + /// # Errors + /// + /// - [`ProtocolError::EmptyTrainingPoses`] when `train_poses` is empty. + /// - [`ProtocolError::PoseShapeMismatch`] when poses disagree in shape. + pub fn fit(train_poses: &[Array2]) -> Result { + let first = train_poses.first().ok_or(ProtocolError::EmptyTrainingPoses)?; + let shape = first.dim(); + + let mut mean_pose = Array2::::zeros(shape); + for pose in train_poses { + if pose.dim() != shape { + return Err(ProtocolError::PoseShapeMismatch { + expected: vec![shape.0, shape.1], + actual: pose.shape().to_vec(), + }); + } + mean_pose += pose; + } + mean_pose /= train_poses.len() as f32; + + Ok(MeanPoseBaseline { + mean_pose, + num_train_poses: train_poses.len(), + }) + } + + /// The fitted mean pose, `[num_joints, 2]`. + #[must_use] + pub fn mean_pose(&self) -> &Array2 { + &self.mean_pose + } + + /// Number of training poses the baseline was fitted on. + #[must_use] + pub fn num_train_poses(&self) -> usize { + self.num_train_poses + } + + /// Mean per-joint position error (MPJPE) of the baseline over test-split + /// poses: the mean Euclidean distance between each test joint and the + /// corresponding mean-pose joint. + /// + /// # Errors + /// + /// - [`ProtocolError::EmptyTrainingPoses`] when `test_poses` is empty + /// (nothing to evaluate). + /// - [`ProtocolError::PoseShapeMismatch`] when a test pose does not match + /// the fitted shape. + pub fn mpjpe(&self, test_poses: &[Array2]) -> Result { + if test_poses.is_empty() { + return Err(ProtocolError::EmptyTrainingPoses); + } + let shape = self.mean_pose.dim(); + let mut total = 0.0f64; + let mut joints = 0usize; + for pose in test_poses { + if pose.dim() != shape { + return Err(ProtocolError::PoseShapeMismatch { + expected: vec![shape.0, shape.1], + actual: pose.shape().to_vec(), + }); + } + for j in 0..shape.0 { + let dx = (pose[[j, 0]] - self.mean_pose[[j, 0]]) as f64; + let dy = (pose[[j, 1]] - self.mean_pose[[j, 1]]) as f64; + total += (dx * dx + dy * dy).sqrt(); + joints += 1; + } + } + Ok(total / joints as f64) + } + + /// Baseline PCK@`threshold` over test poses: fraction of joints whose + /// distance to the mean-pose joint is `< threshold` (same units as the + /// pose coordinates). + /// + /// # Errors + /// + /// Same conditions as [`Self::mpjpe`]. + pub fn pck_at( + &self, + test_poses: &[Array2], + threshold: f32, + ) -> Result { + if test_poses.is_empty() { + return Err(ProtocolError::EmptyTrainingPoses); + } + let shape = self.mean_pose.dim(); + let mut correct = 0usize; + let mut joints = 0usize; + for pose in test_poses { + if pose.dim() != shape { + return Err(ProtocolError::PoseShapeMismatch { + expected: vec![shape.0, shape.1], + actual: pose.shape().to_vec(), + }); + } + for j in 0..shape.0 { + let dx = pose[[j, 0]] - self.mean_pose[[j, 0]]; + let dy = pose[[j, 1]] - self.mean_pose[[j, 1]]; + if (dx * dx + dy * dy).sqrt() < threshold { + correct += 1; + } + joints += 1; + } + } + Ok(correct as f64 / joints as f64) + } +} + +// --------------------------------------------------------------------------- +// EvaluationReport +// --------------------------------------------------------------------------- + +/// Evidence grade of a reported number (CLAUDE.md tagging rules). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum EvidenceGrade { + /// Measured on real data with a leak-free split; carries the exact + /// command that reproduces the number. Constructible only through + /// [`EvaluationReport::measured`], which rejects an empty reproducer. + Measured { + /// Command line that reproduces this result. + reproducer: String, + }, + /// Computed on synthetic/generated data. + Synthetic, + /// Quoted from elsewhere; not reproduced in this repository. + Claimed, +} + +impl EvidenceGrade { + /// Stable uppercase tag (`MEASURED` / `SYNTHETIC` / `CLAIMED`). + #[must_use] + pub fn tag(&self) -> &'static str { + match self { + EvidenceGrade::Measured { .. } => "MEASURED", + EvidenceGrade::Synthetic => "SYNTHETIC", + EvidenceGrade::Claimed => "CLAIMED", + } + } +} + +/// An evaluation result that structurally pairs the model metric with the +/// mean-pose (or other) baseline metric and an [`EvidenceGrade`] — a model +/// number can never be reported without its baseline (ADR-288 §3). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct EvaluationReport { + /// Protocol the split followed. + pub protocol: SplitProtocol, + /// Metric name, e.g. `"pck@0.2"` or `"mpjpe"`. + pub metric_name: String, + /// The model's metric on the audited test split. + pub model_metric: f64, + /// The baseline's metric on the same split (e.g. + /// [`MeanPoseBaseline::mpjpe`]). + pub baseline_metric: f64, + /// Evidence grade; `MEASURED` embeds its reproducer. + pub evidence: EvidenceGrade, +} + +impl EvaluationReport { + /// Build a `MEASURED` report. The reproducer command is mandatory and + /// must be non-blank — a measured number without a reproducer is not + /// measured (CLAUDE.md). + /// + /// # Errors + /// + /// - [`ProtocolError::MissingReproducer`] when `reproducer` is blank. + /// - [`ProtocolError::NonFiniteMetric`] when either metric is NaN/±inf. + pub fn measured( + protocol: SplitProtocol, + metric_name: impl Into, + model_metric: f64, + baseline_metric: f64, + reproducer: impl Into, + ) -> Result { + let reproducer = reproducer.into(); + if reproducer.trim().is_empty() { + return Err(ProtocolError::MissingReproducer); + } + Self::build( + protocol, + metric_name.into(), + model_metric, + baseline_metric, + EvidenceGrade::Measured { reproducer }, + ) + } + + /// Build a `SYNTHETIC` report (synthetic/generated data). + /// + /// # Errors + /// + /// [`ProtocolError::NonFiniteMetric`] when either metric is NaN/±inf. + pub fn synthetic( + protocol: SplitProtocol, + metric_name: impl Into, + model_metric: f64, + baseline_metric: f64, + ) -> Result { + Self::build( + protocol, + metric_name.into(), + model_metric, + baseline_metric, + EvidenceGrade::Synthetic, + ) + } + + /// Build a `CLAIMED` report (quoted, not reproduced here). + /// + /// # Errors + /// + /// [`ProtocolError::NonFiniteMetric`] when either metric is NaN/±inf. + pub fn claimed( + protocol: SplitProtocol, + metric_name: impl Into, + model_metric: f64, + baseline_metric: f64, + ) -> Result { + Self::build( + protocol, + metric_name.into(), + model_metric, + baseline_metric, + EvidenceGrade::Claimed, + ) + } + + fn build( + protocol: SplitProtocol, + metric_name: String, + model_metric: f64, + baseline_metric: f64, + evidence: EvidenceGrade, + ) -> Result { + if !model_metric.is_finite() { + return Err(ProtocolError::NonFiniteMetric { + name: format!("{metric_name} (model)"), + value: model_metric, + }); + } + if !baseline_metric.is_finite() { + return Err(ProtocolError::NonFiniteMetric { + name: format!("{metric_name} (baseline)"), + value: baseline_metric, + }); + } + Ok(EvaluationReport { + protocol, + metric_name, + model_metric, + baseline_metric, + evidence, + }) + } + + /// `model − baseline` (positive is better for higher-is-better metrics + /// such as PCK; interpret per metric). + #[must_use] + pub fn margin_over_baseline(&self) -> f64 { + self.model_metric - self.baseline_metric + } + + /// One-line evidence-tagged summary, e.g. + /// `"[MEASURED] cross-subject pck@0.2: model 0.6100 vs mean-pose baseline 0.4100"`. + #[must_use] + pub fn summary(&self) -> String { + format!( + "[{}] {} {}: model {:.4} vs mean-pose baseline {:.4}", + self.evidence.tag(), + self.protocol.tag(), + self.metric_name, + self.model_metric, + self.baseline_metric + ) + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use approx::assert_abs_diff_eq; + use ndarray::array; + + fn meta( + subject: u32, + environment: u32, + orientation: u32, + recording: u64, + window: u64, + ) -> SampleMeta { + SampleMeta { + subject_id: subject, + environment_id: environment, + orientation_id: orientation, + gesture_id: 1, + recording_id: recording, + window_index: window, + } + } + + // ----- LeakageAudit ----------------------------------------------------- + + #[test] + fn audit_passes_clean_cross_subject_split() { + let train = vec![meta(1, 1, 1, 0, 0), meta(1, 1, 1, 0, 1), meta(2, 1, 2, 1, 0)]; + let test = vec![meta(3, 1, 1, 2, 0), meta(3, 1, 1, 2, 1)]; + let pass = LeakageAudit::for_protocol(SplitProtocol::CrossSubject) + .audit(&train, &test) + .expect("clean split must pass"); + assert_eq!(pass.train_windows, 3); + assert_eq!(pass.test_windows, 2); + assert_eq!(pass.train_subjects, 2); + assert_eq!(pass.test_subjects, 1); + assert_eq!(pass.train_recordings, 2); + assert_eq!(pass.test_recordings, 1); + } + + #[test] + fn audit_rejects_subject_overlap() { + let train = vec![meta(1, 1, 1, 0, 0), meta(2, 1, 1, 1, 0)]; + let test = vec![meta(2, 2, 2, 2, 0)]; // subject 2 on both sides + let err = LeakageAudit::for_protocol(SplitProtocol::CrossSubject) + .audit(&train, &test) + .unwrap_err(); + assert!(matches!(err, ProtocolError::SubjectOverlap { subject_id: 2 })); + } + + #[test] + fn audit_rejects_environment_overlap_when_claimed() { + let train = vec![meta(1, 1, 1, 0, 0)]; + let test = vec![meta(2, 1, 2, 1, 0)]; // environment 1 on both sides + let err = LeakageAudit::for_protocol(SplitProtocol::CrossEnvironment) + .audit(&train, &test) + .unwrap_err(); + assert!(matches!( + err, + ProtocolError::EnvironmentOverlap { environment_id: 1 } + )); + // The same split passes a protocol that does not claim env-disjointness. + assert!(LeakageAudit::for_protocol(SplitProtocol::CrossSubject) + .audit(&train, &test) + .is_ok()); + } + + #[test] + fn audit_rejects_orientation_overlap_when_claimed() { + let train = vec![meta(1, 1, 3, 0, 0)]; + let test = vec![meta(2, 2, 3, 1, 0)]; + let err = LeakageAudit::for_protocol(SplitProtocol::CrossOrientation) + .audit(&train, &test) + .unwrap_err(); + assert!(matches!( + err, + ProtocolError::OrientationOverlap { orientation_id: 3 } + )); + } + + #[test] + fn audit_always_rejects_recording_crossing() { + // Even a protocol claiming nothing (RandomBaseline) must fail when a + // continuous recording straddles the boundary. + let train = vec![meta(1, 1, 1, 5, 0)]; + let test = vec![meta(2, 2, 2, 5, 1)]; // same recording 5 + let err = LeakageAudit::for_protocol(SplitProtocol::RandomBaseline) + .audit(&train, &test) + .unwrap_err(); + assert!(matches!( + err, + ProtocolError::RecordingCrossesSplit { recording_id: 5 } + )); + } + + #[test] + fn audit_rejects_empty_partitions() { + let some = vec![meta(1, 1, 1, 0, 0)]; + let audit = LeakageAudit::for_protocol(SplitProtocol::CrossSubject); + assert!(matches!( + audit.audit(&[], &some), + Err(ProtocolError::EmptyPartition { side: "train" }) + )); + assert!(matches!( + audit.audit(&some, &[]), + Err(ProtocolError::EmptyPartition { side: "test" }) + )); + } + + #[test] + fn random_baseline_partition_fails_audit_end_to_end() { + // Wire a real RandomBaseline SplitPlan into the audit: the leaky + // window-level split must be rejected, which is exactly its purpose. + use crate::protocols::SplitPlan; + let mut metas = Vec::new(); + for recording in 0..8u64 { + for window in 0..6u64 { + metas.push(meta(1 + (recording % 3) as u32, 1, 1, recording, window)); + } + } + let plan = SplitPlan::new(SplitProtocol::RandomBaseline, 9, 0.5).unwrap(); + let (train_idx, test_idx) = plan.partition(&metas); + let train: Vec = train_idx.iter().map(|&i| metas[i]).collect(); + let test: Vec = test_idx.iter().map(|&i| metas[i]).collect(); + assert!(matches!( + LeakageAudit::for_protocol(SplitProtocol::RandomBaseline).audit(&train, &test), + Err(ProtocolError::RecordingCrossesSplit { .. }) + )); + } + + // ----- MeanPoseBaseline ------------------------------------------------- + + #[test] + fn mean_pose_is_elementwise_mean_of_training_poses() { + let train = vec![ + array![[0.0f32, 0.0], [1.0, 1.0]], + array![[0.2f32, 0.4], [0.6, 0.0]], + ]; + let baseline = MeanPoseBaseline::fit(&train).unwrap(); + assert_eq!(baseline.num_train_poses(), 2); + let mean = baseline.mean_pose(); + assert_abs_diff_eq!(mean[[0, 0]], 0.1, epsilon = 1e-6); + assert_abs_diff_eq!(mean[[0, 1]], 0.2, epsilon = 1e-6); + assert_abs_diff_eq!(mean[[1, 0]], 0.8, epsilon = 1e-6); + assert_abs_diff_eq!(mean[[1, 1]], 0.5, epsilon = 1e-6); + } + + #[test] + fn mean_pose_mpjpe_math() { + // Baseline fitted on a single pose ⇒ mean equals it exactly. + let train = vec![array![[0.0f32, 0.0], [1.0, 0.0]]]; + let baseline = MeanPoseBaseline::fit(&train).unwrap(); + + // Test pose offset by (0.3, 0.4) on both joints ⇒ distance 0.5 each. + let test = vec![array![[0.3f32, 0.4], [1.3, 0.4]]]; + let mpjpe = baseline.mpjpe(&test).unwrap(); + assert_abs_diff_eq!(mpjpe, 0.5, epsilon = 1e-6); + + // Distances are ~0.5 up to f32 rounding, so probe strictly either + // side: PCK@0.6 counts both joints, PCK@0.49 counts neither. + assert_abs_diff_eq!(baseline.pck_at(&test, 0.6).unwrap(), 1.0, epsilon = 1e-9); + assert_abs_diff_eq!(baseline.pck_at(&test, 0.49).unwrap(), 0.0, epsilon = 1e-9); + } + + #[test] + fn mean_pose_rejects_empty_and_mismatched() { + assert!(matches!( + MeanPoseBaseline::fit(&[]), + Err(ProtocolError::EmptyTrainingPoses) + )); + let train = vec![ + array![[0.0f32, 0.0], [1.0, 1.0]], + array![[0.0f32, 0.0]], // 1 joint vs 2 + ]; + assert!(matches!( + MeanPoseBaseline::fit(&train), + Err(ProtocolError::PoseShapeMismatch { .. }) + )); + + let baseline = MeanPoseBaseline::fit(&[array![[0.0f32, 0.0]]]).unwrap(); + assert!(baseline.mpjpe(&[]).is_err()); + assert!(baseline + .mpjpe(&[array![[0.0f32, 0.0], [1.0, 1.0]]]) + .is_err()); + } + + // ----- EvaluationReport ------------------------------------------------- + + #[test] + fn measured_requires_reproducer() { + let err = EvaluationReport::measured( + SplitProtocol::CrossSubject, + "pck@0.2", + 0.61, + 0.41, + " ", + ) + .unwrap_err(); + assert!(matches!(err, ProtocolError::MissingReproducer)); + + let report = EvaluationReport::measured( + SplitProtocol::CrossSubject, + "pck@0.2", + 0.61, + 0.41, + "cargo run -p wifi-densepose-train --bin train -- eval --protocol cross-subject --seed 42", + ) + .unwrap(); + assert_eq!(report.evidence.tag(), "MEASURED"); + assert_abs_diff_eq!(report.margin_over_baseline(), 0.2, epsilon = 1e-9); + assert!(report.summary().starts_with("[MEASURED] cross-subject pck@0.2")); + } + + #[test] + fn synthetic_and_claimed_tags() { + let s = + EvaluationReport::synthetic(SplitProtocol::CrossOrientation, "mpjpe", 0.1, 0.3) + .unwrap(); + assert_eq!(s.evidence.tag(), "SYNTHETIC"); + let c = EvaluationReport::claimed(SplitProtocol::CrossSubject, "pck@0.5", 0.9, 0.5) + .unwrap(); + assert_eq!(c.evidence.tag(), "CLAIMED"); + } + + #[test] + fn report_rejects_non_finite_metrics() { + assert!(matches!( + EvaluationReport::synthetic(SplitProtocol::CrossSubject, "pck", f64::NAN, 0.5), + Err(ProtocolError::NonFiniteMetric { .. }) + )); + assert!(matches!( + EvaluationReport::synthetic(SplitProtocol::CrossSubject, "pck", 0.5, f64::INFINITY), + Err(ProtocolError::NonFiniteMetric { .. }) + )); + } + + #[test] + fn report_serializes_roundtrip() { + let report = EvaluationReport::measured( + SplitProtocol::CrossEnvironment, + "mpjpe", + 0.07, + 0.19, + "cargo test -p wifi-densepose-train", + ) + .unwrap(); + let json = serde_json::to_string(&report).unwrap(); + let back: EvaluationReport = serde_json::from_str(&json).unwrap(); + assert_eq!(back, report); + } +} diff --git a/v2/crates/wifi-densepose-vitals/Cargo.toml b/v2/crates/wifi-densepose-vitals/Cargo.toml index b235ea37..d6ea9900 100644 --- a/v2/crates/wifi-densepose-vitals/Cargo.toml +++ b/v2/crates/wifi-densepose-vitals/Cargo.toml @@ -23,6 +23,10 @@ criterion = { version = "0.5", features = ["html_reports"] } name = "vitals_bench" harness = false +[[bench]] +name = "groundtruth_bench" +harness = false + [features] default = ["serde"] serde = ["dep:serde"] diff --git a/v2/crates/wifi-densepose-vitals/benches/groundtruth_bench.rs b/v2/crates/wifi-densepose-vitals/benches/groundtruth_bench.rs new file mode 100644 index 00000000..1d901af3 --- /dev/null +++ b/v2/crates/wifi-densepose-vitals/benches/groundtruth_bench.rs @@ -0,0 +1,136 @@ +//! Benchmark for ground-truth time alignment (ADR-290). +//! +//! Aligns an hour-scale synthetic session (3600 s) against a reference +//! series with a known 12 s clock offset, over the default ±30 s lag +//! window. Variants: 1 Hz estimate vs 1 Hz reference (same-rate), 0.5 Hz +//! estimate vs 1 Hz reference (rate-mismatched, the realistic CSI case), +//! and same-rate with the windowed drift fit enabled. The lag search is +//! O(lags × grid points) with no per-lag allocation; this bench tracks that +//! cost at realistic session length. All input is generated in code and +//! fully deterministic; measurement time is kept short deliberately. +//! +//! Reproduce: +//! cargo bench -p wifi-densepose-vitals --bench groundtruth_bench +//! Compile-only: +//! cargo bench -p wifi-densepose-vitals --bench groundtruth_bench --no-run + +use criterion::{black_box, criterion_group, criterion_main, Criterion}; +use std::time::Duration; +use wifi_densepose_vitals::groundtruth::{ + align, AlignmentConfig, EstimateSeries, Measurand, MeasurementPrinciple, ReferenceDevice, + ReferenceSample, ReferenceSeries, +}; + +/// Session length in seconds (one hour). +const SESSION_SECS: usize = 3600; + +/// Known clock offset injected into the estimate series, milliseconds. +const OFFSET_MS: i64 = 12_000; + +/// Deterministic aperiodic heart-rate-like signal (incommensurate periods). +fn synth(t_secs: f64) -> f64 { + 70.0 + 5.0 * (2.0 * std::f64::consts::PI * t_secs / 47.0).sin() + + 3.0 * (2.0 * std::f64::consts::PI * t_secs / 113.0).sin() +} + +/// Reference series: `SESSION_SECS` samples at 1 Hz on the reference clock. +fn reference_1hz() -> ReferenceSeries { + ReferenceSeries::new( + Measurand::HeartRateBpm, + ReferenceDevice { + make: "Synthetic".to_string(), + model: "bench".to_string(), + principle: MeasurementPrinciple::Other, + }, + (0..SESSION_SECS) + .map(|i| ReferenceSample { + timestamp_ms: (i as i64) * 1000, + value: synth(i as f64), + }) + .collect(), + ) + .expect("valid reference") +} + +/// Estimate series at `period_ms` sampling, shifted `OFFSET_MS` earlier. +fn estimate(period_ms: i64) -> EstimateSeries { + let n = (SESSION_SECS as i64 * 1000) / period_ms; + EstimateSeries::new( + Measurand::HeartRateBpm, + (0..n) + .map(|i| { + let t_ms = i * period_ms; + ReferenceSample { + timestamp_ms: t_ms - OFFSET_MS, + value: synth(t_ms as f64 / 1000.0), + } + }) + .collect(), + ) + .expect("valid estimate") +} + +fn bench_align_hour_session(c: &mut Criterion) { + let reference = reference_1hz(); + let est_1hz = estimate(1000); + let est_half_hz = estimate(2000); + let cfg = AlignmentConfig::default(); + + // 1 Hz estimate vs 1 Hz reference: exact offset recovery expected. + c.bench_function("groundtruth_align_1h_est1hz_ref1hz_pm30s", |b| { + b.iter(|| { + let result = align(black_box(&est_1hz), black_box(&reference), black_box(&cfg)) + .expect("alignment succeeds"); + assert_eq!(result.offset_ms, OFFSET_MS); + black_box(result); + }); + }); + + // 0.5 Hz estimate vs 1 Hz reference: the realistic CSI-pipeline case. + // Nearest-sample resampling quantizes, so allow one grid step of slack. + c.bench_function("groundtruth_align_1h_est0p5hz_ref1hz_pm30s", |b| { + b.iter(|| { + let result = align( + black_box(&est_half_hz), + black_box(&reference), + black_box(&cfg), + ) + .expect("alignment succeeds"); + assert!((result.offset_ms - OFFSET_MS).abs() <= cfg.grid_step_ms); + black_box(result); + }); + }); + + // Same-rate alignment with the windowed linear drift fit enabled. + let cfg_drift = AlignmentConfig { + fit_drift: true, + ..AlignmentConfig::default() + }; + c.bench_function("groundtruth_align_1h_est1hz_ref1hz_pm30s_drift", |b| { + b.iter(|| { + let result = align( + black_box(&est_1hz), + black_box(&reference), + black_box(&cfg_drift), + ) + .expect("alignment succeeds"); + black_box(result); + }); + }); +} + +/// Short measurement window: each iteration is an hour-scale alignment, so +/// default criterion settings would make the suite needlessly slow. +fn short_config() -> Criterion { + Criterion::default() + .warm_up_time(Duration::from_millis(500)) + .measurement_time(Duration::from_secs(3)) + .sample_size(10) +} + +criterion_group! { + name = benches; + config = short_config(); + targets = bench_align_hour_session +} +criterion_main!(benches); diff --git a/v2/crates/wifi-densepose-vitals/src/groundtruth.rs b/v2/crates/wifi-densepose-vitals/src/groundtruth.rs new file mode 100644 index 00000000..83605821 --- /dev/null +++ b/v2/crates/wifi-densepose-vitals/src/groundtruth.rs @@ -0,0 +1,2001 @@ +//! Ground-truth reference ingest, time alignment, and agreement metrics +//! (ADR-290). +//! +//! Every credible vitals result ships with reference-sensor ground truth +//! (chest strap, pulse oximeter, ECG). This module makes a `MEASURED` vitals +//! claim reachable for RuView by providing: +//! +//! 1. **Reference ingest** ([`ReferenceSeries`]): timestamped reference +//! samples for one measurand, parsed from an untrusted +//! `timestamp_ms,value` CSV export with row-numbered structured errors. +//! 2. **Time alignment** ([`align`]): constant-offset estimation by +//! maximizing normalized cross-correlation over a bounded lag window on a +//! common nearest-sample grid, plus an optional linear clock-drift fit. +//! Alignment parameters are returned in [`AlignmentResult`], never +//! silently applied. +//! 3. **Agreement metrics** ([`AgreementReport`]): paired-sample count, +//! coverage, MAE, RMSE, bias, Bland-Altman 95% limits of agreement, and +//! percent-within-tolerance. A mandatory [`SessionScope`] states subject +//! count, motion, propagation, and distance band — a report without scope +//! cannot exist. +//! 4. **Evidence tagging** ([`GradedAgreementReport`]): +//! [`EvidenceGrade::Measured`] is constructible only through +//! [`GradedAgreementReport::measured`], which requires non-zero paired +//! samples, minimum coverage, and a non-blank reproducer command — +//! enforcement lives in the constructor, not in documentation. +//! +//! Agreement against consumer reference devices is engineering evidence, +//! not medical validation, and never a camera-grade or clinical claim. + +use crate::store::VitalSignStore; +use crate::types::{VitalReading, VitalStatus}; +use std::fmt; + +#[cfg(feature = "serde")] +use serde::{Deserialize, Serialize}; + +// --------------------------------------------------------------------------- +// Bounds for untrusted input +// --------------------------------------------------------------------------- + +/// Maximum number of data rows accepted from a reference CSV. +pub const MAX_CSV_ROWS: usize = 1_000_000; + +/// Maximum absolute timestamp in milliseconds (`2^52` ms, far beyond any +/// realistic unix-millis session). Keeps all i64 offset/span arithmetic in +/// this module overflow-free and every timestamp exactly representable as +/// `f64`. +pub const MAX_TIMESTAMP_ABS_MS: i64 = 1 << 52; + +/// Maximum plausible physiological value in BPM/BrPM accepted at the input +/// boundary. +pub const MAX_VALUE_BPM: f64 = 300.0; + +/// Maximum number of resampled grid points for alignment or agreement. +pub const MAX_GRID_POINTS: usize = 10_000_000; + +/// Minimum coverage fraction required to grade a report `MEASURED`. +pub const MIN_MEASURED_COVERAGE: f64 = 0.5; + +/// Expected CSV header line. +const CSV_HEADER: &str = "timestamp_ms,value"; + +/// Maximum length of untrusted text echoed back inside an error. +const MAX_ERROR_ECHO: usize = 64; + +// --------------------------------------------------------------------------- +// Errors +// --------------------------------------------------------------------------- + +/// Structured error for ground-truth ingest, alignment, and agreement. +/// +/// For CSV input, `row` is the 1-based line number in the file (the header +/// is line 1). For in-memory constructors ([`ReferenceSeries::new`], +/// [`EstimateSeries::new`], [`EstimateSeries::from_readings`]), `row` is the +/// zero-based index of the offending sample/reading. +#[derive(Debug, Clone, PartialEq)] +pub enum GroundTruthError { + /// Input contained no header line. + MissingHeader, + /// Header line did not match `timestamp_ms,value`. Carries a bounded + /// echo of what was found. + BadHeader { + /// The (truncated) header text encountered. + found: String, + }, + /// A data row did not have exactly two comma-separated fields. + WrongFieldCount { + /// Offending row. + row: usize, + /// Number of fields found. + found: usize, + }, + /// A timestamp field failed to parse as an integer. + BadTimestamp { + /// Offending row. + row: usize, + }, + /// A timestamp is outside `±`[`MAX_TIMESTAMP_ABS_MS`]. + TimestampOutOfRange { + /// Offending row. + row: usize, + }, + /// A value field failed to parse as a finite number. + BadValue { + /// Offending row. + row: usize, + }, + /// A value is outside `[0, `[`MAX_VALUE_BPM`]`]`. + ValueOutOfRange { + /// Offending row. + row: usize, + /// The out-of-range value. + value: f64, + }, + /// Timestamps must be strictly increasing; sorting is never applied + /// silently. + NonMonotonicTimestamp { + /// Offending row. + row: usize, + }, + /// No usable samples were present. + NoSamples, + /// More data rows than [`MAX_CSV_ROWS`] (bounded allocation). + TooManyRows { + /// Row limit that was exceeded. + max: usize, + }, + /// Estimate and reference series measure different quantities. + MeasurandMismatch { + /// Measurand of the estimate series. + estimate: Measurand, + /// Measurand of the reference series. + reference: Measurand, + }, + /// An alignment/agreement configuration parameter is invalid. + InvalidConfig(&'static str), + /// The resampled grid would exceed [`MAX_GRID_POINTS`]. + GridTooLarge { + /// Grid points that would be required. + points: u64, + }, + /// Not enough overlapping valid samples for a statistic. + InsufficientOverlap { + /// Minimum overlapping pairs required. + required: usize, + /// Best overlap actually found. + found: usize, + }, + /// Overlapping samples exist but at least one side has zero variance, + /// so normalized cross-correlation is undefined. + ConstantSignal, + /// A report failed the `MEASURED` evidence gate; the message states the + /// failed requirement. + NotMeasured(&'static str), +} + +impl fmt::Display for GroundTruthError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::MissingHeader => write!(f, "missing CSV header line '{CSV_HEADER}'"), + Self::BadHeader { found } => { + write!(f, "bad CSV header: expected '{CSV_HEADER}', found '{found}'") + } + Self::WrongFieldCount { row, found } => { + write!(f, "row {row}: expected 2 comma-separated fields, found {found}") + } + Self::BadTimestamp { row } => { + write!(f, "row {row}: timestamp is not a valid integer") + } + Self::TimestampOutOfRange { row } => { + write!(f, "row {row}: timestamp outside ±{MAX_TIMESTAMP_ABS_MS} ms") + } + Self::BadValue { row } => write!(f, "row {row}: value is not a finite number"), + Self::ValueOutOfRange { row, value } => { + write!(f, "row {row}: value {value} outside [0, {MAX_VALUE_BPM}]") + } + Self::NonMonotonicTimestamp { row } => { + write!(f, "row {row}: timestamps must be strictly increasing") + } + Self::NoSamples => write!(f, "no usable samples"), + Self::TooManyRows { max } => write!(f, "more than {max} data rows"), + Self::MeasurandMismatch { estimate, reference } => write!( + f, + "measurand mismatch: estimate is {estimate:?}, reference is {reference:?}" + ), + Self::InvalidConfig(msg) => write!(f, "invalid configuration: {msg}"), + Self::GridTooLarge { points } => { + write!(f, "resampled grid of {points} points exceeds {MAX_GRID_POINTS}") + } + Self::InsufficientOverlap { required, found } => write!( + f, + "insufficient overlap: required {required} paired samples, found {found}" + ), + Self::ConstantSignal => { + write!(f, "constant signal: normalized cross-correlation undefined") + } + Self::NotMeasured(msg) => write!(f, "MEASURED evidence gate failed: {msg}"), + } + } +} + +impl std::error::Error for GroundTruthError {} + +// --------------------------------------------------------------------------- +// Reference series +// --------------------------------------------------------------------------- + +/// Quantity a reference or estimate series measures. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub enum Measurand { + /// Heart rate, beats per minute. + HeartRateBpm, + /// Breathing (respiratory) rate, breaths per minute. + BreathingRateBrpm, +} + +impl Measurand { + /// Default agreement tolerance for this measurand (ADR-290: ±2 bpm for + /// heart rate, ±1 brpm for breathing). + #[must_use] + pub fn default_tolerance_bpm(self) -> f64 { + match self { + Self::HeartRateBpm => 2.0, + Self::BreathingRateBrpm => 1.0, + } + } +} + +/// Measurement principle of a reference device. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub enum MeasurementPrinciple { + /// Electrocardiography (e.g. chest strap ECG). + Ecg, + /// Photoplethysmography (e.g. pulse oximeter, optical wrist sensor). + Ppg, + /// Respiratory effort band / chest expansion. + RespiratoryBand, + /// Capnography. + Capnography, + /// Manually counted. + Manual, + /// Anything else; state it in the device model string. + Other, +} + +/// Metadata identifying the reference device a series came from. +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub struct ReferenceDevice { + /// Device make, e.g. `"Polar"`. + pub make: String, + /// Device model, e.g. `"H10"`. + pub model: String, + /// Measurement principle. + pub principle: MeasurementPrinciple, +} + +/// One timestamped sample. +#[derive(Debug, Clone, Copy, PartialEq)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub struct ReferenceSample { + /// Unix timestamp in milliseconds. + pub timestamp_ms: i64, + /// Value in BPM (heart rate) or BrPM (breathing rate). + pub value: f64, +} + +/// Validate one sample at index/row `row` against the previous timestamp. +fn validate_sample( + row: usize, + timestamp_ms: i64, + value: f64, + prev_ts: Option, +) -> Result<(), GroundTruthError> { + if timestamp_ms.abs() > MAX_TIMESTAMP_ABS_MS { + return Err(GroundTruthError::TimestampOutOfRange { row }); + } + if !value.is_finite() { + return Err(GroundTruthError::BadValue { row }); + } + if !(0.0..=MAX_VALUE_BPM).contains(&value) { + return Err(GroundTruthError::ValueOutOfRange { row, value }); + } + if let Some(prev) = prev_ts { + if timestamp_ms <= prev { + return Err(GroundTruthError::NonMonotonicTimestamp { row }); + } + } + Ok(()) +} + +/// Validate an in-memory sample slice (row = zero-based index). +fn validate_samples(samples: &[ReferenceSample]) -> Result<(), GroundTruthError> { + if samples.is_empty() { + return Err(GroundTruthError::NoSamples); + } + let mut prev: Option = None; + for (i, s) in samples.iter().enumerate() { + validate_sample(i, s.timestamp_ms, s.value, prev)?; + prev = Some(s.timestamp_ms); + } + Ok(()) +} + +/// A reference-device time series for one measurand. +/// +/// Samples are guaranteed non-empty, finite, in-range, and strictly +/// increasing in time — the invariant is enforced by every constructor, so +/// downstream alignment/agreement code never re-checks it. +#[derive(Debug, Clone, PartialEq)] +#[cfg_attr(feature = "serde", derive(Serialize))] +pub struct ReferenceSeries { + measurand: Measurand, + device: ReferenceDevice, + samples: Vec, +} + +impl ReferenceSeries { + /// Build a series from in-memory samples, validating the invariant. + /// + /// Errors use the zero-based sample index as `row`. + pub fn new( + measurand: Measurand, + device: ReferenceDevice, + samples: Vec, + ) -> Result { + validate_samples(&samples)?; + Ok(Self { + measurand, + device, + samples, + }) + } + + /// Parse an untrusted `timestamp_ms,value` CSV export. + /// + /// The first non-blank line must be the header `timestamp_ms,value` + /// (a UTF-8 BOM is tolerated). Blank lines are skipped; every other + /// line must be `,`. Malformed rows are + /// rejected with 1-based line numbers; non-monotonic timestamps are an + /// error, never silently sorted. At most [`MAX_CSV_ROWS`] data rows are + /// accepted. + pub fn parse_csv( + measurand: Measurand, + device: ReferenceDevice, + text: &str, + ) -> Result { + Self::parse_csv_bounded(measurand, device, text, MAX_CSV_ROWS) + } + + /// [`Self::parse_csv`] with an explicit row limit (tested directly). + fn parse_csv_bounded( + measurand: Measurand, + device: ReferenceDevice, + text: &str, + max_rows: usize, + ) -> Result { + let mut saw_header = false; + let mut samples: Vec = Vec::new(); + let mut prev_ts: Option = None; + + for (idx, raw) in text.lines().enumerate() { + let row = idx + 1; + let line = raw.trim_start_matches('\u{feff}').trim(); + if line.is_empty() { + continue; + } + if !saw_header { + if line != CSV_HEADER { + let mut found: String = line.chars().take(MAX_ERROR_ECHO).collect(); + if found.len() < line.len() { + found.push('…'); + } + return Err(GroundTruthError::BadHeader { found }); + } + saw_header = true; + continue; + } + if samples.len() >= max_rows { + return Err(GroundTruthError::TooManyRows { max: max_rows }); + } + let fields: Vec<&str> = line.split(',').collect(); + if fields.len() != 2 { + return Err(GroundTruthError::WrongFieldCount { + row, + found: fields.len(), + }); + } + let timestamp_ms: i64 = fields[0] + .trim() + .parse() + .map_err(|_| GroundTruthError::BadTimestamp { row })?; + let value: f64 = fields[1] + .trim() + .parse() + .map_err(|_| GroundTruthError::BadValue { row })?; + validate_sample(row, timestamp_ms, value, prev_ts)?; + prev_ts = Some(timestamp_ms); + samples.push(ReferenceSample { + timestamp_ms, + value, + }); + } + + if !saw_header { + return Err(GroundTruthError::MissingHeader); + } + if samples.is_empty() { + return Err(GroundTruthError::NoSamples); + } + Ok(Self { + measurand, + device, + samples, + }) + } + + /// The measurand this series records. + #[must_use] + pub fn measurand(&self) -> Measurand { + self.measurand + } + + /// The reference device metadata. + #[must_use] + pub fn device(&self) -> &ReferenceDevice { + &self.device + } + + /// The validated samples (strictly increasing timestamps). + #[must_use] + pub fn samples(&self) -> &[ReferenceSample] { + &self.samples + } +} + +// --------------------------------------------------------------------------- +// Estimate series (CSI-derived) +// --------------------------------------------------------------------------- + +/// A CSI-derived estimate series, extracted from [`VitalReading`]s, carrying +/// the same validated-invariant as [`ReferenceSeries`]. +#[derive(Debug, Clone, PartialEq)] +#[cfg_attr(feature = "serde", derive(Serialize))] +pub struct EstimateSeries { + measurand: Measurand, + samples: Vec, +} + +impl EstimateSeries { + /// Build from in-memory samples, validating the invariant. + /// + /// Errors use the zero-based sample index as `row`. + pub fn new( + measurand: Measurand, + samples: Vec, + ) -> Result { + validate_samples(&samples)?; + Ok(Self { measurand, samples }) + } + + /// Extract one measurand from a slice of pipeline readings. + /// + /// Readings whose selected estimate has [`VitalStatus::Unavailable`] are + /// skipped (Degraded/Unreliable estimates are kept — honest agreement + /// statistics must include them). `timestamp_secs` is converted to unix + /// milliseconds; non-finite or out-of-range timestamps/values are + /// structured errors carrying the zero-based reading index as `row`. + pub fn from_readings( + measurand: Measurand, + readings: &[VitalReading], + ) -> Result { + let mut samples: Vec = Vec::new(); + let mut prev_ts: Option = None; + for (row, reading) in readings.iter().enumerate() { + let est = match measurand { + Measurand::HeartRateBpm => &reading.heart_rate, + Measurand::BreathingRateBrpm => &reading.respiratory_rate, + }; + if est.status == VitalStatus::Unavailable { + continue; + } + let ts_ms_f = reading.timestamp_secs * 1000.0; + if !ts_ms_f.is_finite() || ts_ms_f.abs() > MAX_TIMESTAMP_ABS_MS as f64 { + return Err(GroundTruthError::TimestampOutOfRange { row }); + } + #[allow(clippy::cast_possible_truncation)] + let timestamp_ms = ts_ms_f.round() as i64; + validate_sample(row, timestamp_ms, est.value_bpm, prev_ts)?; + prev_ts = Some(timestamp_ms); + samples.push(ReferenceSample { + timestamp_ms, + value: est.value_bpm, + }); + } + if samples.is_empty() { + return Err(GroundTruthError::NoSamples); + } + Ok(Self { measurand, samples }) + } + + /// Extract one measurand from everything currently held in a + /// [`VitalSignStore`] session. + /// + /// Takes `&mut` because [`VitalSignStore::history`] rotates its ring + /// buffer in place; contents are unchanged. + pub fn from_store( + measurand: Measurand, + store: &mut VitalSignStore, + ) -> Result { + let n = store.len(); + Self::from_readings(measurand, store.history(n)) + } + + /// The measurand this series records. + #[must_use] + pub fn measurand(&self) -> Measurand { + self.measurand + } + + /// The validated samples (strictly increasing timestamps). + #[must_use] + pub fn samples(&self) -> &[ReferenceSample] { + &self.samples + } +} + +// --------------------------------------------------------------------------- +// Resampling +// --------------------------------------------------------------------------- + +/// Number of grid points spanning `[start, end]` at `step` ms, bounded by +/// [`MAX_GRID_POINTS`]. +fn grid_len(start_ms: i64, end_ms: i64, step_ms: i64) -> Result { + debug_assert!(end_ms >= start_ms && step_ms > 0); + let points = (end_ms - start_ms) / step_ms + 1; + let points_u = points as u64; + if points_u > MAX_GRID_POINTS as u64 { + return Err(GroundTruthError::GridTooLarge { points: points_u }); + } + Ok(points as usize) +} + +/// Nearest-sample resampling onto a uniform grid. +/// +/// A grid point at time `t` takes the value of the nearest sample if that +/// sample is within `max_dist_ms`; otherwise the grid point is `None`. No +/// interpolation is performed, so physiological values are never bridged +/// across gaps: with `max_dist_ms = max_gap_ms / 2`, two samples further +/// apart than `max_gap_ms` leave uncovered grid points between them. +fn resample_nearest( + samples: &[ReferenceSample], + grid_start_ms: i64, + step_ms: i64, + n_points: usize, + max_dist_ms: i64, +) -> Vec> { + debug_assert!(!samples.is_empty()); + let mut out = Vec::with_capacity(n_points); + let mut j = 0usize; + for i in 0..n_points { + let t = grid_start_ms + (i as i64) * step_ms; + while j + 1 < samples.len() + && (samples[j + 1].timestamp_ms - t).abs() < (samples[j].timestamp_ms - t).abs() + { + j += 1; + } + let dist = (samples[j].timestamp_ms - t).abs(); + out.push(if dist <= max_dist_ms { + Some(samples[j].value) + } else { + None + }); + } + out +} + +// --------------------------------------------------------------------------- +// Time alignment +// --------------------------------------------------------------------------- + +/// Configuration for [`align`]. +#[derive(Debug, Clone)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub struct AlignmentConfig { + /// Bounded lag search window in milliseconds (default ±30 s). + pub max_lag_ms: i64, + /// Common resampling grid step in milliseconds (also the offset + /// resolution; default 1000). + pub grid_step_ms: i64, + /// Maximum gap in milliseconds across which values may be carried to a + /// grid point (nearest-sample within `max_gap_ms / 2`; default 5000). + pub max_gap_ms: i64, + /// Minimum overlapping valid pairs required at a candidate lag + /// (default 10). + pub min_overlap: usize, + /// Whether to additionally fit a linear clock drift (default false). + pub fit_drift: bool, + /// Number of windows for the drift fit (default 4, minimum 2). + pub drift_windows: usize, +} + +impl Default for AlignmentConfig { + fn default() -> Self { + Self { + max_lag_ms: 30_000, + grid_step_ms: 1000, + max_gap_ms: 5000, + min_overlap: 10, + fit_drift: false, + drift_windows: 4, + } + } +} + +impl AlignmentConfig { + fn validate(&self) -> Result<(), GroundTruthError> { + if self.grid_step_ms <= 0 { + return Err(GroundTruthError::InvalidConfig("grid_step_ms must be > 0")); + } + if self.max_gap_ms <= 0 { + return Err(GroundTruthError::InvalidConfig("max_gap_ms must be > 0")); + } + if self.max_lag_ms < 0 || self.max_lag_ms > MAX_TIMESTAMP_ABS_MS { + return Err(GroundTruthError::InvalidConfig( + "max_lag_ms must be in [0, 2^52]", + )); + } + if self.min_overlap < 2 { + return Err(GroundTruthError::InvalidConfig("min_overlap must be >= 2")); + } + if self.fit_drift && self.drift_windows < 2 { + return Err(GroundTruthError::InvalidConfig( + "drift_windows must be >= 2 when fit_drift is set", + )); + } + Ok(()) + } +} + +/// Optional linear clock-drift fit: the estimated offset as a linear +/// function of time, `offset(t) ≈ offset_at_start_ms + rate_ppm * 1e-6 * t`, +/// with `t` measured from the start of the common grid. +#[derive(Debug, Clone, PartialEq)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub struct DriftFit { + /// Fitted offset at the start of the common grid, milliseconds. + pub offset_at_start_ms: f64, + /// Fitted clock rate difference, parts per million (positive: the + /// estimate clock runs slow relative to the reference clock). + pub rate_ppm: f64, + /// Number of windows that produced a usable local offset. + pub windows_used: usize, +} + +/// Result of [`align`]. Parameters are reported here and must be passed +/// explicitly to [`AgreementReport::compute`] — they are never silently +/// applied to any series. +#[derive(Debug, Clone, PartialEq)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub struct AlignmentResult { + /// Estimated constant clock offset in milliseconds: add this to + /// estimate timestamps to map them onto the reference clock. + pub offset_ms: i64, + /// Peak normalized cross-correlation at the chosen offset, in `[-1, 1]`. + pub peak_ncc: f64, + /// Number of overlapping valid grid pairs at the chosen offset. + pub n_overlap: usize, + /// Grid step used, milliseconds (the offset resolution). + pub grid_step_ms: i64, + /// Optional linear clock-drift fit (requested via + /// [`AlignmentConfig::fit_drift`]; `None` if too few windows aligned). + pub drift: Option, +} + +/// Best lag found by a bounded normalized cross-correlation search. +struct LagSearch { + lag_steps: i64, + ncc: f64, + n_overlap: usize, +} + +/// Search lags `-max_lag_steps..=max_lag_steps` for the maximum normalized +/// cross-correlation between `est[i]` and `refg[i + lag]` over pairs where +/// both grids hold a value. Ties prefer the smaller `|lag|` (deterministic: +/// lags are scanned in increasing order). +fn best_lag( + est: &[Option], + refg: &[Option], + max_lag_steps: i64, + min_overlap: usize, +) -> Result { + let n = est.len() as i64; + let mut best: Option = None; + let mut max_overlap_seen = 0usize; + + for lag in -max_lag_steps..=max_lag_steps { + let i_lo = 0.max(-lag); + let i_hi = n.min(n - lag); + if i_hi <= i_lo { + continue; + } + // Zip the two aligned windows once per lag: no per-lag allocation, + // and no per-element bounds check inside the O(lags × n) hot loop. + let est_win = &est[i_lo as usize..i_hi as usize]; + let ref_win = &refg[(i_lo + lag) as usize..(i_hi + lag) as usize]; + let mut count = 0usize; + let (mut se, mut sr, mut see, mut srr, mut ser) = (0.0f64, 0.0, 0.0, 0.0, 0.0); + for (&ev, &rv) in est_win.iter().zip(ref_win) { + let (Some(e), Some(r)) = (ev, rv) else { + continue; + }; + count += 1; + se += e; + sr += r; + see += e * e; + srr += r * r; + ser += e * r; + } + max_overlap_seen = max_overlap_seen.max(count); + if count < min_overlap { + continue; + } + let nf = count as f64; + let var_e = see - se * se / nf; + let var_r = srr - sr * sr / nf; + if var_e <= 0.0 || var_r <= 0.0 { + continue; + } + let ncc = (ser - se * sr / nf) / (var_e * var_r).sqrt(); + let take = match &best { + None => true, + Some(b) => ncc > b.ncc || (ncc == b.ncc && lag.abs() < b.lag_steps.abs()), + }; + if take { + best = Some(LagSearch { + lag_steps: lag, + ncc, + n_overlap: count, + }); + } + } + + best.ok_or({ + if max_overlap_seen < min_overlap { + GroundTruthError::InsufficientOverlap { + required: min_overlap, + found: max_overlap_seen, + } + } else { + GroundTruthError::ConstantSignal + } + }) +} + +/// Fit a linear clock drift from per-window constant offsets. +/// +/// The common grid is split into `cfg.drift_windows` equal windows; each +/// window runs its own bounded lag search, and the resulting +/// (window-center-time, local-offset) points are fit by least squares. +/// Returns `None` when fewer than two windows align. +fn fit_drift( + est: &[Option], + refg: &[Option], + max_lag_steps: i64, + cfg: &AlignmentConfig, +) -> Option { + let n = est.len(); + let windows = cfg.drift_windows; + let mut xs: Vec = Vec::with_capacity(windows); + let mut ys: Vec = Vec::with_capacity(windows); + for w in 0..windows { + let lo = w * n / windows; + let hi = ((w + 1) * n / windows).min(n); + if hi <= lo { + continue; + } + if let Ok(local) = best_lag(&est[lo..hi], &refg[lo..hi], max_lag_steps, cfg.min_overlap) { + let center_ms = ((lo + hi) as f64 / 2.0) * cfg.grid_step_ms as f64; + xs.push(center_ms); + ys.push((local.lag_steps * cfg.grid_step_ms) as f64); + } + } + if xs.len() < 2 { + return None; + } + let nf = xs.len() as f64; + let x_mean = xs.iter().sum::() / nf; + let y_mean = ys.iter().sum::() / nf; + let sxx: f64 = xs.iter().map(|x| (x - x_mean) * (x - x_mean)).sum(); + if sxx <= 0.0 { + return None; + } + let sxy: f64 = xs + .iter() + .zip(&ys) + .map(|(x, y)| (x - x_mean) * (y - y_mean)) + .sum(); + let slope = sxy / sxx; + let intercept = y_mean - slope * x_mean; + Some(DriftFit { + offset_at_start_ms: intercept, + rate_ppm: slope * 1.0e6, + windows_used: xs.len(), + }) +} + +/// Estimate the constant clock offset between a CSI-derived estimate series +/// and a reference series by maximizing normalized cross-correlation over a +/// bounded lag window on a common nearest-sample grid. +/// +/// Grid points further than `max_gap_ms / 2` from any sample are treated as +/// gaps and never bridged. The returned offset has `grid_step_ms` +/// resolution and is **reported, not applied** — pass it explicitly to +/// [`AgreementReport::compute`]. +pub fn align( + estimate: &EstimateSeries, + reference: &ReferenceSeries, + cfg: &AlignmentConfig, +) -> Result { + cfg.validate()?; + if estimate.measurand != reference.measurand { + return Err(GroundTruthError::MeasurandMismatch { + estimate: estimate.measurand, + reference: reference.measurand, + }); + } + let e = estimate.samples(); + let r = reference.samples(); + let start = e[0].timestamp_ms.min(r[0].timestamp_ms); + let end = e[e.len() - 1] + .timestamp_ms + .max(r[r.len() - 1].timestamp_ms); + let n = grid_len(start, end, cfg.grid_step_ms)?; + let max_dist = cfg.max_gap_ms / 2; + let eg = resample_nearest(e, start, cfg.grid_step_ms, n, max_dist); + let rg = resample_nearest(r, start, cfg.grid_step_ms, n, max_dist); + let max_lag_steps = cfg.max_lag_ms / cfg.grid_step_ms; + + let global = best_lag(&eg, &rg, max_lag_steps, cfg.min_overlap)?; + let drift = if cfg.fit_drift { + fit_drift(&eg, &rg, max_lag_steps, cfg) + } else { + None + }; + + Ok(AlignmentResult { + offset_ms: global.lag_steps * cfg.grid_step_ms, + peak_ncc: global.ncc, + n_overlap: global.n_overlap, + grid_step_ms: cfg.grid_step_ms, + drift, + }) +} + +// --------------------------------------------------------------------------- +// Session scope +// --------------------------------------------------------------------------- + +/// Subject motion state during a session. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub enum MotionState { + /// Subject seated/lying, minimal movement. + Static, + /// Subject moving during the session. + Moving, +} + +/// RF propagation condition between sensor and subject. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub enum Propagation { + /// Clear line of sight. + LineOfSight, + /// Obstructed within the same room (furniture, people). + NonLineOfSight, + /// Signal traverses at least one wall. + ThroughWall, +} + +/// Coarse sensor-to-subject distance band. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub enum DistanceBand { + /// Up to 2 m. + Near, + /// 2 m to 5 m. + Mid, + /// Beyond 5 m. + Far, +} + +/// Mandatory scope statement for an agreement report (ADR-290): a vitals +/// number without its scope is systematically misleading, so a report +/// cannot be constructed without one. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub struct SessionScope { + /// Number of people in the sensing area during the session. + pub subject_count: u32, + /// Subject motion state. + pub motion: MotionState, + /// RF propagation condition. + pub propagation: Propagation, + /// Sensor-to-subject distance band. + pub distance_band: DistanceBand, +} + +// --------------------------------------------------------------------------- +// Agreement metrics +// --------------------------------------------------------------------------- + +/// Configuration for [`AgreementReport::compute`]. +#[derive(Debug, Clone)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub struct AgreementConfig { + /// Pairing grid step in milliseconds (default 1000). + pub grid_step_ms: i64, + /// Maximum gap in milliseconds across which values may be carried to a + /// grid point (nearest-sample within `max_gap_ms / 2`; default 5000). + pub max_gap_ms: i64, + /// Agreement tolerance in BPM; `None` uses + /// [`Measurand::default_tolerance_bpm`] (±2 bpm HR, ±1 brpm breathing). + pub tolerance_bpm: Option, +} + +impl Default for AgreementConfig { + fn default() -> Self { + Self { + grid_step_ms: 1000, + max_gap_ms: 5000, + tolerance_bpm: None, + } + } +} + +impl AgreementConfig { + fn validate(&self) -> Result<(), GroundTruthError> { + if self.grid_step_ms <= 0 { + return Err(GroundTruthError::InvalidConfig("grid_step_ms must be > 0")); + } + if self.max_gap_ms <= 0 { + return Err(GroundTruthError::InvalidConfig("max_gap_ms must be > 0")); + } + if let Some(t) = self.tolerance_bpm { + if !t.is_finite() || t <= 0.0 { + return Err(GroundTruthError::InvalidConfig( + "tolerance_bpm must be finite and > 0", + )); + } + } + Ok(()) + } +} + +/// Agreement statistics between an aligned estimate series and a reference +/// series. Differences are `estimate - reference` in BPM. +/// +/// The [`SessionScope`] field is mandatory by type: no report exists +/// without its scope. `applied_offset_ms` records the alignment that was +/// explicitly applied for pairing. +#[derive(Debug, Clone, PartialEq)] +#[cfg_attr(feature = "serde", derive(Serialize))] +pub struct AgreementReport { + /// Measurand compared. + pub measurand: Measurand, + /// Reference device the estimates were compared against. + pub device: ReferenceDevice, + /// Mandatory session scope. + pub scope: SessionScope, + /// Constant clock offset (ms) that was explicitly applied to estimate + /// timestamps for pairing. + pub applied_offset_ms: i64, + /// Number of paired samples. + pub n_pairs: usize, + /// Fraction of the overlapping-span grid where both series had a valid + /// sample, in `[0, 1]`. + pub coverage: f64, + /// Mean absolute error, BPM. + pub mae_bpm: f64, + /// Root-mean-square error, BPM. + pub rmse_bpm: f64, + /// Mean error (bias), BPM. + pub bias_bpm: f64, + /// Bland-Altman lower 95% limit of agreement (`bias - 1.96·SD`), BPM. + pub loa_lower_bpm: f64, + /// Bland-Altman upper 95% limit of agreement (`bias + 1.96·SD`), BPM. + pub loa_upper_bpm: f64, + /// Tolerance used for `within_tolerance_fraction`, BPM. + pub tolerance_bpm: f64, + /// Fraction of pairs with `|estimate - reference| <= tolerance_bpm`. + pub within_tolerance_fraction: f64, +} + +impl AgreementReport { + /// Compute agreement statistics between an estimate and a reference + /// series, applying the given constant clock offset (typically + /// [`AlignmentResult::offset_ms`]) to the estimate timestamps. + /// + /// The offset is a required, explicit argument — alignment is never + /// applied silently — and is echoed back in `applied_offset_ms`. + /// Pairing uses nearest-sample resampling on a grid over the + /// overlapping span; gaps wider than `max_gap_ms` are never bridged. + /// At least two pairs are required (Bland-Altman limits need a sample + /// standard deviation). + pub fn compute( + estimate: &EstimateSeries, + reference: &ReferenceSeries, + applied_offset_ms: i64, + cfg: &AgreementConfig, + scope: SessionScope, + ) -> Result { + cfg.validate()?; + if applied_offset_ms.abs() > MAX_TIMESTAMP_ABS_MS { + return Err(GroundTruthError::InvalidConfig( + "applied_offset_ms out of range", + )); + } + if estimate.measurand != reference.measurand { + return Err(GroundTruthError::MeasurandMismatch { + estimate: estimate.measurand, + reference: reference.measurand, + }); + } + let e = estimate.samples(); + let r = reference.samples(); + // Overlapping span on the reference clock; |ts| <= 2^52 and + // |offset| <= 2^52 keep the sums well inside i64. + let e_start = e[0].timestamp_ms + applied_offset_ms; + let e_end = e[e.len() - 1].timestamp_ms + applied_offset_ms; + let start = e_start.max(r[0].timestamp_ms); + let end = e_end.min(r[r.len() - 1].timestamp_ms); + if end < start { + return Err(GroundTruthError::InsufficientOverlap { + required: 2, + found: 0, + }); + } + let n_grid = grid_len(start, end, cfg.grid_step_ms)?; + let max_dist = cfg.max_gap_ms / 2; + let eg = resample_nearest( + e, + start - applied_offset_ms, + cfg.grid_step_ms, + n_grid, + max_dist, + ); + let rg = resample_nearest(r, start, cfg.grid_step_ms, n_grid, max_dist); + + let diffs: Vec = eg + .iter() + .zip(&rg) + .filter_map(|(ev, rv)| match (ev, rv) { + (Some(ev), Some(rv)) => Some(ev - rv), + _ => None, + }) + .collect(); + let n_pairs = diffs.len(); + if n_pairs < 2 { + return Err(GroundTruthError::InsufficientOverlap { + required: 2, + found: n_pairs, + }); + } + + let nf = n_pairs as f64; + let bias = diffs.iter().sum::() / nf; + let mae = diffs.iter().map(|d| d.abs()).sum::() / nf; + let rmse = (diffs.iter().map(|d| d * d).sum::() / nf).sqrt(); + let var = diffs.iter().map(|d| (d - bias) * (d - bias)).sum::() / (nf - 1.0); + let sd = var.sqrt(); + let tolerance = cfg + .tolerance_bpm + .unwrap_or_else(|| estimate.measurand.default_tolerance_bpm()); + let within = diffs.iter().filter(|d| d.abs() <= tolerance).count() as f64 / nf; + + Ok(Self { + measurand: estimate.measurand, + device: reference.device.clone(), + scope, + applied_offset_ms, + n_pairs, + coverage: nf / n_grid as f64, + mae_bpm: mae, + rmse_bpm: rmse, + bias_bpm: bias, + loa_lower_bpm: bias - 1.96 * sd, + loa_upper_bpm: bias + 1.96 * sd, + tolerance_bpm: tolerance, + within_tolerance_fraction: within, + }) + } +} + +// --------------------------------------------------------------------------- +// Evidence grading +// --------------------------------------------------------------------------- + +/// Proof-of-measurement payload for [`EvidenceGrade::Measured`]. +/// +/// Has no public constructor: the only way to obtain one is +/// [`GradedAgreementReport::measured`], which enforces the gate. This makes +/// `MEASURED` unconstructible without passing the gate (ADR-288-style +/// enforcement in types). +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(Serialize))] +pub struct MeasuredEvidence { + reproducer: String, +} + +impl MeasuredEvidence { + /// The exact command line that reproduces the reported numbers. + #[must_use] + pub fn reproducer(&self) -> &str { + &self.reproducer + } +} + +/// Evidence grade per CLAUDE.md tagging rules. +#[derive(Debug, Clone, PartialEq)] +#[cfg_attr(feature = "serde", derive(Serialize))] +pub enum EvidenceGrade { + /// Measured against a real reference device with a reproducer command. + /// Only constructible through [`GradedAgreementReport::measured`]. + Measured(MeasuredEvidence), + /// Real data, but the comparison does not meet the `MEASURED` gate + /// (or is quoted rather than reproduced here). + Claimed, + /// Computed on synthetic/generated input. + Synthetic, +} + +impl EvidenceGrade { + /// Stable uppercase tag (`MEASURED` / `CLAIMED` / `SYNTHETIC`). + #[must_use] + pub fn tag(&self) -> &'static str { + match self { + Self::Measured(_) => "MEASURED", + Self::Claimed => "CLAIMED", + Self::Synthetic => "SYNTHETIC", + } + } +} + +/// An [`AgreementReport`] paired with its [`EvidenceGrade`]. +/// +/// Fields are private; the constructors are the policy: +/// +/// - [`Self::measured`] requires a reference device (structurally present in +/// every computed report), `n_pairs > 0`, coverage of at least +/// [`MIN_MEASURED_COVERAGE`], and a non-blank reproducer command. +/// - [`Self::claimed`] and [`Self::synthetic`] are always available. +#[derive(Debug, Clone, PartialEq)] +#[cfg_attr(feature = "serde", derive(Serialize))] +pub struct GradedAgreementReport { + report: AgreementReport, + evidence: EvidenceGrade, +} + +impl GradedAgreementReport { + /// Grade a report `MEASURED`. The gate is enforced here, not in docs: + /// zero pairs, coverage below [`MIN_MEASURED_COVERAGE`], or a blank + /// reproducer are structured errors. + pub fn measured( + report: AgreementReport, + reproducer: &str, + ) -> Result { + if report.n_pairs == 0 { + return Err(GroundTruthError::NotMeasured( + "zero paired samples against the reference device", + )); + } + if !report.coverage.is_finite() || report.coverage < MIN_MEASURED_COVERAGE { + return Err(GroundTruthError::NotMeasured( + "coverage below the minimum for a measured claim", + )); + } + if reproducer.trim().is_empty() { + return Err(GroundTruthError::NotMeasured( + "a measured number without a reproducer command is not measured", + )); + } + Ok(Self { + report, + evidence: EvidenceGrade::Measured(MeasuredEvidence { + reproducer: reproducer.trim().to_string(), + }), + }) + } + + /// Grade a report `CLAIMED` (real data, gate not met or not reproduced + /// here). + #[must_use] + pub fn claimed(report: AgreementReport) -> Self { + Self { + report, + evidence: EvidenceGrade::Claimed, + } + } + + /// Grade a report `SYNTHETIC` (generated input). + #[must_use] + pub fn synthetic(report: AgreementReport) -> Self { + Self { + report, + evidence: EvidenceGrade::Synthetic, + } + } + + /// The underlying agreement report. + #[must_use] + pub fn report(&self) -> &AgreementReport { + &self.report + } + + /// The evidence grade. + #[must_use] + pub fn evidence(&self) -> &EvidenceGrade { + &self.evidence + } +} + +// --------------------------------------------------------------------------- +// Session evaluation (VitalSignStore integration) +// --------------------------------------------------------------------------- + +/// Alignment plus agreement for one store session, with the alignment +/// parameters visible in both places. +#[derive(Debug, Clone, PartialEq)] +#[cfg_attr(feature = "serde", derive(Serialize))] +pub struct SessionEvaluation { + /// The estimated alignment (offset and optional drift fit). + pub alignment: AlignmentResult, + /// Agreement computed with `alignment.offset_ms` explicitly applied + /// (echoed in `report.applied_offset_ms`). Drift is reported only, + /// never applied. + pub report: AgreementReport, +} + +/// Evaluate everything currently held in a [`VitalSignStore`] session +/// against a reference series: extract the matching measurand, estimate the +/// constant clock offset, and compute agreement with that offset explicitly +/// applied (and reported in the result). +/// +/// Takes `&mut` store because reading history rotates its ring buffer in +/// place; contents are unchanged. +pub fn evaluate_session( + store: &mut VitalSignStore, + reference: &ReferenceSeries, + align_cfg: &AlignmentConfig, + agree_cfg: &AgreementConfig, + scope: SessionScope, +) -> Result { + let estimate = EstimateSeries::from_store(reference.measurand(), store)?; + let alignment = align(&estimate, reference, align_cfg)?; + let report = AgreementReport::compute( + &estimate, + reference, + alignment.offset_ms, + agree_cfg, + scope, + )?; + Ok(SessionEvaluation { alignment, report }) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::{VitalEstimate, VitalReading, VitalStatus}; + + fn device() -> ReferenceDevice { + ReferenceDevice { + make: "Polar".to_string(), + model: "H10".to_string(), + principle: MeasurementPrinciple::Ecg, + } + } + + fn scope() -> SessionScope { + SessionScope { + subject_count: 1, + motion: MotionState::Static, + propagation: Propagation::LineOfSight, + distance_band: DistanceBand::Near, + } + } + + /// Deterministic aperiodic test signal (BPM range), aperiodic within + /// the ±30 s lag window thanks to incommensurate periods. + fn synth(t_secs: f64) -> f64 { + 70.0 + 5.0 * (2.0 * std::f64::consts::PI * t_secs / 47.0).sin() + + 3.0 * (2.0 * std::f64::consts::PI * t_secs / 113.0).sin() + } + + fn series_1hz(start_ms: i64, n: usize, f: impl Fn(f64) -> f64) -> Vec { + (0..n) + .map(|i| { + let ts = start_ms + (i as i64) * 1000; + ReferenceSample { + timestamp_ms: ts, + value: f(ts as f64 / 1000.0), + } + }) + .collect() + } + + // -- CSV parsing -------------------------------------------------------- + + #[test] + fn csv_parses_valid_input() { + let csv = "timestamp_ms,value\n1000,72.5\n2000,73.0\n3000,71.5\n"; + let s = ReferenceSeries::parse_csv(Measurand::HeartRateBpm, device(), csv).unwrap(); + assert_eq!(s.samples().len(), 3); + assert_eq!(s.samples()[0].timestamp_ms, 1000); + assert!((s.samples()[2].value - 71.5).abs() < f64::EPSILON); + assert_eq!(s.measurand(), Measurand::HeartRateBpm); + assert_eq!(s.device().make, "Polar"); + } + + #[test] + fn csv_tolerates_crlf_blank_lines_and_bom() { + let csv = "\u{feff}timestamp_ms,value\r\n\r\n1000,72\r\n2000,73\r\n\r\n"; + let s = ReferenceSeries::parse_csv(Measurand::HeartRateBpm, device(), csv).unwrap(); + assert_eq!(s.samples().len(), 2); + } + + #[test] + fn csv_rejects_empty_input() { + let err = ReferenceSeries::parse_csv(Measurand::HeartRateBpm, device(), "").unwrap_err(); + assert_eq!(err, GroundTruthError::MissingHeader); + } + + #[test] + fn csv_rejects_bad_header() { + let err = ReferenceSeries::parse_csv(Measurand::HeartRateBpm, device(), "time,bpm\n1,2\n") + .unwrap_err(); + assert!(matches!(err, GroundTruthError::BadHeader { .. })); + } + + #[test] + fn csv_bad_header_echo_is_bounded() { + let long = "x".repeat(10_000); + let err = + ReferenceSeries::parse_csv(Measurand::HeartRateBpm, device(), &long).unwrap_err(); + let GroundTruthError::BadHeader { found } = err else { + panic!("expected BadHeader"); + }; + assert!(found.chars().count() <= MAX_ERROR_ECHO + 1); + } + + #[test] + fn csv_rejects_wrong_field_count_with_row_number() { + let csv = "timestamp_ms,value\n1000,72\n2000,73,extra\n"; + let err = ReferenceSeries::parse_csv(Measurand::HeartRateBpm, device(), csv).unwrap_err(); + assert_eq!(err, GroundTruthError::WrongFieldCount { row: 3, found: 3 }); + + let csv = "timestamp_ms,value\njustonefield\n"; + let err = ReferenceSeries::parse_csv(Measurand::HeartRateBpm, device(), csv).unwrap_err(); + assert_eq!(err, GroundTruthError::WrongFieldCount { row: 2, found: 1 }); + } + + #[test] + fn csv_rejects_bad_timestamp_with_row_number() { + let csv = "timestamp_ms,value\n1000,72\nnot_a_ts,73\n"; + let err = ReferenceSeries::parse_csv(Measurand::HeartRateBpm, device(), csv).unwrap_err(); + assert_eq!(err, GroundTruthError::BadTimestamp { row: 3 }); + } + + #[test] + fn csv_rejects_bad_and_nonfinite_values() { + let csv = "timestamp_ms,value\n1000,abc\n"; + let err = ReferenceSeries::parse_csv(Measurand::HeartRateBpm, device(), csv).unwrap_err(); + assert_eq!(err, GroundTruthError::BadValue { row: 2 }); + + let csv = "timestamp_ms,value\n1000,NaN\n"; + let err = ReferenceSeries::parse_csv(Measurand::HeartRateBpm, device(), csv).unwrap_err(); + assert_eq!(err, GroundTruthError::BadValue { row: 2 }); + + let csv = "timestamp_ms,value\n1000,inf\n"; + let err = ReferenceSeries::parse_csv(Measurand::HeartRateBpm, device(), csv).unwrap_err(); + assert_eq!(err, GroundTruthError::BadValue { row: 2 }); + } + + #[test] + fn csv_rejects_out_of_range_value() { + let csv = "timestamp_ms,value\n1000,400\n"; + let err = ReferenceSeries::parse_csv(Measurand::HeartRateBpm, device(), csv).unwrap_err(); + assert!(matches!(err, GroundTruthError::ValueOutOfRange { row: 2, .. })); + + let csv = "timestamp_ms,value\n1000,-1\n"; + let err = ReferenceSeries::parse_csv(Measurand::HeartRateBpm, device(), csv).unwrap_err(); + assert!(matches!(err, GroundTruthError::ValueOutOfRange { row: 2, .. })); + } + + #[test] + fn csv_rejects_non_monotonic_timestamps() { + // Decreasing. + let csv = "timestamp_ms,value\n2000,72\n1000,73\n"; + let err = ReferenceSeries::parse_csv(Measurand::HeartRateBpm, device(), csv).unwrap_err(); + assert_eq!(err, GroundTruthError::NonMonotonicTimestamp { row: 3 }); + + // Duplicate. + let csv = "timestamp_ms,value\n2000,72\n2000,73\n"; + let err = ReferenceSeries::parse_csv(Measurand::HeartRateBpm, device(), csv).unwrap_err(); + assert_eq!(err, GroundTruthError::NonMonotonicTimestamp { row: 3 }); + } + + #[test] + fn csv_rejects_timestamp_out_of_range() { + let csv = format!("timestamp_ms,value\n{},72\n", i64::MAX); + let err = ReferenceSeries::parse_csv(Measurand::HeartRateBpm, device(), &csv).unwrap_err(); + // i64::MAX parses fine but exceeds the 2^52 bound. + assert_eq!(err, GroundTruthError::TimestampOutOfRange { row: 2 }); + } + + #[test] + fn csv_rejects_header_only() { + let err = ReferenceSeries::parse_csv(Measurand::HeartRateBpm, device(), "timestamp_ms,value\n") + .unwrap_err(); + assert_eq!(err, GroundTruthError::NoSamples); + } + + #[test] + fn csv_row_limit_is_enforced() { + let csv = "timestamp_ms,value\n1000,70\n2000,71\n3000,72\n"; + let err = + ReferenceSeries::parse_csv_bounded(Measurand::HeartRateBpm, device(), csv, 2) + .unwrap_err(); + assert_eq!(err, GroundTruthError::TooManyRows { max: 2 }); + } + + #[test] + fn series_new_validates_and_reports_index() { + let err = ReferenceSeries::new(Measurand::HeartRateBpm, device(), vec![]).unwrap_err(); + assert_eq!(err, GroundTruthError::NoSamples); + + let samples = vec![ + ReferenceSample { + timestamp_ms: 2000, + value: 70.0, + }, + ReferenceSample { + timestamp_ms: 1000, + value: 71.0, + }, + ]; + let err = ReferenceSeries::new(Measurand::HeartRateBpm, device(), samples).unwrap_err(); + assert_eq!(err, GroundTruthError::NonMonotonicTimestamp { row: 1 }); + } + + // -- Alignment ---------------------------------------------------------- + + fn cfg_default() -> AlignmentConfig { + AlignmentConfig::default() + } + + #[test] + fn alignment_recovers_zero_offset() { + let reference = ReferenceSeries::new( + Measurand::HeartRateBpm, + device(), + series_1hz(0, 300, synth), + ) + .unwrap(); + let estimate = EstimateSeries::new( + Measurand::HeartRateBpm, + series_1hz(0, 300, synth), + ) + .unwrap(); + let result = align(&estimate, &reference, &cfg_default()).unwrap(); + assert_eq!(result.offset_ms, 0); + assert!(result.peak_ncc > 0.999); + assert!(result.drift.is_none()); + } + + #[test] + fn alignment_recovers_known_positive_offset() { + // Estimate device stamps events 7 s early: an estimate sample at + // its own clock time t carries the reference value at t + 7 s, so + // reference_time = estimate_time + 7000. + let reference = ReferenceSeries::new( + Measurand::HeartRateBpm, + device(), + series_1hz(0, 600, synth), + ) + .unwrap(); + let est_samples: Vec = (0..600) + .map(|i| ReferenceSample { + timestamp_ms: (i as i64) * 1000 - 7000, + value: synth(i as f64), + }) + .collect(); + let estimate = EstimateSeries::new(Measurand::HeartRateBpm, est_samples).unwrap(); + let result = align(&estimate, &reference, &cfg_default()).unwrap(); + assert_eq!(result.offset_ms, 7000); + assert!(result.peak_ncc > 0.999); + } + + #[test] + fn alignment_recovers_known_negative_offset() { + let reference = ReferenceSeries::new( + Measurand::HeartRateBpm, + device(), + series_1hz(0, 600, synth), + ) + .unwrap(); + let est_samples: Vec = (0..600) + .map(|i| ReferenceSample { + timestamp_ms: (i as i64) * 1000 + 11_000, + value: synth(i as f64), + }) + .collect(); + let estimate = EstimateSeries::new(Measurand::HeartRateBpm, est_samples).unwrap(); + let result = align(&estimate, &reference, &cfg_default()).unwrap(); + assert_eq!(result.offset_ms, -11_000); + assert!(result.peak_ncc > 0.999); + } + + #[test] + fn alignment_rejects_measurand_mismatch() { + let reference = ReferenceSeries::new( + Measurand::BreathingRateBrpm, + device(), + series_1hz(0, 60, |t| { + 15.0 + (t / 20.0).sin() + }), + ) + .unwrap(); + let estimate = EstimateSeries::new( + Measurand::HeartRateBpm, + series_1hz(0, 60, synth), + ) + .unwrap(); + let err = align(&estimate, &reference, &cfg_default()).unwrap_err(); + assert!(matches!(err, GroundTruthError::MeasurandMismatch { .. })); + } + + #[test] + fn alignment_rejects_insufficient_overlap() { + // Series 10 minutes apart with a ±30 s window: no lag overlaps. + let reference = ReferenceSeries::new( + Measurand::HeartRateBpm, + device(), + series_1hz(0, 60, synth), + ) + .unwrap(); + let estimate = EstimateSeries::new( + Measurand::HeartRateBpm, + series_1hz(600_000, 60, synth), + ) + .unwrap(); + let err = align(&estimate, &reference, &cfg_default()).unwrap_err(); + assert!(matches!(err, GroundTruthError::InsufficientOverlap { .. })); + } + + #[test] + fn alignment_rejects_constant_signal() { + let reference = ReferenceSeries::new( + Measurand::HeartRateBpm, + device(), + series_1hz(0, 60, |_| 70.0), + ) + .unwrap(); + let estimate = EstimateSeries::new( + Measurand::HeartRateBpm, + series_1hz(0, 60, |_| 70.0), + ) + .unwrap(); + let err = align(&estimate, &reference, &cfg_default()).unwrap_err(); + assert_eq!(err, GroundTruthError::ConstantSignal); + } + + #[test] + fn alignment_rejects_invalid_config() { + let reference = ReferenceSeries::new( + Measurand::HeartRateBpm, + device(), + series_1hz(0, 60, synth), + ) + .unwrap(); + let estimate = EstimateSeries::new( + Measurand::HeartRateBpm, + series_1hz(0, 60, synth), + ) + .unwrap(); + let cfg = AlignmentConfig { + grid_step_ms: 0, + ..AlignmentConfig::default() + }; + assert!(matches!( + align(&estimate, &reference, &cfg).unwrap_err(), + GroundTruthError::InvalidConfig(_) + )); + let cfg = AlignmentConfig { + fit_drift: true, + drift_windows: 1, + ..AlignmentConfig::default() + }; + assert!(matches!( + align(&estimate, &reference, &cfg).unwrap_err(), + GroundTruthError::InvalidConfig(_) + )); + } + + #[test] + fn alignment_drift_fit_recovers_synthetic_drift() { + // The mapping reference_time = estimate_time + offset(t) with + // offset(t) = 2000 ms + 0.01 * t (1% clock-rate error). + let a_ms = 2000.0; + let b = 0.01; + let n_est = 2000usize; + let est_samples: Vec = (0..n_est) + .map(|i| { + let est_ts = (i as i64) * 1000; + let ref_time_s = (est_ts as f64 + a_ms + b * est_ts as f64) / 1000.0; + ReferenceSample { + timestamp_ms: est_ts, + value: synth(ref_time_s), + } + }) + .collect(); + let ref_samples = series_1hz(0, 2101, synth); + let reference = + ReferenceSeries::new(Measurand::HeartRateBpm, device(), ref_samples).unwrap(); + let estimate = EstimateSeries::new(Measurand::HeartRateBpm, est_samples).unwrap(); + let cfg = AlignmentConfig { + fit_drift: true, + ..AlignmentConfig::default() + }; + let result = align(&estimate, &reference, &cfg).unwrap(); + let drift = result.drift.expect("drift fit should succeed"); + assert_eq!(drift.windows_used, 4); + // True rate is 10_000 ppm; local offsets quantize to the 1 s grid, + // so allow a generous but decisive tolerance. + assert!( + (drift.rate_ppm - 10_000.0).abs() < 2000.0, + "rate_ppm = {}", + drift.rate_ppm + ); + assert!( + (drift.offset_at_start_ms - a_ms).abs() < 1500.0, + "offset_at_start_ms = {}", + drift.offset_at_start_ms + ); + } + + #[test] + fn resampling_does_not_bridge_wide_gaps() { + let samples = vec![ + ReferenceSample { + timestamp_ms: 0, + value: 70.0, + }, + ReferenceSample { + timestamp_ms: 10_000, + value: 71.0, + }, + ]; + // max_dist 500 ms: grid points between the two samples stay None. + let grid = resample_nearest(&samples, 0, 1000, 11, 500); + assert_eq!(grid[0], Some(70.0)); + assert_eq!(grid[10], Some(71.0)); + for g in &grid[1..10] { + assert_eq!(*g, None); + } + } + + // -- Agreement ---------------------------------------------------------- + + fn agree_cfg(tolerance: Option) -> AgreementConfig { + AgreementConfig { + grid_step_ms: 1000, + max_gap_ms: 2000, + tolerance_bpm: tolerance, + } + } + + /// Fixture: diffs (est - ref) = [1, -1, 2, 0] over four 1 Hz pairs. + /// + /// Hand-computed: bias = 0.5, MAE = 1.0, RMSE = sqrt(1.5), + /// SD (n-1) = sqrt(5/3), LoA = 0.5 ± 1.96*sqrt(5/3). + fn fixture_pair() -> (EstimateSeries, ReferenceSeries) { + let ref_samples: Vec = (0..4) + .map(|i| ReferenceSample { + timestamp_ms: i * 1000, + value: 70.0, + }) + .collect(); + let est_values = [71.0, 69.0, 72.0, 70.0]; + let est_samples: Vec = est_values + .iter() + .enumerate() + .map(|(i, v)| ReferenceSample { + timestamp_ms: (i as i64) * 1000, + value: *v, + }) + .collect(); + ( + EstimateSeries::new(Measurand::HeartRateBpm, est_samples).unwrap(), + ReferenceSeries::new(Measurand::HeartRateBpm, device(), ref_samples).unwrap(), + ) + } + + #[test] + fn agreement_matches_hand_computed_fixture() { + let (estimate, reference) = fixture_pair(); + let report = + AgreementReport::compute(&estimate, &reference, 0, &agree_cfg(None), scope()).unwrap(); + + assert_eq!(report.n_pairs, 4); + assert!((report.coverage - 1.0).abs() < 1e-12); + assert!((report.bias_bpm - 0.5).abs() < 1e-12); + assert!((report.mae_bpm - 1.0).abs() < 1e-12); + assert!((report.rmse_bpm - 1.5f64.sqrt()).abs() < 1e-12); + let sd = (5.0f64 / 3.0).sqrt(); + assert!((report.loa_lower_bpm - (0.5 - 1.96 * sd)).abs() < 1e-12); + assert!((report.loa_upper_bpm - (0.5 + 1.96 * sd)).abs() < 1e-12); + // Default HR tolerance ±2 bpm: all four diffs are within. + assert!((report.tolerance_bpm - 2.0).abs() < f64::EPSILON); + assert!((report.within_tolerance_fraction - 1.0).abs() < 1e-12); + assert_eq!(report.applied_offset_ms, 0); + assert_eq!(report.scope, scope()); + } + + #[test] + fn agreement_within_tolerance_with_explicit_tolerance() { + let (estimate, reference) = fixture_pair(); + let report = + AgreementReport::compute(&estimate, &reference, 0, &agree_cfg(Some(1.0)), scope()) + .unwrap(); + // Diffs [1, -1, 2, 0]: three of four within ±1. + assert!((report.within_tolerance_fraction - 0.75).abs() < 1e-12); + assert!((report.tolerance_bpm - 1.0).abs() < f64::EPSILON); + } + + #[test] + fn agreement_breathing_default_tolerance_is_1_brpm() { + let ref_samples = series_1hz(0, 10, |_| 15.0); + let est_samples = series_1hz(0, 10, |_| 16.5); + let reference = + ReferenceSeries::new(Measurand::BreathingRateBrpm, device(), ref_samples).unwrap(); + let estimate = EstimateSeries::new(Measurand::BreathingRateBrpm, est_samples).unwrap(); + let report = + AgreementReport::compute(&estimate, &reference, 0, &agree_cfg(None), scope()).unwrap(); + assert!((report.tolerance_bpm - 1.0).abs() < f64::EPSILON); + // All diffs are +1.5 brpm: none within ±1. + assert!((report.within_tolerance_fraction - 0.0).abs() < 1e-12); + assert!((report.bias_bpm - 1.5).abs() < 1e-9); + } + + #[test] + fn agreement_coverage_reflects_unbridged_gaps() { + // Reference covers 0..=10 s; estimate is missing 4..=7 s. With + // max_gap 1000 (max_dist 500), the four gap grid points stay + // unpaired: 7 pairs over an 11-point grid. + let ref_samples = series_1hz(0, 11, synth); + let est_samples: Vec = (0..11) + .filter(|i| !(4..=7).contains(i)) + .map(|i| ReferenceSample { + timestamp_ms: (i as i64) * 1000, + value: synth(i as f64), + }) + .collect(); + let reference = + ReferenceSeries::new(Measurand::HeartRateBpm, device(), ref_samples).unwrap(); + let estimate = EstimateSeries::new(Measurand::HeartRateBpm, est_samples).unwrap(); + let cfg = AgreementConfig { + grid_step_ms: 1000, + max_gap_ms: 1000, + tolerance_bpm: None, + }; + let report = AgreementReport::compute(&estimate, &reference, 0, &cfg, scope()).unwrap(); + assert_eq!(report.n_pairs, 7); + assert!((report.coverage - 7.0 / 11.0).abs() < 1e-12); + } + + #[test] + fn agreement_applies_offset_explicitly() { + // Estimate timestamps 5 s behind the reference clock; passing the + // alignment offset pairs them exactly. + let ref_samples = series_1hz(0, 120, synth); + let est_samples: Vec = (0..120) + .map(|i| ReferenceSample { + timestamp_ms: (i as i64) * 1000 - 5000, + value: synth(i as f64), + }) + .collect(); + let reference = + ReferenceSeries::new(Measurand::HeartRateBpm, device(), ref_samples).unwrap(); + let estimate = EstimateSeries::new(Measurand::HeartRateBpm, est_samples).unwrap(); + let report = + AgreementReport::compute(&estimate, &reference, 5000, &agree_cfg(None), scope()) + .unwrap(); + assert_eq!(report.applied_offset_ms, 5000); + assert!(report.mae_bpm < 1e-9); + // Without the offset the same series disagree. + let misaligned = + AgreementReport::compute(&estimate, &reference, 0, &agree_cfg(None), scope()).unwrap(); + assert!(misaligned.mae_bpm > report.mae_bpm); + } + + #[test] + fn agreement_rejects_disjoint_and_mismatched_series() { + let (estimate, reference) = fixture_pair(); + // No overlap after a huge offset. + let err = AgreementReport::compute( + &estimate, + &reference, + 1_000_000, + &agree_cfg(None), + scope(), + ) + .unwrap_err(); + assert!(matches!(err, GroundTruthError::InsufficientOverlap { .. })); + + // Measurand mismatch. + let breathing = EstimateSeries::new( + Measurand::BreathingRateBrpm, + series_1hz(0, 4, |_| 15.0), + ) + .unwrap(); + let err = AgreementReport::compute(&breathing, &reference, 0, &agree_cfg(None), scope()) + .unwrap_err(); + assert!(matches!(err, GroundTruthError::MeasurandMismatch { .. })); + } + + // -- Evidence grading --------------------------------------------------- + + fn good_report() -> AgreementReport { + let (estimate, reference) = fixture_pair(); + AgreementReport::compute(&estimate, &reference, 0, &agree_cfg(None), scope()).unwrap() + } + + #[test] + fn measured_grade_requires_reproducer() { + let graded = GradedAgreementReport::measured( + good_report(), + "cargo test -p wifi-densepose-vitals groundtruth", + ) + .unwrap(); + assert_eq!(graded.evidence().tag(), "MEASURED"); + let EvidenceGrade::Measured(evidence) = graded.evidence() else { + panic!("expected Measured"); + }; + assert_eq!( + evidence.reproducer(), + "cargo test -p wifi-densepose-vitals groundtruth" + ); + + let err = GradedAgreementReport::measured(good_report(), " ").unwrap_err(); + assert!(matches!(err, GroundTruthError::NotMeasured(_))); + } + + #[test] + fn measured_grade_rejects_zero_pairs() { + let mut report = good_report(); + report.n_pairs = 0; + let err = GradedAgreementReport::measured(report, "cargo test").unwrap_err(); + assert!(matches!(err, GroundTruthError::NotMeasured(_))); + } + + #[test] + fn measured_grade_rejects_low_coverage() { + let mut report = good_report(); + report.coverage = MIN_MEASURED_COVERAGE - 0.01; + let err = GradedAgreementReport::measured(report, "cargo test").unwrap_err(); + assert!(matches!(err, GroundTruthError::NotMeasured(_))); + } + + #[test] + fn claimed_and_synthetic_grades_are_always_constructible() { + let claimed = GradedAgreementReport::claimed(good_report()); + assert_eq!(claimed.evidence().tag(), "CLAIMED"); + let synthetic = GradedAgreementReport::synthetic(good_report()); + assert_eq!(synthetic.evidence().tag(), "SYNTHETIC"); + assert_eq!(synthetic.report().n_pairs, 4); + } + + // -- VitalSignStore integration ----------------------------------------- + + fn reading(ts_secs: f64, hr: f64, rr: f64, hr_status: VitalStatus) -> VitalReading { + VitalReading { + respiratory_rate: VitalEstimate { + value_bpm: rr, + confidence: 0.9, + status: VitalStatus::Valid, + }, + heart_rate: VitalEstimate { + value_bpm: hr, + confidence: 0.85, + status: hr_status, + }, + subcarrier_count: 56, + signal_quality: 0.9, + timestamp_secs: ts_secs, + } + } + + #[test] + fn estimate_series_from_readings_skips_unavailable() { + let readings = vec![ + reading(0.0, 70.0, 15.0, VitalStatus::Valid), + reading(1.0, 0.0, 15.0, VitalStatus::Unavailable), + reading(2.0, 72.0, 15.0, VitalStatus::Degraded), + ]; + let series = EstimateSeries::from_readings(Measurand::HeartRateBpm, &readings).unwrap(); + assert_eq!(series.samples().len(), 2); + assert_eq!(series.samples()[0].timestamp_ms, 0); + assert_eq!(series.samples()[1].timestamp_ms, 2000); + assert!((series.samples()[1].value - 72.0).abs() < f64::EPSILON); + } + + #[test] + fn estimate_series_from_readings_rejects_bad_input() { + let readings = vec![ + reading(1.0, 70.0, 15.0, VitalStatus::Valid), + reading(1.0, 71.0, 15.0, VitalStatus::Valid), + ]; + let err = EstimateSeries::from_readings(Measurand::HeartRateBpm, &readings).unwrap_err(); + assert_eq!(err, GroundTruthError::NonMonotonicTimestamp { row: 1 }); + + let readings = vec![reading(f64::NAN, 70.0, 15.0, VitalStatus::Valid)]; + let err = EstimateSeries::from_readings(Measurand::HeartRateBpm, &readings).unwrap_err(); + assert_eq!(err, GroundTruthError::TimestampOutOfRange { row: 0 }); + + let readings = vec![reading(0.0, 0.0, 15.0, VitalStatus::Unavailable)]; + let err = EstimateSeries::from_readings(Measurand::HeartRateBpm, &readings).unwrap_err(); + assert_eq!(err, GroundTruthError::NoSamples); + } + + #[test] + fn evaluate_session_end_to_end_recovers_offset_and_agrees() { + // Store readings at 1 Hz on the estimate clock; the reference + // device stamps the same physiological signal 5 s later + // (reference_time = estimate_time + 5000). + let mut store = VitalSignStore::new(1000); + for i in 0..300 { + store.push(reading(i as f64, synth(i as f64 + 5.0), 15.0, VitalStatus::Valid)); + } + let ref_samples: Vec = (0..300) + .map(|i| ReferenceSample { + timestamp_ms: (i as i64) * 1000 + 5000, + value: synth(i as f64 + 5.0), + }) + .collect(); + let reference = + ReferenceSeries::new(Measurand::HeartRateBpm, device(), ref_samples).unwrap(); + + let eval = evaluate_session( + &mut store, + &reference, + &AlignmentConfig::default(), + &AgreementConfig::default(), + scope(), + ) + .unwrap(); + + assert_eq!(eval.alignment.offset_ms, 5000); + assert_eq!(eval.report.applied_offset_ms, 5000); + assert!(eval.report.mae_bpm < 1e-9); + assert!(eval.report.coverage > 0.99); + assert!(eval.report.n_pairs >= 290); + + // And the result meets the MEASURED gate. + let graded = GradedAgreementReport::measured( + eval.report, + "cargo test -p wifi-densepose-vitals evaluate_session_end_to_end", + ) + .unwrap(); + assert_eq!(graded.evidence().tag(), "MEASURED"); + } + + #[test] + fn error_display_is_stable() { + let err = GroundTruthError::NonMonotonicTimestamp { row: 7 }; + assert_eq!( + err.to_string(), + "row 7: timestamps must be strictly increasing" + ); + assert_eq!( + GroundTruthError::MissingHeader.to_string(), + "missing CSV header line 'timestamp_ms,value'" + ); + } + + #[cfg(feature = "serde")] + #[test] + fn graded_report_serializes() { + let graded = GradedAgreementReport::measured(good_report(), "cargo test").unwrap(); + let json = serde_json::to_string(&graded).unwrap(); + assert!(json.contains("Measured")); + assert!(json.contains("cargo test")); + } +} diff --git a/v2/crates/wifi-densepose-vitals/src/lib.rs b/v2/crates/wifi-densepose-vitals/src/lib.rs index ca84aea9..f97112be 100644 --- a/v2/crates/wifi-densepose-vitals/src/lib.rs +++ b/v2/crates/wifi-densepose-vitals/src/lib.rs @@ -23,6 +23,12 @@ //! Results are stored in a [`VitalSignStore`] with configurable //! retention for historical analysis. //! +//! Ground-truth evaluation ([`groundtruth`], ADR-290) ingests a +//! reference-device series (CSV export), time-aligns it against a store +//! session, and produces evidence-graded agreement statistics +//! (MAE/RMSE/bias, Bland-Altman limits, percent-within-tolerance) with a +//! mandatory session scope. +//! //! # Example //! //! ``` @@ -67,6 +73,7 @@ pub mod anomaly; pub mod breathing; +pub mod groundtruth; pub mod heartrate; pub mod preprocessor; pub mod store; @@ -74,6 +81,12 @@ pub mod types; pub use anomaly::{AnomalyAlert, VitalAnomalyDetector}; pub use breathing::BreathingExtractor; +pub use groundtruth::{ + align, evaluate_session, AgreementConfig, AgreementReport, AlignmentConfig, AlignmentResult, + DistanceBand, DriftFit, EstimateSeries, EvidenceGrade, GradedAgreementReport, + GroundTruthError, Measurand, MeasurementPrinciple, MotionState, Propagation, ReferenceDevice, + ReferenceSample, ReferenceSeries, SessionEvaluation, SessionScope, +}; pub use heartrate::HeartRateExtractor; pub use preprocessor::CsiVitalPreprocessor; pub use store::{VitalSignStore, VitalStats};