mirror of
https://github.com/ruvnet/RuView
synced 2026-08-05 19:41:44 +00:00
Merge origin/main into feat/adr-152-wiflow-std-benchmark
CHANGELOG [Unreleased] conflict: combined both sides' disjoint entries (ADR-152/153 bullets merged into the Added section alongside the beyond-sota-public entries from #1018). Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
Generated
+5
@@ -10910,6 +10910,7 @@ version = "0.3.0"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"criterion",
|
||||
"ruvector-mincut",
|
||||
"wifi-densepose-bfld",
|
||||
"wifi-densepose-core",
|
||||
"wifi-densepose-geo",
|
||||
@@ -11079,9 +11080,13 @@ dependencies = [
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"ureq 2.12.1",
|
||||
"wifi-densepose-bfld",
|
||||
"wifi-densepose-engine",
|
||||
"wifi-densepose-geo",
|
||||
"wifi-densepose-hardware",
|
||||
"wifi-densepose-signal",
|
||||
"wifi-densepose-wifiscan",
|
||||
"wifi-densepose-worldgraph",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -8,22 +8,24 @@
|
||||
//!
|
||||
//! # Wire format parsed here (option b — local parser, no cross-crate dep)
|
||||
//!
|
||||
//! Authoritative layout: firmware `csi_collector.c` (ADR-018 + ADR-110).
|
||||
//!
|
||||
//! Offset Size Field
|
||||
//! ────── ──── ─────────────────────────────────────────────────────────────
|
||||
//! 0 4 Magic: 0xC511_0001 (LE u32)
|
||||
//! 4 1 node_id (u8)
|
||||
//! 5 1 n_antennas (u8)
|
||||
//! 6 1 n_subcarriers (u8)
|
||||
//! 7 1 (reserved)
|
||||
//! 8 2 freq_mhz (LE u16)
|
||||
//! 10 4 sequence (LE u32)
|
||||
//! 14 1 rssi (i8)
|
||||
//! 15 1 noise_floor (i8)
|
||||
//! 16 4 (reserved / padding)
|
||||
//! 6 2 n_subcarriers (LE u16 — 256 for ESP32-C6 HE-SU frames, #1005)
|
||||
//! 8 4 freq_mhz (LE u32)
|
||||
//! 12 4 sequence (LE u32)
|
||||
//! 16 1 rssi (i8)
|
||||
//! 17 1 noise_floor (i8)
|
||||
//! 18 1 PPDU type (ADR-110: 0=HT/legacy, 1=HE-SU, 2=HE-MU, 3=HE-TB)
|
||||
//! 19 1 flags (ADR-110: bit0 bw40, bit4 time-sync valid)
|
||||
//! 20 2 × n_antennas × n_subcarriers IQ pairs: i_val (i8), q_val (i8)
|
||||
//!
|
||||
//! This parser mirrors `parse_esp32_frame` in
|
||||
//! `wifi-densepose-sensing-server/src/csi.rs` exactly (same magic, same layout).
|
||||
//! `wifi-densepose-sensing-server/src/csi.rs` (same magic, same layout).
|
||||
|
||||
use anyhow::{bail, Result};
|
||||
use clap::Args;
|
||||
@@ -261,11 +263,15 @@ pub(crate) fn parse_csi_packet(buf: &[u8], tier: &str) -> Option<CsiFrame> {
|
||||
|
||||
let node_id = buf[4];
|
||||
let n_antennas = buf[5] as usize;
|
||||
let n_subcarriers = buf[6] as usize;
|
||||
let freq_mhz = u16::from_le_bytes([buf[8], buf[9]]);
|
||||
let _sequence = u32::from_le_bytes([buf[10], buf[11], buf[12], buf[13]]);
|
||||
let rssi = buf[14] as i8;
|
||||
let noise_floor = buf[15] as i8;
|
||||
// u16 since ADR-110 / #1005: ESP32-C6 HE-SU frames carry 256 bins
|
||||
// (the old single-byte read decoded 256 = 0x0100 LE as 0 subcarriers).
|
||||
let n_subcarriers = u16::from_le_bytes([buf[6], buf[7]]) as usize;
|
||||
let freq_mhz = u32::from_le_bytes([buf[8], buf[9], buf[10], buf[11]]);
|
||||
let freq_mhz = u16::try_from(freq_mhz).unwrap_or(0);
|
||||
let _sequence = u32::from_le_bytes([buf[12], buf[13], buf[14], buf[15]]);
|
||||
let rssi = buf[16] as i8;
|
||||
let noise_floor = buf[17] as i8;
|
||||
let _ppdu_type = buf[18]; // ADR-110; baseline tier gating is by count
|
||||
|
||||
let n_pairs = n_antennas * n_subcarriers;
|
||||
let iq_start = 20usize;
|
||||
@@ -414,24 +420,53 @@ mod tests {
|
||||
assert!(parse_csi_packet(&buf, "ht20").is_none());
|
||||
}
|
||||
|
||||
/// Build an ADR-018 frame (correct firmware layout, ADR-110 bytes 18-19).
|
||||
fn build_frame(n_subcarriers: u16, ppdu: u8) -> Vec<u8> {
|
||||
let mut buf = vec![0u8; 20 + n_subcarriers as usize * 2];
|
||||
buf[0..4].copy_from_slice(&0xC511_0001u32.to_le_bytes());
|
||||
buf[4] = 12; // node_id
|
||||
buf[5] = 1; // n_antennas
|
||||
buf[6..8].copy_from_slice(&n_subcarriers.to_le_bytes());
|
||||
buf[8..12].copy_from_slice(&2432u32.to_le_bytes()); // freq_mhz
|
||||
buf[12..16].copy_from_slice(&11610u32.to_le_bytes()); // sequence
|
||||
buf[16] = (-40i8) as u8; // rssi
|
||||
buf[17] = (-87i8) as u8; // noise floor
|
||||
buf[18] = ppdu;
|
||||
buf[19] = 0x10; // time-sync valid
|
||||
for k in 0..n_subcarriers as usize {
|
||||
buf[20 + k * 2] = (10 + (k % 100) as i8) as u8;
|
||||
buf[20 + k * 2 + 1] = (k % 50) as u8;
|
||||
}
|
||||
buf
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_csi_packet_valid() {
|
||||
let mut buf = vec![0u8; 24]; // 20-byte header + 2 IQ pairs (1 antenna, 2 subcarriers)
|
||||
// Magic 0xC511_0001 LE
|
||||
buf[0] = 0x01; buf[1] = 0x00; buf[2] = 0x11; buf[3] = 0xC5;
|
||||
buf[5] = 1; // n_antennas
|
||||
buf[6] = 2; // n_subcarriers
|
||||
// freq_mhz = 2437 (channel 6)
|
||||
buf[8] = 0x85; buf[9] = 0x09;
|
||||
// IQ pairs at offset 20: (10, 20), (−5, 15)
|
||||
buf[20] = 10i8 as u8; buf[21] = 20i8 as u8;
|
||||
buf[22] = (-5i8) as u8; buf[23] = 15i8 as u8;
|
||||
|
||||
let buf = build_frame(2, 0);
|
||||
let frame = parse_csi_packet(&buf, "ht20");
|
||||
assert!(frame.is_some());
|
||||
let f = frame.unwrap();
|
||||
assert_eq!(f.num_spatial_streams(), 1);
|
||||
assert_eq!(f.num_subcarriers(), 2);
|
||||
assert_eq!(f.metadata.rssi_dbm, -40);
|
||||
assert_eq!(f.metadata.noise_floor_dbm, -87);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_csi_packet_he_su_256_bins() {
|
||||
// ESP32-C6 HE-SU frame (issue #1005): n_subcarriers = 256 = 0x0100 LE.
|
||||
// The pre-#1005 single-byte read decoded this as 0 subcarriers.
|
||||
let buf = build_frame(256, 1);
|
||||
assert_eq!(buf.len(), 532); // matches the live wire size
|
||||
let f = parse_csi_packet(&buf, "he20").expect("256-bin HE frame must parse");
|
||||
assert_eq!(f.num_subcarriers(), 256);
|
||||
assert_eq!(f.metadata.rssi_dbm, -40);
|
||||
// A 256-bin frame is accepted by the he20 recorder (num_subcarriers
|
||||
// tier total) and rejected by ht20 (52/64) — no HT/HE mixing.
|
||||
let mut he = wifi_densepose_signal::CalibrationRecorder::new(tier_config("he20"));
|
||||
assert!(he.record(&f).is_ok());
|
||||
let mut ht = wifi_densepose_signal::CalibrationRecorder::new(tier_config("ht20"));
|
||||
assert!(ht.record(&f).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -563,6 +563,12 @@ impl crate::traits::CanonicalFrame for CsiFrame {
|
||||
/// (each fixed-width LE; `device_id` length-prefixed; `calibration_id` as
|
||||
/// 16 UUID bytes or 16 zero bytes for `None`) ‖ `(nrows, ncols)` as u32 LE
|
||||
/// ‖ complex payload as `ComplexSample::to_le_bytes()` in stream-major order.
|
||||
///
|
||||
/// # Panics
|
||||
/// If `calibration_id` is `Some(Uuid::nil())`: the nil UUID is the wire
|
||||
/// sentinel for `None`, so encoding it would alias two distinct frames to
|
||||
/// the same bytes (and the same witness hash) — a non-injective encoding
|
||||
/// is refused rather than silently produced.
|
||||
fn to_canonical_bytes(&self) -> Vec<u8> {
|
||||
let m = &self.metadata;
|
||||
// 16 (id) + ~48 (meta) + 8 (shape) + 16 * n_samples
|
||||
@@ -600,7 +606,17 @@ impl crate::traits::CanonicalFrame for CsiFrame {
|
||||
b.extend_from_slice(&m.noise_floor_dbm.to_le_bytes());
|
||||
b.extend_from_slice(&m.sequence_number.to_le_bytes());
|
||||
match m.calibration_id {
|
||||
Some(id) => b.extend_from_slice(id.as_bytes()),
|
||||
Some(id) => {
|
||||
// Some(nil) would alias the None sentinel on the wire: the
|
||||
// bytes would decode to a *different* frame (calibration_id
|
||||
// None) with the same witness. Refuse the non-injective
|
||||
// encoding (see the trait-impl `# Panics` doc).
|
||||
assert!(
|
||||
id != Uuid::nil(),
|
||||
"calibration_id Some(Uuid::nil()) is unencodable: nil is the None sentinel"
|
||||
);
|
||||
b.extend_from_slice(id.as_bytes());
|
||||
}
|
||||
None => b.extend_from_slice(&[0u8; 16]),
|
||||
}
|
||||
b.extend_from_slice(&m.model_id.to_le_bytes());
|
||||
@@ -616,6 +632,205 @@ impl crate::traits::CanonicalFrame for CsiFrame {
|
||||
}
|
||||
}
|
||||
|
||||
/// Errors decoding a frame from its canonical bytes.
|
||||
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
|
||||
pub enum CanonicalDecodeError {
|
||||
/// The buffer ended before the layout was fully read.
|
||||
#[error("canonical buffer truncated at byte {at} (need {need} more)")]
|
||||
Truncated {
|
||||
/// Byte offset where reading failed.
|
||||
at: usize,
|
||||
/// How many more bytes were needed.
|
||||
need: usize,
|
||||
},
|
||||
/// A discriminant byte held an unknown value.
|
||||
#[error("invalid {field} discriminant {value}")]
|
||||
BadDiscriminant {
|
||||
/// Which field failed.
|
||||
field: &'static str,
|
||||
/// The offending byte.
|
||||
value: u8,
|
||||
},
|
||||
/// The device-id bytes were not UTF-8.
|
||||
#[error("device id is not valid UTF-8")]
|
||||
BadDeviceId,
|
||||
/// Shape (nrows × ncols) disagrees with the remaining payload length.
|
||||
#[error("payload length mismatch: shape {rows}x{cols} needs {expect} bytes, found {found}")]
|
||||
PayloadMismatch {
|
||||
/// Declared rows.
|
||||
rows: usize,
|
||||
/// Declared cols.
|
||||
cols: usize,
|
||||
/// Bytes the shape implies.
|
||||
expect: usize,
|
||||
/// Bytes actually present.
|
||||
found: usize,
|
||||
},
|
||||
/// Trailing bytes after the declared payload.
|
||||
#[error("{0} trailing bytes after payload")]
|
||||
TrailingBytes(usize),
|
||||
/// A reserved region that must be all-zero held nonzero bytes. Accepting
|
||||
/// them would let two distinct byte strings decode to the same frame
|
||||
/// (re-encoding could not reproduce the original — forged bytes would be
|
||||
/// indistinguishable after a replay round-trip).
|
||||
#[error("reserved bytes for {field} must be zero")]
|
||||
ReservedNotZero {
|
||||
/// Which field's reserved region was nonzero.
|
||||
field: &'static str,
|
||||
},
|
||||
}
|
||||
|
||||
/// Byte cursor for the canonical layout.
|
||||
struct Cursor<'a> {
|
||||
b: &'a [u8],
|
||||
at: usize,
|
||||
}
|
||||
|
||||
impl<'a> Cursor<'a> {
|
||||
fn take(&mut self, n: usize) -> Result<&'a [u8], CanonicalDecodeError> {
|
||||
if self.b.len() - self.at < n {
|
||||
return Err(CanonicalDecodeError::Truncated {
|
||||
at: self.at,
|
||||
need: n - (self.b.len() - self.at),
|
||||
});
|
||||
}
|
||||
let s = &self.b[self.at..self.at + n];
|
||||
self.at += n;
|
||||
Ok(s)
|
||||
}
|
||||
fn u8(&mut self) -> Result<u8, CanonicalDecodeError> {
|
||||
Ok(self.take(1)?[0])
|
||||
}
|
||||
fn u16(&mut self) -> Result<u16, CanonicalDecodeError> {
|
||||
Ok(u16::from_le_bytes(self.take(2)?.try_into().unwrap()))
|
||||
}
|
||||
fn u32(&mut self) -> Result<u32, CanonicalDecodeError> {
|
||||
Ok(u32::from_le_bytes(self.take(4)?.try_into().unwrap()))
|
||||
}
|
||||
fn i64(&mut self) -> Result<i64, CanonicalDecodeError> {
|
||||
Ok(i64::from_le_bytes(self.take(8)?.try_into().unwrap()))
|
||||
}
|
||||
fn f32(&mut self) -> Result<f32, CanonicalDecodeError> {
|
||||
Ok(f32::from_le_bytes(self.take(4)?.try_into().unwrap()))
|
||||
}
|
||||
fn i8(&mut self) -> Result<i8, CanonicalDecodeError> {
|
||||
Ok(self.take(1)?[0] as i8)
|
||||
}
|
||||
fn uuid(&mut self) -> Result<Uuid, CanonicalDecodeError> {
|
||||
Ok(Uuid::from_bytes(self.take(16)?.try_into().unwrap()))
|
||||
}
|
||||
}
|
||||
|
||||
impl CsiFrame {
|
||||
/// Reconstruct a frame from its [`to_canonical_bytes`] encoding — the
|
||||
/// replay half of the ADR-136 contract. Round-trip law (tested):
|
||||
/// `from_canonical_bytes(f.to_canonical_bytes())` yields a frame with the
|
||||
/// **same id, metadata, payload, and witness hash** as `f`.
|
||||
///
|
||||
/// Amplitude/phase are recomputed from the complex payload (they are
|
||||
/// projections, not independent state).
|
||||
///
|
||||
/// [`to_canonical_bytes`]: crate::traits::CanonicalFrame::to_canonical_bytes
|
||||
///
|
||||
/// # Errors
|
||||
/// [`CanonicalDecodeError`] on truncation, bad discriminants, non-UTF-8
|
||||
/// device id, nonzero reserved bytes, shape/payload disagreement, or
|
||||
/// trailing bytes — every malformed input fails closed. Strictness
|
||||
/// guarantees injectivity on the accepted domain: any accepted byte
|
||||
/// string re-encodes to exactly itself.
|
||||
pub fn from_canonical_bytes(bytes: &[u8]) -> Result<Self, CanonicalDecodeError> {
|
||||
let mut c = Cursor { b: bytes, at: 0 };
|
||||
|
||||
let id = FrameId::from_uuid(c.uuid()?);
|
||||
|
||||
let seconds = c.i64()?;
|
||||
let nanos = c.u32()?;
|
||||
let dev_len = c.u32()? as usize;
|
||||
let device_id = core::str::from_utf8(c.take(dev_len)?)
|
||||
.map_err(|_| CanonicalDecodeError::BadDeviceId)?
|
||||
.to_string();
|
||||
let frequency_band = match c.u8()? {
|
||||
0 => FrequencyBand::Band2_4GHz,
|
||||
1 => FrequencyBand::Band5GHz,
|
||||
2 => FrequencyBand::Band6GHz,
|
||||
v => {
|
||||
return Err(CanonicalDecodeError::BadDiscriminant {
|
||||
field: "frequency_band",
|
||||
value: v,
|
||||
})
|
||||
}
|
||||
};
|
||||
let channel = c.u8()?;
|
||||
let bandwidth_mhz = c.u16()?;
|
||||
let tx_antennas = c.u8()?;
|
||||
let rx_antennas = c.u8()?;
|
||||
let spacing_mm = match c.u8()? {
|
||||
1 => Some(c.f32()?),
|
||||
0 => {
|
||||
// Reserved padding must be zero (decoder strictness =
|
||||
// injectivity on the accepted domain): otherwise forged
|
||||
// nonzero padding would decode to the same frame as the
|
||||
// canonical encoding and re-encode differently.
|
||||
if c.take(4)? != [0u8; 4] {
|
||||
return Err(CanonicalDecodeError::ReservedNotZero { field: "spacing_mm" });
|
||||
}
|
||||
None
|
||||
}
|
||||
v => {
|
||||
return Err(CanonicalDecodeError::BadDiscriminant {
|
||||
field: "spacing_mm",
|
||||
value: v,
|
||||
})
|
||||
}
|
||||
};
|
||||
let rssi_dbm = c.i8()?;
|
||||
let noise_floor_dbm = c.i8()?;
|
||||
let sequence_number = c.u32()?;
|
||||
let cal = c.uuid()?;
|
||||
let calibration_id = if cal == Uuid::nil() { None } else { Some(cal) };
|
||||
let model_id = c.u16()?;
|
||||
let model_version = c.u16()?;
|
||||
|
||||
let rows = c.u32()? as usize;
|
||||
let cols = c.u32()? as usize;
|
||||
let expect = rows.saturating_mul(cols).saturating_mul(16);
|
||||
let found = bytes.len() - c.at;
|
||||
if found < expect {
|
||||
return Err(CanonicalDecodeError::PayloadMismatch { rows, cols, expect, found });
|
||||
}
|
||||
let mut samples = Vec::with_capacity(rows * cols);
|
||||
for _ in 0..rows * cols {
|
||||
let raw: [u8; 16] = c.take(16)?.try_into().unwrap();
|
||||
samples.push(ComplexSample::from_le_bytes(raw).0);
|
||||
}
|
||||
if c.at != bytes.len() {
|
||||
return Err(CanonicalDecodeError::TrailingBytes(bytes.len() - c.at));
|
||||
}
|
||||
let data = Array2::from_shape_vec((rows, cols), samples).map_err(|_| {
|
||||
CanonicalDecodeError::PayloadMismatch { rows, cols, expect, found }
|
||||
})?;
|
||||
|
||||
let metadata = CsiMetadata {
|
||||
timestamp: Timestamp { seconds, nanos },
|
||||
device_id: DeviceId::new(device_id),
|
||||
frequency_band,
|
||||
channel,
|
||||
bandwidth_mhz,
|
||||
antenna_config: AntennaConfig { tx_antennas, rx_antennas, spacing_mm },
|
||||
rssi_dbm,
|
||||
noise_floor_dbm,
|
||||
sequence_number,
|
||||
calibration_id,
|
||||
model_id,
|
||||
model_version,
|
||||
};
|
||||
|
||||
let amplitude = data.mapv(num_complex::Complex::norm);
|
||||
let phase = data.mapv(num_complex::Complex::arg);
|
||||
Ok(Self { id, metadata, data, amplitude, phase })
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Signal Types
|
||||
// =============================================================================
|
||||
@@ -1307,6 +1522,133 @@ mod tests {
|
||||
assert_ne!(frame.witness_hash(), frame2.witness_hash());
|
||||
}
|
||||
|
||||
/// AC7 — replay: `from_canonical_bytes` is the exact inverse of
|
||||
/// `to_canonical_bytes` — same id, metadata, payload, and witness hash.
|
||||
/// This is the capture-to-claim law: a stored canonical capture replays to
|
||||
/// a frame the pipeline cannot distinguish from the original.
|
||||
#[test]
|
||||
fn ac7_canonical_round_trip_replays_identically() {
|
||||
use ndarray::Array2;
|
||||
let mut meta = CsiMetadata::new(DeviceId::new("node-α"), FrequencyBand::Band6GHz, 37);
|
||||
meta.set_calibration(uuid::Uuid::new_v4());
|
||||
meta.set_model(9, 0x0203);
|
||||
meta.antenna_config.spacing_mm = Some(62.5);
|
||||
meta.rssi_dbm = -41;
|
||||
meta.sequence_number = 123_456;
|
||||
let data = Array2::from_shape_fn((2, 56), |(r, c)| {
|
||||
Complex64::new((r as f64 + 1.0) * (c as f64).cos(), (c as f64 * 0.1).tan())
|
||||
});
|
||||
let frame = CsiFrame::new(meta, data);
|
||||
|
||||
let bytes = frame.to_canonical_bytes();
|
||||
let replayed = CsiFrame::from_canonical_bytes(&bytes).expect("decodes");
|
||||
|
||||
assert_eq!(replayed.id, frame.id);
|
||||
// Field-wise metadata equality (CsiMetadata has no PartialEq; the
|
||||
// byte-identical re-encoding below covers every field regardless).
|
||||
assert_eq!(replayed.metadata.device_id, frame.metadata.device_id);
|
||||
assert_eq!(replayed.metadata.calibration_id, frame.metadata.calibration_id);
|
||||
assert_eq!(replayed.metadata.model_version, frame.metadata.model_version);
|
||||
assert_eq!(replayed.metadata.antenna_config.spacing_mm, Some(62.5));
|
||||
assert_eq!(replayed.data, frame.data);
|
||||
// Witness equality — the strongest statement of equivalence.
|
||||
assert_eq!(replayed.witness_hash(), frame.witness_hash());
|
||||
// Re-encoding is byte-identical.
|
||||
assert_eq!(replayed.to_canonical_bytes(), bytes);
|
||||
// Projections recomputed consistently.
|
||||
assert_eq!(replayed.amplitude, frame.amplitude);
|
||||
}
|
||||
|
||||
/// AC8 — the decoder fails closed on every malformed-input class.
|
||||
#[test]
|
||||
fn ac8_canonical_decode_fails_closed() {
|
||||
use ndarray::Array2;
|
||||
let meta = CsiMetadata::new(DeviceId::new("n"), FrequencyBand::Band2_4GHz, 1);
|
||||
let data = Array2::from_shape_fn((1, 4), |(_, c)| Complex64::new(c as f64, 0.0));
|
||||
let frame = CsiFrame::new(meta, data);
|
||||
let bytes = frame.to_canonical_bytes();
|
||||
|
||||
// Truncation anywhere fails: in the payload it is caught by the
|
||||
// shape-vs-length check (PayloadMismatch); in the header by Truncated.
|
||||
assert!(matches!(
|
||||
CsiFrame::from_canonical_bytes(&bytes[..bytes.len() - 1]),
|
||||
Err(CanonicalDecodeError::PayloadMismatch { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
CsiFrame::from_canonical_bytes(&bytes[..10]),
|
||||
Err(CanonicalDecodeError::Truncated { .. })
|
||||
));
|
||||
|
||||
// Trailing junk fails.
|
||||
let mut padded = bytes.clone();
|
||||
padded.extend_from_slice(&[0u8; 3]);
|
||||
assert!(matches!(
|
||||
CsiFrame::from_canonical_bytes(&padded),
|
||||
Err(CanonicalDecodeError::TrailingBytes(3))
|
||||
));
|
||||
|
||||
// Bad frequency-band discriminant fails. Band byte sits right after
|
||||
// id(16) + seconds(8) + nanos(4) + dev_len(4) + dev("n" = 1).
|
||||
let mut bad = bytes.clone();
|
||||
bad[16 + 8 + 4 + 4 + 1] = 9;
|
||||
assert!(matches!(
|
||||
CsiFrame::from_canonical_bytes(&bad),
|
||||
Err(CanonicalDecodeError::BadDiscriminant { field: "frequency_band", value: 9 })
|
||||
));
|
||||
|
||||
// A nil calibration uuid decodes as None (the documented encoding).
|
||||
let replayed = CsiFrame::from_canonical_bytes(&bytes).unwrap();
|
||||
assert_eq!(replayed.metadata.calibration_id, None);
|
||||
}
|
||||
|
||||
/// AC8b (review finding 7) — decoder strictness = injectivity on the
|
||||
/// accepted domain: forged nonzero bytes in the `spacing_mm` reserved
|
||||
/// region are rejected, so for accepted inputs `re-encode != original`
|
||||
/// is impossible.
|
||||
#[test]
|
||||
fn ac8b_forged_reserved_spacing_bytes_rejected() {
|
||||
use ndarray::Array2;
|
||||
let meta = CsiMetadata::new(DeviceId::new("n"), FrequencyBand::Band2_4GHz, 1);
|
||||
let data = Array2::from_shape_fn((1, 4), |(_, c)| Complex64::new(c as f64, 0.0));
|
||||
let frame = CsiFrame::new(meta, data);
|
||||
let bytes = frame.to_canonical_bytes();
|
||||
|
||||
// Spacing tag sits after id(16)+secs(8)+nanos(4)+dev_len(4)+dev("n"=1)
|
||||
// + band(1)+channel(1)+bw(2)+tx(1)+rx(1); the 4 reserved bytes follow.
|
||||
let tag_off = 16 + 8 + 4 + 4 + 1 + 1 + 1 + 2 + 1 + 1;
|
||||
assert_eq!(bytes[tag_off], 0, "fixture must encode spacing_mm = None");
|
||||
assert_eq!(&bytes[tag_off + 1..tag_off + 5], &[0u8; 4]);
|
||||
|
||||
// Sanity: the canonical bytes decode and re-encode byte-identically.
|
||||
let ok = CsiFrame::from_canonical_bytes(&bytes).unwrap();
|
||||
assert_eq!(ok.to_canonical_bytes(), bytes);
|
||||
|
||||
// Forge each reserved byte: the decoder must fail closed (before the
|
||||
// fix it decoded to the same frame, whose re-encoding differed from
|
||||
// the forged original — a witness-replay ambiguity).
|
||||
for i in 1..=4 {
|
||||
let mut forged = bytes.clone();
|
||||
forged[tag_off + i] = 0xAB;
|
||||
assert!(matches!(
|
||||
CsiFrame::from_canonical_bytes(&forged),
|
||||
Err(CanonicalDecodeError::ReservedNotZero { field: "spacing_mm" })
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// AC8c (review finding 7) — `Some(Uuid::nil())` calibration is an
|
||||
/// encoding error: nil is the wire sentinel for `None`, so encoding it
|
||||
/// would alias two distinct frames to one byte string (and one witness).
|
||||
#[test]
|
||||
#[should_panic(expected = "nil is the None sentinel")]
|
||||
fn ac8c_nil_calibration_id_is_an_encoding_error() {
|
||||
use ndarray::Array2;
|
||||
let mut meta = CsiMetadata::new(DeviceId::new("n"), FrequencyBand::Band2_4GHz, 1);
|
||||
meta.calibration_id = Some(uuid::Uuid::nil());
|
||||
let data = Array2::from_shape_fn((1, 2), |(_, c)| Complex64::new(c as f64, 0.0));
|
||||
let _ = CsiFrame::new(meta, data).to_canonical_bytes();
|
||||
}
|
||||
|
||||
/// AC3 — `serde(default)` forward-read of pre-ADR-136 metadata JSON.
|
||||
#[cfg(feature = "serde")]
|
||||
#[test]
|
||||
|
||||
@@ -19,6 +19,9 @@ wifi-densepose-worldgraph = { version = "0.3.0", path = "../wifi-densepose-world
|
||||
wifi-densepose-geo = { version = "0.1.0", path = "../wifi-densepose-geo" }
|
||||
# Deterministic witness over the trust decision (ADR-137 §2.7 / ADR-028).
|
||||
blake3 = { version = "1.5", default-features = false }
|
||||
# Dynamic min-cut over the live mesh coupling graph (mesh_guard.rs):
|
||||
# incremental partition-risk monitoring + structural recalibration trigger.
|
||||
ruvector-mincut = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
criterion = { version = "0.5", features = ["html_reports"] }
|
||||
|
||||
@@ -48,5 +48,41 @@ fn bench_cycle(c: &mut Criterion) {
|
||||
});
|
||||
}
|
||||
|
||||
criterion_group!(benches, bench_cycle);
|
||||
/// Mesh guard in isolation: cold build (node set appears) vs steady state
|
||||
/// (identical weights next cycle → change-gated, zero graph updates) for a
|
||||
/// 12-node mesh — the full ADR-029 deployment size.
|
||||
fn bench_mesh_guard(c: &mut Criterion) {
|
||||
use wifi_densepose_engine::MeshGuard;
|
||||
let nodes: Vec<u8> = (0..12).collect();
|
||||
let w = |i: usize, j: usize| 0.4 + 0.01 * ((i + j) % 7) as f64;
|
||||
|
||||
c.bench_function("mesh_guard_cold_build_12n", |b| {
|
||||
b.iter_batched(
|
||||
MeshGuard::default,
|
||||
|mut g| g.update(&nodes, w),
|
||||
BatchSize::SmallInput,
|
||||
);
|
||||
});
|
||||
|
||||
c.bench_function("mesh_guard_steady_state_12n", |b| {
|
||||
let mut g = MeshGuard::default();
|
||||
g.update(&nodes, w); // warm
|
||||
b.iter(|| g.update(&nodes, w));
|
||||
});
|
||||
|
||||
c.bench_function("mesh_guard_one_edge_change_12n", |b| {
|
||||
let mut g = MeshGuard::default();
|
||||
g.update(&nodes, w);
|
||||
let mut flip = false;
|
||||
b.iter(|| {
|
||||
flip = !flip;
|
||||
let delta = if flip { 0.2 } else { 0.0 };
|
||||
g.update(&nodes, |i, j| {
|
||||
if (i.min(j), i.max(j)) == (0, 1) { 0.4 + delta } else { w(i, j) }
|
||||
})
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
criterion_group!(benches, bench_cycle, bench_mesh_guard);
|
||||
criterion_main!(benches);
|
||||
|
||||
@@ -46,6 +46,9 @@ use wifi_densepose_worldgraph::{
|
||||
WorldId, WorldNode, ZoneBoundsEnu,
|
||||
};
|
||||
|
||||
pub mod mesh_guard;
|
||||
pub use mesh_guard::{MeshGuard, MeshPartitionReport};
|
||||
|
||||
/// Errors from an engine cycle.
|
||||
#[derive(Debug)]
|
||||
pub enum EngineError {
|
||||
@@ -97,6 +100,15 @@ pub struct TrustedOutput {
|
||||
/// BLAKE3 witness over the trust decision (provenance ‖ class ‖ calibration)
|
||||
/// — a deterministic, signed-belief fingerprint (ADR-137 §2.7 / ADR-028).
|
||||
pub witness: [u8; 32],
|
||||
/// Whether the drift→recalibration advisor recommends re-running the
|
||||
/// ADR-135 baseline / refitting the per-room adapter (ADR-150 §3.4):
|
||||
/// sustained low coherence or an ADR-142 change-point this cycle.
|
||||
pub recalibration_recommended: bool,
|
||||
/// Dynamic min-cut partition report over the live mesh coupling graph
|
||||
/// (None for meshes of fewer than two nodes). `at_risk` counts as a
|
||||
/// structural event for the recalibration advisor and names the nodes
|
||||
/// (`weak_side`) closest to splitting off — failure/jamming triage.
|
||||
pub mesh: Option<MeshPartitionReport>,
|
||||
}
|
||||
|
||||
/// Composition root for the RuView streaming engine.
|
||||
@@ -116,6 +128,74 @@ pub struct StreamingEngine {
|
||||
slam: RfSlam,
|
||||
// ADR-139 live loop: stable track_id -> PersonTrack WorldId.
|
||||
person_tracks: BTreeMap<u64, WorldId>,
|
||||
// WorldGraph belief retention: max live SemanticState nodes. The live loop
|
||||
// appends one belief per cycle (1.7M/day at 20 Hz); durable history is the
|
||||
// recorder's job, so old beliefs are evicted deterministically past this cap.
|
||||
semantic_retention: usize,
|
||||
// Per-room calibration adapter (ADR-150 §3.4: ~11 KB LoRA on a frozen
|
||||
// base). Identity is part of the trust chain: when set, the adapter id is
|
||||
// appended to the provenance model_version, so swapping adapters changes
|
||||
// the witness. None = shared base model.
|
||||
adapter: Option<AdapterInfo>,
|
||||
// Drift→recalibration advisor (ADR-135 trigger for ADR-150 §3.4 refit).
|
||||
recal: RecalibrationAdvisor,
|
||||
// Dynamic min-cut mesh partition guard (incremental, change-gated).
|
||||
mesh: MeshGuard,
|
||||
}
|
||||
|
||||
/// Identity of an active per-room calibration adapter (ADR-150 §3.4). The id
|
||||
/// must be content-derived (e.g. a hash prefix of the adapter file) so the
|
||||
/// provenance/witness chain pins the exact weights that shaped inference.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AdapterInfo {
|
||||
/// Content-derived adapter identity (e.g. first 16 hex of its SHA-256).
|
||||
pub adapter_id: String,
|
||||
/// Number of in-room samples the adapter was fitted on (0 if unknown).
|
||||
pub trained_samples: u32,
|
||||
}
|
||||
|
||||
/// Recommends re-running calibration / adapter refit when the live signal
|
||||
/// degrades persistently (ADR-135 drift → ADR-150 §3.4 few-shot recalibration).
|
||||
///
|
||||
/// Two triggers, both cheap and deterministic:
|
||||
/// - `low_coherence_streak`: N consecutive cycles whose base coherence fell
|
||||
/// below the floor (sustained degradation, not a single bad frame);
|
||||
/// - any ADR-142 change-point this cycle (the environment itself changed).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RecalibrationAdvisor {
|
||||
/// Coherence below this counts toward the streak.
|
||||
pub coherence_floor: f32,
|
||||
/// Consecutive low-coherence cycles required to recommend recalibration.
|
||||
pub streak_threshold: u32,
|
||||
streak: u32,
|
||||
}
|
||||
|
||||
impl Default for RecalibrationAdvisor {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
coherence_floor: 0.5,
|
||||
streak_threshold: 60, // ~3 s at 20 Hz of sustained degradation
|
||||
streak: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RecalibrationAdvisor {
|
||||
/// Feed one cycle's evidence; returns whether recalibration is recommended.
|
||||
fn observe(&mut self, base_coherence: f32, change_point: bool) -> bool {
|
||||
if base_coherence < self.coherence_floor {
|
||||
self.streak = self.streak.saturating_add(1);
|
||||
} else {
|
||||
self.streak = 0;
|
||||
}
|
||||
change_point || self.streak >= self.streak_threshold
|
||||
}
|
||||
|
||||
/// Current consecutive low-coherence cycle count.
|
||||
#[must_use]
|
||||
pub fn streak(&self) -> u32 {
|
||||
self.streak
|
||||
}
|
||||
}
|
||||
|
||||
impl StreamingEngine {
|
||||
@@ -135,9 +215,53 @@ impl StreamingEngine {
|
||||
evolution: None,
|
||||
slam: RfSlam::with_discovery(0.5, 5, 0.6),
|
||||
person_tracks: BTreeMap::new(),
|
||||
semantic_retention: Self::DEFAULT_SEMANTIC_RETENTION,
|
||||
adapter: None,
|
||||
recal: RecalibrationAdvisor::default(),
|
||||
mesh: MeshGuard::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Activate a per-room calibration adapter (ADR-150 §3.4). From the next
|
||||
/// cycle on, the adapter id is part of provenance `model_version` — and
|
||||
/// therefore of the witness — so the exact weights shaping inference are
|
||||
/// pinned in the trust chain. Pass the result of hashing the adapter file.
|
||||
pub fn set_room_adapter(&mut self, info: AdapterInfo) {
|
||||
self.adapter = Some(info);
|
||||
}
|
||||
|
||||
/// Deactivate the adapter (revert to the shared base model).
|
||||
pub fn clear_room_adapter(&mut self) {
|
||||
self.adapter = None;
|
||||
}
|
||||
|
||||
/// The active adapter, if any.
|
||||
#[must_use]
|
||||
pub fn room_adapter(&self) -> Option<&AdapterInfo> {
|
||||
self.adapter.as_ref()
|
||||
}
|
||||
|
||||
/// Tune the drift→recalibration advisor (floor + streak threshold).
|
||||
pub fn set_recalibration_advisor(&mut self, advisor: RecalibrationAdvisor) {
|
||||
self.recal = advisor;
|
||||
}
|
||||
|
||||
/// Mutable access to the mesh partition guard (risk threshold, quantum,
|
||||
/// min-node count). Operators tune the partition-risk sensitivity here.
|
||||
pub fn mesh_guard_mut(&mut self) -> &mut MeshGuard {
|
||||
&mut self.mesh
|
||||
}
|
||||
|
||||
/// Default cap on live `SemanticState` beliefs in the WorldGraph
|
||||
/// (~6 minutes of full-rate history at 20 Hz; older beliefs are evicted —
|
||||
/// durable history belongs to the recorder).
|
||||
pub const DEFAULT_SEMANTIC_RETENTION: usize = 7_200;
|
||||
|
||||
/// Override the `SemanticState` retention cap (minimum 1).
|
||||
pub fn set_semantic_retention(&mut self, max_states: usize) {
|
||||
self.semantic_retention = max_states.max(1);
|
||||
}
|
||||
|
||||
/// ADR-139 live loop: create or update a `PersonTrack` node by stable
|
||||
/// `track_id`, locate it in `room`, and wire an `Observes` edge from
|
||||
/// `sensor` (so the privacy rollup can suppress it under identity-strict
|
||||
@@ -321,21 +445,47 @@ impl StreamingEngine {
|
||||
// 4. Evolution change-point (ADR-142) over per-node mean amplitude.
|
||||
let change_point = self.track_evolution(node_frames, now_ms, room);
|
||||
|
||||
// 5. Privacy control plane (ADR-141): demote on a fusion-level OR an
|
||||
// array-level contradiction (monotonic — information only removed).
|
||||
// 5. Mesh partition guard (ADR-032): dynamic min-cut over the coupling
|
||||
// graph. Coupling between nodes i and j is the product of their
|
||||
// fusion attention weights scaled by the node count, so a node the
|
||||
// fuser down-weights is exactly a node weakly coupled in the graph.
|
||||
// (Change-gated incremental updates: steady state touches 0 edges.)
|
||||
let node_ids: Vec<u8> = node_frames.iter().map(|f| f.node_id).collect();
|
||||
let weights = &quality.per_node_weights;
|
||||
let n = weights.len() as f64;
|
||||
let mesh = self.mesh.update(&node_ids, |i, j| {
|
||||
let wi = weights.get(i).copied().unwrap_or(0.0) as f64;
|
||||
let wj = weights.get(j).copied().unwrap_or(0.0) as f64;
|
||||
wi * wj * n
|
||||
});
|
||||
let mesh_at_risk = mesh.as_ref().is_some_and(|m| m.at_risk);
|
||||
|
||||
// 6. Privacy control plane (ADR-141): demote on a fusion-level OR an
|
||||
// array-level contradiction OR a mesh close to partitioning. The
|
||||
// last is a security/reliability signal (ADR-032): a fragmenting
|
||||
// array makes the fused belief less trustworthy, so we emit at a
|
||||
// more restricted class. Monotonic — information is only ever
|
||||
// removed — and the demotion is part of the witness.
|
||||
let base_class = self.privacy.active_class();
|
||||
let demoted = quality.forces_privacy_demotion() || array_contradiction;
|
||||
let demoted = quality.forces_privacy_demotion() || array_contradiction || mesh_at_risk;
|
||||
let effective_class = if demoted { demote_one(base_class) } else { base_class };
|
||||
|
||||
// 6. Semantic state with mandatory provenance (ADR-139/140). The
|
||||
// 7. Semantic state with mandatory provenance (ADR-139/140). The
|
||||
// calibration version comes from the *agreed* epoch (None on mismatch).
|
||||
// When a per-room adapter is active (ADR-150 §3.4) its content-derived
|
||||
// id is part of model_version — and therefore of the witness — so the
|
||||
// exact weights shaping inference are pinned in the trust chain.
|
||||
let calibration_version = match quality.calibration_id {
|
||||
Some(c) => format!("cal:{:016x}", c.0),
|
||||
None => "cal:none".to_string(),
|
||||
};
|
||||
let model_version = match &self.adapter {
|
||||
Some(a) => format!("rfenc-v{}+adapter:{}", self.model_version, a.adapter_id),
|
||||
None => format!("rfenc-v{}", self.model_version),
|
||||
};
|
||||
let provenance = SemanticProvenance {
|
||||
evidence: quality.evidence_refs.iter().map(|e| format!("{e:?}")).collect(),
|
||||
model_version: format!("rfenc-v{}", self.model_version),
|
||||
model_version,
|
||||
calibration_version,
|
||||
privacy_decision: format!("{:?}/{:?}", self.privacy.active_mode(), effective_class),
|
||||
};
|
||||
@@ -350,10 +500,23 @@ impl StreamingEngine {
|
||||
provenance.clone(),
|
||||
&[room],
|
||||
);
|
||||
// Retention: bound the live belief set (one node is appended per cycle;
|
||||
// without this the graph grows ~1.7M nodes/day at 20 Hz). Deterministic
|
||||
// eviction; the just-added belief is always newest and survives.
|
||||
self.world.prune_semantic_states(self.semantic_retention);
|
||||
|
||||
// 7. Deterministic witness over the trust decision (ADR-137 §2.7).
|
||||
// 8. Deterministic witness over the trust decision (ADR-137 §2.7).
|
||||
// `effective_class` already reflects any mesh-risk demotion, so a
|
||||
// fragmenting array shifts the witness — partition risk is auditable.
|
||||
let witness = witness_of(&provenance, effective_class);
|
||||
|
||||
// 9. Drift→recalibration advisor (ADR-135 → ADR-150 §3.4): sustained
|
||||
// low coherence, an environment change-point, or a mesh close to
|
||||
// partitioning recommends refit.
|
||||
let recalibration_recommended = self
|
||||
.recal
|
||||
.observe(quality.base_coherence, change_point.is_some() || mesh_at_risk);
|
||||
|
||||
self.cycle += 1;
|
||||
Ok(TrustedOutput {
|
||||
semantic_id,
|
||||
@@ -364,6 +527,8 @@ impl StreamingEngine {
|
||||
directional,
|
||||
change_point,
|
||||
witness,
|
||||
recalibration_recommended,
|
||||
mesh,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -547,6 +712,205 @@ mod tests {
|
||||
assert_eq!(o1.quality.per_node_weights, o2.quality.per_node_weights);
|
||||
}
|
||||
|
||||
/// ADR-150 §3.4 adapter provenance: activating a per-room adapter changes
|
||||
/// the provenance model_version AND the witness — the exact weights shaping
|
||||
/// inference are pinned in the trust chain, so an adapter can never swap
|
||||
/// silently. Clearing it restores the base identity (and base witness).
|
||||
#[test]
|
||||
fn adapter_identity_is_witnessed() {
|
||||
let cal = CalibrationId(9);
|
||||
let frames = [node_frame(0, 1000, 56), node_frame(1, 1001, 56)];
|
||||
|
||||
let (mut e, room) = engine();
|
||||
let base = e.process_cycle(&frames, cal, room, 1_000).unwrap();
|
||||
assert_eq!(base.provenance.model_version, "rfenc-v1");
|
||||
|
||||
e.set_room_adapter(AdapterInfo {
|
||||
adapter_id: "a1b2c3d4e5f60718".into(),
|
||||
trained_samples: 150,
|
||||
});
|
||||
let adapted = e.process_cycle(&frames, cal, room, 2_000).unwrap();
|
||||
assert_eq!(
|
||||
adapted.provenance.model_version,
|
||||
"rfenc-v1+adapter:a1b2c3d4e5f60718"
|
||||
);
|
||||
assert_ne!(adapted.witness, base.witness, "adapter must shift the witness");
|
||||
|
||||
// A different adapter id yields a different witness again.
|
||||
e.set_room_adapter(AdapterInfo {
|
||||
adapter_id: "ffffffffffffffff".into(),
|
||||
trained_samples: 150,
|
||||
});
|
||||
let other = e.process_cycle(&frames, cal, room, 3_000).unwrap();
|
||||
assert_ne!(other.witness, adapted.witness);
|
||||
|
||||
// Clearing restores the base identity and the base witness.
|
||||
e.clear_room_adapter();
|
||||
let back = e.process_cycle(&frames, cal, room, 4_000).unwrap();
|
||||
assert_eq!(back.provenance.model_version, "rfenc-v1");
|
||||
assert_eq!(back.witness, base.witness);
|
||||
}
|
||||
|
||||
/// Drift→recalibration advisor logic: a sustained low-coherence streak
|
||||
/// recommends refit; a single healthy cycle resets the streak; a
|
||||
/// change-point recommends immediately regardless of streak.
|
||||
#[test]
|
||||
fn recalibration_advisor_streak_and_change_point() {
|
||||
let mut adv = RecalibrationAdvisor {
|
||||
coherence_floor: 0.5,
|
||||
streak_threshold: 3,
|
||||
..Default::default()
|
||||
};
|
||||
// Healthy cycles never recommend and keep the streak at zero.
|
||||
for _ in 0..5 {
|
||||
assert!(!adv.observe(0.9, false));
|
||||
}
|
||||
assert_eq!(adv.streak(), 0);
|
||||
// Two low cycles: not yet.
|
||||
assert!(!adv.observe(0.2, false));
|
||||
assert!(!adv.observe(0.2, false));
|
||||
// Third consecutive low cycle: fire.
|
||||
assert!(adv.observe(0.2, false));
|
||||
// Recovery resets the streak.
|
||||
assert!(!adv.observe(0.9, false));
|
||||
assert_eq!(adv.streak(), 0);
|
||||
// A change-point recommends immediately, even at full coherence.
|
||||
assert!(adv.observe(0.9, true));
|
||||
}
|
||||
|
||||
/// Engine-level: clean coherent cycles never recommend recalibration (the
|
||||
/// advisor is wired into process_cycle and stays quiet on healthy input).
|
||||
#[test]
|
||||
fn healthy_cycles_do_not_recommend_recalibration() {
|
||||
let (mut e, room) = engine();
|
||||
e.set_recalibration_advisor(RecalibrationAdvisor {
|
||||
coherence_floor: 0.5,
|
||||
streak_threshold: 3,
|
||||
..Default::default()
|
||||
});
|
||||
let cal = CalibrationId(2);
|
||||
for i in 0..5u64 {
|
||||
let frames = [
|
||||
node_frame(0, 1_000 + i * 50_000, 56),
|
||||
node_frame(1, 1_001 + i * 50_000, 56),
|
||||
];
|
||||
let out = e.process_cycle(&frames, cal, room, i as i64).unwrap();
|
||||
assert!(!out.recalibration_recommended);
|
||||
}
|
||||
}
|
||||
|
||||
/// Maximum total coupling mass of an n-node mesh whose attention weights
|
||||
/// sum to 1 (coupling = wᵢ·wⱼ·n): Σ_{i<j} wᵢwⱼ·n = n(1−Σwᵢ²)/2 ≤ (n−1)/2.
|
||||
/// Any cut is a subset of the edges, so every achievable cut value is
|
||||
/// bounded by this mass — a risk threshold at or above it is *guaranteed*
|
||||
/// to be crossed (deterministic fixture, review finding 4).
|
||||
fn max_coupling_mass(n_nodes: usize) -> f64 {
|
||||
(n_nodes as f64 - 1.0) / 2.0
|
||||
}
|
||||
|
||||
/// Mesh guard wiring: a balanced 2-node cycle reports a mesh (cut exists)
|
||||
/// but never flags risk (min_nodes=3); a 3-node mesh whose cut value
|
||||
/// *deterministically* falls at or below the configured risk threshold
|
||||
/// (threshold = the provable upper bound on any achievable cut) is flagged
|
||||
/// at_risk, and the structural event feeds the recalibration advisor
|
||||
/// immediately — no conditional assertions (review finding 4).
|
||||
#[test]
|
||||
fn mesh_partition_risk_feeds_recalibration() {
|
||||
let (mut e, room) = engine();
|
||||
let cal = CalibrationId(3);
|
||||
|
||||
// Balanced 2-node mesh: report present, no risk.
|
||||
let out = e
|
||||
.process_cycle(&[node_frame(0, 1000, 56), node_frame(1, 1001, 56)], cal, room, 1)
|
||||
.unwrap();
|
||||
let mesh = out.mesh.expect("2-node mesh reports");
|
||||
assert!(!mesh.at_risk);
|
||||
assert!(!out.recalibration_recommended);
|
||||
|
||||
// 3-node mesh with the operator risk threshold set to the provable
|
||||
// cut upper bound: the crossing is deterministic regardless of the
|
||||
// fuser's exact weighting.
|
||||
e.mesh_guard_mut().risk_threshold = max_coupling_mass(3);
|
||||
let frames = [
|
||||
node_frame(0, 10_000_000, 56),
|
||||
node_frame(1, 10_000_001, 56),
|
||||
node_frame(2, 10_000_002, 56),
|
||||
];
|
||||
let out3 = e.process_cycle(&frames, cal, room, 2).unwrap();
|
||||
let m3 = out3.mesh.expect("3-node mesh reports");
|
||||
assert!(m3.at_risk, "cut ≤ threshold must flag partition risk");
|
||||
assert!(
|
||||
out3.recalibration_recommended,
|
||||
"mesh risk is a structural event — the advisor must fire immediately, no streak"
|
||||
);
|
||||
assert!(m3.cut_value.is_finite() && m3.cut_value >= 0.0);
|
||||
}
|
||||
|
||||
/// Mesh partition risk demotes the privacy class and shifts the witness —
|
||||
/// a fragmenting array makes the fused belief less trustworthy, so it is
|
||||
/// emitted at a more restricted class, and that demotion is auditable.
|
||||
/// Both cycles use the *same 3-node topology and frames*; the engines
|
||||
/// differ only in the forced mesh risk, so the witness delta is
|
||||
/// attributable to the risk demotion alone (review finding 4).
|
||||
#[test]
|
||||
fn mesh_risk_demotes_privacy_and_shifts_witness() {
|
||||
let cal = CalibrationId(8);
|
||||
let frames3 = [
|
||||
node_frame(0, 1000, 56),
|
||||
node_frame(1, 1001, 56),
|
||||
node_frame(2, 1002, 56),
|
||||
];
|
||||
|
||||
// Baseline: same topology, default risk threshold — clean cycle, not
|
||||
// demoted (PrivateHome → Anonymous), mesh healthy.
|
||||
let (mut e1, r1) = engine();
|
||||
let base = e1.process_cycle(&frames3, cal, r1, 5_000).unwrap();
|
||||
assert!(!base.mesh.as_ref().unwrap().at_risk);
|
||||
assert!(!base.demoted);
|
||||
assert_eq!(base.effective_class, PrivacyClass::Anonymous);
|
||||
|
||||
// Forced risk: identical frames/topology, threshold at the provable
|
||||
// cut upper bound so the crossing is deterministic.
|
||||
let (mut e2, r2) = engine();
|
||||
e2.mesh_guard_mut().risk_threshold = max_coupling_mass(3);
|
||||
let risky = e2.process_cycle(&frames3, cal, r2, 5_000).unwrap();
|
||||
assert!(risky.mesh.as_ref().unwrap().at_risk);
|
||||
assert!(risky.demoted, "mesh risk must demote");
|
||||
// PrivateHome base Anonymous(2) → demoted to Restricted(3).
|
||||
assert_eq!(risky.effective_class, PrivacyClass::Restricted);
|
||||
assert!(risky.provenance.privacy_decision.contains("Restricted"));
|
||||
assert_ne!(
|
||||
risky.witness, base.witness,
|
||||
"same topology, risk-only delta must shift the witness"
|
||||
);
|
||||
}
|
||||
|
||||
/// WorldGraph belief retention: the live loop appends one SemanticState per
|
||||
/// cycle; past the cap the oldest beliefs are evicted so graph memory is
|
||||
/// bounded, while structural nodes and the newest belief always survive.
|
||||
#[test]
|
||||
fn semantic_state_growth_is_bounded() {
|
||||
let (mut e, room) = engine();
|
||||
e.set_semantic_retention(5);
|
||||
let cal = CalibrationId(1);
|
||||
let mut last_id = None;
|
||||
let baseline_nodes = 2; // room + sensor
|
||||
for i in 0..20u64 {
|
||||
let frames = [
|
||||
node_frame(0, 1000 + i * 50_000, 56),
|
||||
node_frame(1, 1001 + i * 50_000, 56),
|
||||
];
|
||||
let out = e.process_cycle(&frames, cal, room, 5_000 + i as i64).unwrap();
|
||||
last_id = Some(out.semantic_id);
|
||||
assert!(e.world().node_count() <= baseline_nodes + 5);
|
||||
}
|
||||
// 20 cycles ran, only 5 beliefs remain, newest is still present.
|
||||
assert_eq!(e.world().node_count(), baseline_nodes + 5);
|
||||
assert!(e.world().node(last_id.unwrap()).is_some());
|
||||
// Structural nodes survive eviction.
|
||||
assert!(e.world().node(room).is_some());
|
||||
}
|
||||
|
||||
fn node_frame_scaled(node_id: u8, ts_us: u64, n_sub: usize, scale: f32) -> MultiBandCsiFrame {
|
||||
MultiBandCsiFrame {
|
||||
node_id,
|
||||
|
||||
@@ -0,0 +1,364 @@
|
||||
//! Mesh partition guard: dynamic min-cut over the live multistatic node graph.
|
||||
//!
|
||||
//! The fusion mesh (nodes = sensing nodes, edge weights = fusion coupling
|
||||
//! derived from per-node attention weights) changes *incrementally* at cycle
|
||||
//! rate — one node's coupling drifts, a node joins or drops. This module
|
||||
//! maintains a [`ruvector_mincut::DynamicMinCut`] over that graph and exposes,
|
||||
//! per cycle:
|
||||
//!
|
||||
//! - the **min-cut value** — the cheapest set of couplings whose loss splits
|
||||
//! the mesh in two: a principled, global "how close is the array to
|
||||
//! partitioning" number (vs per-node heuristics that miss multi-node
|
||||
//! structure);
|
||||
//! - the **weak side** — which specific nodes are about to partition (feeds
|
||||
//! failure/jamming triage, ADR-032 posture);
|
||||
//! - an **at-risk flag** consumed by the engine: it counts as a structural
|
||||
//! event for the drift→recalibration advisor.
|
||||
//!
|
||||
//! ## Cost model (the optimization)
|
||||
//!
|
||||
//! Weights are quantized (default 1/64; a *nonzero* coupling below one quantum
|
||||
//! saturates to quantum 1 so a live coupling is never erased — see
|
||||
//! [`MeshGuard::weight_quantum`]) and updates are **change-gated**: an
|
||||
//! edge is touched only when its quantized weight actually moves, so the
|
||||
//! steady-state cycle applies *zero* graph updates and reuses the cached cut —
|
||||
//! O(active-changes) per cycle, not O(n²) rebuilds. The exact (deterministic)
|
||||
//! algorithm is used; mesh sizes are ≤ tens of nodes, far inside its budget.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use ruvector_mincut::{DynamicMinCut, MinCutBuilder};
|
||||
|
||||
/// Per-cycle report from the mesh guard.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct MeshPartitionReport {
|
||||
/// Current min-cut value over the coupling graph (higher = more robust).
|
||||
pub cut_value: f64,
|
||||
/// True when the mesh has ≥ `min_nodes` nodes and the cut value fell to or
|
||||
/// below the risk threshold — the array is close to splitting.
|
||||
pub at_risk: bool,
|
||||
/// The smaller side of the min-cut partition (node ids): the nodes that
|
||||
/// would be isolated if the weak couplings failed.
|
||||
pub weak_side: Vec<u8>,
|
||||
/// Incremental edge updates applied this cycle (0 in steady state).
|
||||
pub updates_applied: usize,
|
||||
}
|
||||
|
||||
/// Dynamic min-cut guard over the live mesh.
|
||||
pub struct MeshGuard {
|
||||
mincut: Option<DynamicMinCut>,
|
||||
/// Node set the structure was built over (sorted). A change forces rebuild.
|
||||
nodes: Vec<u8>,
|
||||
/// Quantized edge weights currently installed, keyed `(u, v)` with `u < v`.
|
||||
edges: BTreeMap<(u8, u8), i64>,
|
||||
/// Weight quantum: weights are snapped to multiples of this before
|
||||
/// comparison/installation, gating out sub-quantum jitter.
|
||||
///
|
||||
/// Policy: a **nonzero** coupling below one quantum saturates to quantum 1
|
||||
/// instead of quantizing to 0 — quantization never erases a live coupling.
|
||||
/// (Without the floor, a balanced mesh of ≥ 65 nodes — attention weights
|
||||
/// ~1/n ⇒ couplings ~1/n < 1/64 — had every edge erased and was reported
|
||||
/// permanently "already partitioned"/at-risk.) Exact zero stays zero: a
|
||||
/// truly absent coupling *is* a partition. Relative weakness below one
|
||||
/// quantum is not resolved; lower this quantum if that resolution matters.
|
||||
pub weight_quantum: f64,
|
||||
/// Cut value at or below which the mesh counts as at partition risk.
|
||||
pub risk_threshold: f64,
|
||||
/// Minimum node count for risk to be meaningful (a 2-node mesh always has
|
||||
/// a trivial cut; default 3).
|
||||
pub min_nodes: usize,
|
||||
}
|
||||
|
||||
impl Default for MeshGuard {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
mincut: None,
|
||||
nodes: Vec::new(),
|
||||
edges: BTreeMap::new(),
|
||||
weight_quantum: 1.0 / 64.0,
|
||||
risk_threshold: 0.25,
|
||||
min_nodes: 3,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MeshGuard {
|
||||
/// Quantize a raw weight to the guard's grid (floor; weights are ≥ 0).
|
||||
/// Nonzero sub-quantum weights saturate to quantum 1 — see the
|
||||
/// [`Self::weight_quantum`] policy (review finding: sub-quantum couplings
|
||||
/// must not produce a false "already partitioned").
|
||||
fn quantize(&self, w: f64) -> i64 {
|
||||
let w = w.max(0.0);
|
||||
let q = (w / self.weight_quantum).floor() as i64;
|
||||
if q == 0 && w > 0.0 {
|
||||
1
|
||||
} else {
|
||||
q
|
||||
}
|
||||
}
|
||||
|
||||
/// Update the guard with this cycle's mesh: `nodes` are the contributing
|
||||
/// node ids and `coupling(i, j)` returns the fusion coupling between
|
||||
/// `nodes[i]` and `nodes[j]` (symmetric, ≥ 0).
|
||||
///
|
||||
/// Returns `None` for meshes of fewer than 2 nodes (no cut exists).
|
||||
pub fn update(
|
||||
&mut self,
|
||||
nodes: &[u8],
|
||||
coupling: impl Fn(usize, usize) -> f64,
|
||||
) -> Option<MeshPartitionReport> {
|
||||
if nodes.len() < 2 {
|
||||
// Mesh degenerated: drop state so a later rebuild starts clean.
|
||||
self.mincut = None;
|
||||
self.nodes.clear();
|
||||
self.edges.clear();
|
||||
return None;
|
||||
}
|
||||
let mut sorted: Vec<u8> = nodes.to_vec();
|
||||
sorted.sort_unstable();
|
||||
sorted.dedup();
|
||||
|
||||
// Desired quantized edge set for this cycle.
|
||||
let mut desired: BTreeMap<(u8, u8), i64> = BTreeMap::new();
|
||||
for i in 0..nodes.len() {
|
||||
for j in (i + 1)..nodes.len() {
|
||||
let (a, b) = if nodes[i] < nodes[j] {
|
||||
(nodes[i], nodes[j])
|
||||
} else {
|
||||
(nodes[j], nodes[i])
|
||||
};
|
||||
if a == b {
|
||||
continue;
|
||||
}
|
||||
let q = self.quantize(coupling(i, j));
|
||||
desired.insert((a, b), q);
|
||||
}
|
||||
}
|
||||
|
||||
// Change detection: count quantized-weight moves vs the installed set.
|
||||
let changed = if self.mincut.is_none() || self.nodes != sorted {
|
||||
usize::MAX // node set changed / first cycle: rebuild unconditionally
|
||||
} else {
|
||||
desired
|
||||
.iter()
|
||||
.filter(|(k, &q)| self.edges.get(k).copied().unwrap_or(0) != q)
|
||||
.count()
|
||||
};
|
||||
|
||||
let mut updates = 0usize;
|
||||
if changed > 0 {
|
||||
// Measured policy (criterion, 12-node mesh): a full exact rebuild
|
||||
// is ~170 µs while ONE DynamicMinCut delete+insert is ~240 µs —
|
||||
// the incremental machinery's overheads target much larger graphs.
|
||||
// At mesh scale the optimum is: change-gate aggressively (the
|
||||
// steady state below is ~7 µs and covers almost every cycle) and
|
||||
// rebuild whenever anything actually moved.
|
||||
let edges: Vec<(u64, u64, f64)> = desired
|
||||
.iter()
|
||||
.filter(|(_, &q)| q > 0)
|
||||
.map(|(&(a, b), &q)| {
|
||||
(u64::from(a), u64::from(b), q as f64 * self.weight_quantum)
|
||||
})
|
||||
.collect();
|
||||
updates = if changed == usize::MAX { edges.len() } else { changed };
|
||||
self.mincut = MinCutBuilder::new().exact().with_edges(edges).build().ok();
|
||||
self.nodes = sorted;
|
||||
self.edges = desired;
|
||||
}
|
||||
// changed == 0: steady state — zero graph work, cached cut reused.
|
||||
|
||||
// Nodes with no positive coupling never enter the cut structure (zero
|
||||
// edges are not installed) — they are already partitioned. Report them
|
||||
// as the degenerate cut before consulting the structure.
|
||||
let mut isolated: Vec<u8> = self
|
||||
.nodes
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|&v| {
|
||||
!self
|
||||
.edges
|
||||
.iter()
|
||||
.any(|(&(a, b), &q)| q > 0 && (a == v || b == v))
|
||||
})
|
||||
.collect();
|
||||
if !isolated.is_empty() {
|
||||
isolated.sort_unstable();
|
||||
return Some(MeshPartitionReport {
|
||||
cut_value: 0.0,
|
||||
at_risk: self.nodes.len() >= self.min_nodes,
|
||||
weak_side: isolated,
|
||||
updates_applied: updates,
|
||||
});
|
||||
}
|
||||
|
||||
let mc = self.mincut.as_ref()?;
|
||||
// A disconnected coupling graph is the degenerate cut: value 0.
|
||||
let cut_value = if mc.is_connected() { mc.min_cut_value() } else { 0.0 };
|
||||
let (side_a, side_b) = mc.partition();
|
||||
let weak_raw = if side_a.len() <= side_b.len() { side_a } else { side_b };
|
||||
let mut weak_side: Vec<u8> = weak_raw.into_iter().map(|v| v as u8).collect();
|
||||
weak_side.sort_unstable();
|
||||
let at_risk = self.nodes.len() >= self.min_nodes && cut_value <= self.risk_threshold;
|
||||
|
||||
Some(MeshPartitionReport { cut_value, at_risk, weak_side, updates_applied: updates })
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Triangle with one weakly-attached node: the cut isolates that node and
|
||||
/// the cut value equals its total coupling.
|
||||
#[test]
|
||||
fn weakly_attached_node_is_the_weak_side() {
|
||||
let mut g = MeshGuard::default();
|
||||
let nodes = [0u8, 1, 2];
|
||||
// 0–1 strongly coupled; node 2 hangs on by 0.05 + 0.05.
|
||||
let w = |i: usize, j: usize| match (i.min(j), i.max(j)) {
|
||||
(0, 1) => 1.0,
|
||||
_ => 0.05,
|
||||
};
|
||||
let r = g.update(&nodes, w).expect("3-node mesh");
|
||||
assert!(r.cut_value <= 0.13, "cut {} should be ~0.10", r.cut_value);
|
||||
assert_eq!(r.weak_side, vec![2]);
|
||||
assert!(r.at_risk, "weak coupling must flag partition risk");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strong_mesh_is_not_at_risk() {
|
||||
let mut g = MeshGuard::default();
|
||||
let r = g.update(&[0, 1, 2, 3], |_, _| 0.9).expect("mesh");
|
||||
assert!(r.cut_value > g.risk_threshold);
|
||||
assert!(!r.at_risk);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_node_mesh_reports_but_never_risks() {
|
||||
let mut g = MeshGuard::default();
|
||||
let r = g.update(&[0, 1], |_, _| 0.01).expect("2-node mesh");
|
||||
// Trivial cut exists but min_nodes=3 keeps the flag off.
|
||||
assert!(!r.at_risk);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fewer_than_two_nodes_yields_none() {
|
||||
let mut g = MeshGuard::default();
|
||||
assert!(g.update(&[7], |_, _| 1.0).is_none());
|
||||
assert!(g.update(&[], |_, _| 1.0).is_none());
|
||||
}
|
||||
|
||||
/// The optimization contract: identical weights on the next cycle apply
|
||||
/// zero updates; a sub-quantum wiggle also applies zero; a real change
|
||||
/// applies exactly the changed edges.
|
||||
#[test]
|
||||
fn steady_state_applies_zero_updates() {
|
||||
let mut g = MeshGuard::default();
|
||||
let nodes = [0u8, 1, 2, 3];
|
||||
let first = g.update(&nodes, |_, _| 0.5).unwrap();
|
||||
assert_eq!(first.updates_applied, 6); // cold build installs all edges
|
||||
|
||||
let second = g.update(&nodes, |_, _| 0.5).unwrap();
|
||||
assert_eq!(second.updates_applied, 0);
|
||||
|
||||
// Sub-quantum jitter (quantum is 1/64 ≈ 0.0156) is gated out.
|
||||
let third = g.update(&nodes, |_, _| 0.5 + 0.004).unwrap();
|
||||
assert_eq!(third.updates_applied, 0);
|
||||
|
||||
// One genuinely changed edge touches exactly one edge.
|
||||
let fourth = g
|
||||
.update(&nodes, |i, j| if (i.min(j), i.max(j)) == (0, 1) { 0.1 } else { 0.5 })
|
||||
.unwrap();
|
||||
assert_eq!(fourth.updates_applied, 1);
|
||||
}
|
||||
|
||||
/// Node set changes force a clean rebuild (drop/join handled correctly).
|
||||
#[test]
|
||||
fn node_join_and_drop_rebuild() {
|
||||
let mut g = MeshGuard::default();
|
||||
g.update(&[0, 1, 2], |_, _| 0.8).unwrap();
|
||||
// Node 3 joins.
|
||||
let joined = g.update(&[0, 1, 2, 3], |_, _| 0.8).unwrap();
|
||||
assert_eq!(joined.updates_applied, 6); // rebuild over 4 nodes
|
||||
// Node 0 drops.
|
||||
let dropped = g.update(&[1, 2, 3], |_, _| 0.8).unwrap();
|
||||
assert_eq!(dropped.updates_applied, 3);
|
||||
assert!(!dropped.at_risk);
|
||||
}
|
||||
|
||||
/// Determinism: same inputs, same report (cut value + weak side).
|
||||
#[test]
|
||||
fn reports_are_deterministic() {
|
||||
let run = || {
|
||||
let mut g = MeshGuard::default();
|
||||
let w = |i: usize, j: usize| match (i.min(j), i.max(j)) {
|
||||
(0, 1) => 0.9,
|
||||
(1, 2) => 0.6,
|
||||
_ => 0.07,
|
||||
};
|
||||
g.update(&[0, 1, 2], w).unwrap()
|
||||
};
|
||||
let a = run();
|
||||
let b = run();
|
||||
assert_eq!(a.cut_value.to_bits(), b.cut_value.to_bits());
|
||||
assert_eq!(a.weak_side, b.weak_side);
|
||||
}
|
||||
|
||||
/// Regression (review finding 3): a balanced mesh of ≥ 65 nodes has every
|
||||
/// pairwise coupling at ~1/n < quantum (1/64). The old floor-to-zero
|
||||
/// quantization erased all edges and reported the mesh permanently
|
||||
/// "already partitioned" (cut 0, at_risk). Nonzero sub-quantum couplings
|
||||
/// now saturate to one quantum, so the mesh reports a healthy cut.
|
||||
#[test]
|
||||
fn large_balanced_mesh_is_not_at_risk() {
|
||||
let mut g = MeshGuard::default();
|
||||
let nodes: Vec<u8> = (0..70u8).collect();
|
||||
// Attention-weight product coupling: (1/n)·(1/n)·n = 1/n ≈ 0.0143 < 1/64.
|
||||
let n = nodes.len() as f64;
|
||||
let r = g.update(&nodes, |_, _| 1.0 / n).expect("70-node mesh");
|
||||
assert!(
|
||||
r.cut_value > 0.0,
|
||||
"live couplings must not quantize to zero"
|
||||
);
|
||||
// Min cut isolates one node: 69 edges × one quantum (1/64) ≈ 1.08,
|
||||
// well above the 0.25 default risk threshold.
|
||||
assert!(r.cut_value > g.risk_threshold);
|
||||
assert!(
|
||||
!r.at_risk,
|
||||
"balanced large mesh must not be at partition risk"
|
||||
);
|
||||
assert!(r.weak_side.len() < nodes.len(), "no false full partition");
|
||||
}
|
||||
|
||||
/// Sub-quantum couplings saturate to one quantum but exact zero is still a
|
||||
/// real partition (the floor must not invent couplings).
|
||||
#[test]
|
||||
fn sub_quantum_saturates_but_zero_stays_zero() {
|
||||
let mut g = MeshGuard::default();
|
||||
// 0.001 < 1/64 everywhere: connected, tiny cut, flagged at risk
|
||||
// (cut = 2 × 1/64 ≈ 0.031 ≤ 0.25) — but NOT "already partitioned".
|
||||
let r = g.update(&[0, 1, 2], |_, _| 0.001).expect("mesh");
|
||||
assert!(r.cut_value > 0.0);
|
||||
assert!(r.at_risk);
|
||||
// Exact zero to node 2: degenerate cut 0, node 2 isolated.
|
||||
let mut g2 = MeshGuard::default();
|
||||
let r2 = g2
|
||||
.update(&[0, 1, 2], |i, j| if i == 2 || j == 2 { 0.0 } else { 0.5 })
|
||||
.expect("mesh");
|
||||
assert_eq!(r2.cut_value, 0.0);
|
||||
assert_eq!(r2.weak_side, vec![2]);
|
||||
}
|
||||
|
||||
/// A fully partitioned mesh (zero coupling to one node) reports cut 0.
|
||||
#[test]
|
||||
fn disconnected_mesh_is_cut_zero() {
|
||||
let mut g = MeshGuard::default();
|
||||
let w = |i: usize, j: usize| {
|
||||
if i == 2 || j == 2 { 0.0 } else { 0.9 }
|
||||
};
|
||||
let r = g.update(&[0, 1, 2], w).unwrap();
|
||||
assert_eq!(r.cut_value, 0.0);
|
||||
assert!(r.at_risk);
|
||||
assert_eq!(r.weak_side, vec![2]);
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,8 @@
|
||||
//! 12 4 Sequence number (LE u32)
|
||||
//! 16 1 RSSI (i8)
|
||||
//! 17 1 Noise floor (i8)
|
||||
//! 18 2 Reserved
|
||||
//! 18 1 PPDU type (ADR-110: 0=HT/legacy, 1=HE-SU, 2=HE-MU, 3=HE-TB)
|
||||
//! 19 1 Flags (ADR-110: bit0 bw40, bit2 STBC, bit3 LDPC, bit4 15.4-sync)
|
||||
//! 20 N*2 I/Q pairs (n_antennas * n_subcarriers * 2 bytes)
|
||||
//! ```
|
||||
//!
|
||||
@@ -240,12 +241,31 @@ impl Esp32CsiParser {
|
||||
}
|
||||
}
|
||||
|
||||
// Determine bandwidth from subcarrier count
|
||||
let bandwidth = match n_subcarriers {
|
||||
0..=56 => Bandwidth::Bw20,
|
||||
57..=114 => Bandwidth::Bw40,
|
||||
115..=242 => Bandwidth::Bw80,
|
||||
_ => Bandwidth::Bw160,
|
||||
// Determine bandwidth from PPDU type + subcarrier count (ADR-110).
|
||||
//
|
||||
// HE-LTF uses a 4x denser tone grid than HT-LTF on the same channel
|
||||
// width: HE20 = 256-FFT (242 active tones), HE40 = 512-FFT (484
|
||||
// active). So a 256-bin frame on an HE PPDU is *20 MHz*, not 160.
|
||||
// For HE frames the firmware also writes the bandwidth into byte 19
|
||||
// bit 0 (see Adr018Flags::bw40) — prefer that when set.
|
||||
//
|
||||
// HT/legacy keeps the count heuristic, with 64 included in the 20 MHz
|
||||
// bucket: ESP32 HT20 CSI delivers the full 64-bin FFT grid (live
|
||||
// capture evidence: 148-byte frames = 64 subcarriers on a 20 MHz
|
||||
// channel, issue #1005).
|
||||
let bandwidth = if ppdu_type.is_he() {
|
||||
if adr018_flags.bw40 || n_subcarriers > 256 {
|
||||
Bandwidth::Bw40
|
||||
} else {
|
||||
Bandwidth::Bw20
|
||||
}
|
||||
} else {
|
||||
match n_subcarriers {
|
||||
0..=64 => Bandwidth::Bw20,
|
||||
65..=128 => Bandwidth::Bw40,
|
||||
129..=242 => Bandwidth::Bw80,
|
||||
_ => Bandwidth::Bw160,
|
||||
}
|
||||
};
|
||||
|
||||
let frame = CsiFrame {
|
||||
|
||||
@@ -55,7 +55,9 @@ pub mod sync_packet;
|
||||
pub mod radio_ops;
|
||||
|
||||
pub use bridge::CsiData;
|
||||
pub use csi_frame::{AntennaConfig, Bandwidth, CsiFrame, CsiMetadata, SubcarrierData};
|
||||
pub use csi_frame::{
|
||||
Adr018Flags, AntennaConfig, Bandwidth, CsiFrame, CsiMetadata, PpduType, SubcarrierData,
|
||||
};
|
||||
pub use error::ParseError;
|
||||
pub use esp32_parser::{
|
||||
ruview_sibling_packet_name, Esp32CsiParser, ESP32_CSI_MAGIC, RUVIEW_COMPRESSED_CSI_MAGIC,
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
//! ADR-110 / issue #1005: real ESP32-C6 HE-LTF CSI frames captured live.
|
||||
//!
|
||||
//! Both fixtures below are verbatim UDP payloads captured on 2026-06-11 from
|
||||
//! an ESP32-C6 (node_id 12, IDF v5.5 build) streaming to UDP :5005 — the
|
||||
//! same node, same link, seconds apart. The 532-byte frame is an HE-SU
|
||||
//! capture (256 subcarrier bins = 242 active HE20 tones); the 148-byte frame
|
||||
//! is the HT fallback grid (64 bins) the same firmware emits for non-HE
|
||||
//! traffic. They are the canonical regression fixtures for the non-fixed
|
||||
//! subcarrier count introduced by HE-LTF.
|
||||
|
||||
use wifi_densepose_hardware::{Bandwidth, Esp32CsiParser, PpduType};
|
||||
|
||||
/// 532-byte HE-SU frame: header + 256 subcarrier I/Q pairs.
|
||||
/// magic=0xC5110001 node=12 ant=1 nsub=256 freq=2432 seq=11610
|
||||
/// rssi=-40 noise=-87 byte18=0x01 (HE-SU) byte19=0x10 (15.4-sync valid)
|
||||
const HE_FRAME_HEX: &str = "010011c50c010001800900005a2d0000d8a9011000000000000000000000f70ef70ef50cf30bf209f108f006ef03ee02ee00eefdeffbeff8f0f7f1f4f2f3f4f1f5f0f7eef8edfaecfdecffeb01ea03ea05e908ea0aeb0deb0fec11ee13f015f216f318f519f71afa1bfd1bff1c021c051b071b0a1a0c190f1811161315161218101a0e1b0c1c091d071e041f0120ff20fc20f91ff71ff41ef11def1cec1be919e717e615e413e311e10edf0cde09dd06dc04dc01dcffdcfbdcf9ddf6def3dff0e0ede2eae4e8e6e6e8e4eae2ebe0eedef1dcf4dbf7dafad9fdd900d903d806d909d90cda0fdc12dc14dd17df1ae11ce31ee520e722e924ed25f127f328f629f929fd2900290329062809270c260e26122516061a00001c201c1f1a211722142411250e260c27082804280129fe29fb28f927f627f426f125ef23ec22ea20e81eea20e81e891b53a82951565d4ffafbfebe9abddb10222aa47b3b371fd2c0860cd4d86ea2f35faccd46b0b66f6ff0050f2da27d1c92f7f8e1017cb545afd3e3fe60db6f478dc85a33b3454cf6df9061194a0a0fc3e0eedf76f1d292cb25c8f541dfcc4109f9f1a34955520ad8ffa3694ac395cbf6c19073a4aefb1ebf47c76730458431805d9f18ff2e81955e8752b29757f66e289f72f8e35309a737547c040444cbda1a81d221d950037ec38fd9d1dd0f56c3dc707a7bbfe66ca5a97ab7cc17d68d38ba43a1806f91f5911a5967e2c9f7f07186";
|
||||
|
||||
/// 148-byte HT frame from the same node: header + 64 subcarrier I/Q pairs.
|
||||
/// magic=0xC5110001 node=12 ant=1 nsub=64 freq=2432 seq=11622
|
||||
/// rssi=-79 noise=-87 byte18=0x00 (HT/legacy) byte19=0x10
|
||||
const HT_FRAME_HEX: &str = "010011c50c01400080090000662d0000b1a900100000000000000000fcfaf909f013f112f213f212f311f410f511f510f610f510f411f410f411f312f213f214f214f212f313f513f512f611f610f80ef90df90c0000010eff11fe13ff11fe1300000000ff01000001010002000200020204000301040103000400040002ff03ff03fe02fe02fe01fd00edfc03fa000000000000";
|
||||
|
||||
fn unhex(s: &str) -> Vec<u8> {
|
||||
(0..s.len())
|
||||
.step_by(2)
|
||||
.map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_he_su_frame_532_bytes_parses_with_256_subcarriers() {
|
||||
let data = unhex(HE_FRAME_HEX);
|
||||
assert_eq!(data.len(), 532);
|
||||
|
||||
let (frame, consumed) = Esp32CsiParser::parse_frame(&data).expect("HE frame must parse");
|
||||
assert_eq!(consumed, 532);
|
||||
assert_eq!(frame.metadata.node_id, 12);
|
||||
assert_eq!(frame.metadata.n_antennas, 1);
|
||||
assert_eq!(frame.metadata.n_subcarriers, 256);
|
||||
assert_eq!(frame.subcarrier_count(), 256);
|
||||
assert_eq!(frame.metadata.channel_freq_mhz, 2432);
|
||||
assert_eq!(frame.metadata.sequence, 11610);
|
||||
assert_eq!(frame.metadata.rssi_dbm, -40);
|
||||
assert_eq!(frame.metadata.noise_floor_dbm, -87);
|
||||
// ADR-110 byte 18: HE-SU PPDU. Byte 19 bit 4: ESP-NOW time-sync valid.
|
||||
assert_eq!(frame.metadata.ppdu_type, PpduType::HeSu);
|
||||
assert!(frame.metadata.ppdu_type.is_he());
|
||||
assert!(frame.metadata.adr018_flags.ieee802154_sync_valid);
|
||||
assert!(!frame.metadata.adr018_flags.bw40);
|
||||
// 256-FFT HE-LTF on a 20 MHz channel — NOT 160 MHz.
|
||||
assert_eq!(frame.metadata.bandwidth, Bandwidth::Bw20);
|
||||
assert!(frame.is_valid());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_ht_frame_148_bytes_parses_with_64_subcarriers() {
|
||||
let data = unhex(HT_FRAME_HEX);
|
||||
assert_eq!(data.len(), 148);
|
||||
|
||||
let (frame, consumed) = Esp32CsiParser::parse_frame(&data).expect("HT frame must parse");
|
||||
assert_eq!(consumed, 148);
|
||||
assert_eq!(frame.metadata.node_id, 12);
|
||||
assert_eq!(frame.metadata.n_subcarriers, 64);
|
||||
assert_eq!(frame.metadata.channel_freq_mhz, 2432);
|
||||
assert_eq!(frame.metadata.sequence, 11622);
|
||||
assert_eq!(frame.metadata.rssi_dbm, -79);
|
||||
assert_eq!(frame.metadata.noise_floor_dbm, -87);
|
||||
assert_eq!(frame.metadata.ppdu_type, PpduType::HtLegacy);
|
||||
assert!(!frame.metadata.ppdu_type.is_he());
|
||||
// 64-bin full HT20 FFT grid on a 20 MHz channel — NOT 40 MHz.
|
||||
assert_eq!(frame.metadata.bandwidth, Bandwidth::Bw20);
|
||||
assert!(frame.is_valid());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_interleaved_stream_parses_both_grids() {
|
||||
// The live node interleaves HE (84%) and HT (16%) frames on one socket.
|
||||
let mut stream = unhex(HE_FRAME_HEX);
|
||||
stream.extend_from_slice(&unhex(HT_FRAME_HEX));
|
||||
stream.extend_from_slice(&unhex(HE_FRAME_HEX));
|
||||
|
||||
let (frames, consumed) = Esp32CsiParser::parse_stream(&stream);
|
||||
assert_eq!(frames.len(), 3);
|
||||
assert_eq!(consumed, 532 + 148 + 532);
|
||||
assert_eq!(frames[0].metadata.n_subcarriers, 256);
|
||||
assert_eq!(frames[1].metadata.n_subcarriers, 64);
|
||||
assert_eq!(frames[2].metadata.n_subcarriers, 256);
|
||||
assert_eq!(frames[0].metadata.ppdu_type, PpduType::HeSu);
|
||||
assert_eq!(frames[1].metadata.ppdu_type, PpduType::HtLegacy);
|
||||
}
|
||||
@@ -15,12 +15,17 @@ readme = "README.md"
|
||||
default = ["std", "api", "ruvector"]
|
||||
ruvector = ["dep:ruvector-solver", "dep:ruvector-temporal-tensor"]
|
||||
std = []
|
||||
api = ["chrono/serde", "geo/use-serde"]
|
||||
# REST/WebSocket surface. Pulls the web stack (axum, futures-util) only when
|
||||
# enabled, and enables the `serde` FEATURE (not just `dep:serde`) so the
|
||||
# `cfg_attr(feature = "serde", ...)` derives on domain types are actually
|
||||
# active when the API is on (review finding 5: `api = ["dep:serde"]` enabled
|
||||
# the dependency but left every `feature = "serde"` cfg dead).
|
||||
api = ["serde", "dep:axum", "dep:futures-util"]
|
||||
portable = ["low-power"]
|
||||
low-power = []
|
||||
distributed = ["tokio/sync"]
|
||||
drone = ["distributed"]
|
||||
serde = ["chrono/serde", "geo/use-serde"]
|
||||
serde = ["dep:serde", "chrono/serde", "geo/use-serde"]
|
||||
|
||||
[dependencies]
|
||||
# Workspace dependencies
|
||||
@@ -30,20 +35,22 @@ wifi-densepose-nn = { version = "0.3.0", path = "../wifi-densepose-nn" }
|
||||
ruvector-solver = { workspace = true, optional = true }
|
||||
ruvector-temporal-tensor = { workspace = true, optional = true }
|
||||
|
||||
# Async runtime
|
||||
# Async runtime — required by the core integration layer (UDP CSI receiver,
|
||||
# hardware adapter, scan loop in `DisasterResponse::start_scanning`), not just
|
||||
# the REST API, so it is deliberately NOT gated behind `api`.
|
||||
tokio = { version = "1.35", features = ["rt", "sync", "time"] }
|
||||
async-trait = "0.1"
|
||||
|
||||
# Web framework (REST API)
|
||||
axum = { version = "0.7", features = ["ws"] }
|
||||
futures-util = "0.3"
|
||||
# Web framework (REST API) — only compiled with the `api` feature.
|
||||
axum = { version = "0.7", features = ["ws"], optional = true }
|
||||
futures-util = { version = "0.3", optional = true }
|
||||
|
||||
# Error handling
|
||||
thiserror = "2.0"
|
||||
anyhow = "1.0"
|
||||
|
||||
# Serialization
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde = { version = "1.0", features = ["derive"], optional = true }
|
||||
serde_json = "1.0"
|
||||
|
||||
# Time handling
|
||||
|
||||
@@ -78,6 +78,10 @@
|
||||
#![warn(rustdoc::missing_crate_level_docs)]
|
||||
|
||||
pub mod alerting;
|
||||
/// REST API surface (Axum). Requires the `api` feature — its DTOs derive
|
||||
/// serde, which is an optional dependency gated behind that feature.
|
||||
#[cfg(feature = "api")]
|
||||
#[cfg_attr(docsrs, doc(cfg(feature = "api")))]
|
||||
pub mod api;
|
||||
pub mod detection;
|
||||
pub mod domain;
|
||||
@@ -122,6 +126,8 @@ pub use integration::{
|
||||
AdapterError, HardwareAdapter, IntegrationConfig, NeuralAdapter, SignalAdapter,
|
||||
};
|
||||
|
||||
#[cfg(feature = "api")]
|
||||
#[cfg_attr(docsrs, doc(cfg(feature = "api")))]
|
||||
pub use api::{create_router, AppState};
|
||||
|
||||
pub use ml::{
|
||||
|
||||
@@ -53,6 +53,16 @@ wifi-densepose-signal = { version = "0.3.1", path = "../wifi-densepose-signal",
|
||||
# Hardware crate — SyncPacket decoder for ADR-110 §A0.12 mesh-aligned timestamps.
|
||||
wifi-densepose-hardware = { version = "0.3.0", path = "../wifi-densepose-hardware" }
|
||||
|
||||
# Governed streaming engine (ADR-135..146): fusion + privacy demotion +
|
||||
# WorldGraph belief + deterministic witness. The live server data runs through
|
||||
# this as a governed path whose Restricted-class decision strips per-node raw
|
||||
# amplitudes from the live publish; full output gating is a tracked follow-up —
|
||||
# see engine_bridge.rs ("Honest scope of the live-path governance").
|
||||
wifi-densepose-engine = { version = "0.3.0", path = "../wifi-densepose-engine" }
|
||||
wifi-densepose-worldgraph = { version = "0.3.0", path = "../wifi-densepose-worldgraph" }
|
||||
wifi-densepose-bfld = { version = "0.3.1", path = "../wifi-densepose-bfld", default-features = false }
|
||||
wifi-densepose-geo = { version = "0.1.0", path = "../wifi-densepose-geo" }
|
||||
|
||||
# midstream — real-time introspection / low-latency tap (ADR-099 D1).
|
||||
# Two crates only, on purpose: scheduler / neural-solver / strange-loop are
|
||||
# explicitly out of scope of ADR-099 (D5).
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
use ruvector_mincut::{DynamicMinCut, MinCutBuilder};
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use wifi_densepose_hardware::PpduType;
|
||||
|
||||
use crate::adaptive_classifier;
|
||||
use crate::types::*;
|
||||
@@ -84,6 +85,18 @@ pub fn parse_wasm_output(buf: &[u8]) -> Option<WasmOutputPacket> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse an ADR-018 raw CSI frame (magic 0xC511_0001).
|
||||
///
|
||||
/// Header layout (authoritative: firmware `csi_collector.c` / ADR-018):
|
||||
/// magic u32 LE @0, node_id u8 @4, n_antennas u8 @5, n_subcarriers u16 LE
|
||||
/// @6-7, freq_mhz u32 LE @8-11, sequence u32 LE @12-15, rssi i8 @16,
|
||||
/// noise_floor i8 @17, PPDU type u8 @18 (ADR-110), flags u8 @19 (ADR-110),
|
||||
/// I/Q pairs from @20.
|
||||
///
|
||||
/// Until issue #1005 this function read `n_subcarriers` from byte 6 alone
|
||||
/// (an ESP32-C6 HE-SU frame's 256 = 0x0100 LE decoded as 0 — the frame
|
||||
/// parsed "successfully" with zero subcarriers) and read sequence/rssi/
|
||||
/// noise at stale offsets 10/14/15 (rssi landed on sequence bytes ⇒ 0).
|
||||
pub fn parse_esp32_frame(buf: &[u8]) -> Option<Esp32Frame> {
|
||||
if buf.len() < 20 {
|
||||
return None;
|
||||
@@ -95,16 +108,18 @@ pub fn parse_esp32_frame(buf: &[u8]) -> Option<Esp32Frame> {
|
||||
|
||||
let node_id = buf[4];
|
||||
let n_antennas = buf[5];
|
||||
let n_subcarriers = buf[6];
|
||||
let freq_mhz = u16::from_le_bytes([buf[8], buf[9]]);
|
||||
let sequence = u32::from_le_bytes([buf[10], buf[11], buf[12], buf[13]]);
|
||||
let rssi_raw = buf[14] as i8;
|
||||
let n_subcarriers = u16::from_le_bytes([buf[6], buf[7]]);
|
||||
let freq_mhz_u32 = u32::from_le_bytes([buf[8], buf[9], buf[10], buf[11]]);
|
||||
let freq_mhz = u16::try_from(freq_mhz_u32).unwrap_or(0);
|
||||
let sequence = u32::from_le_bytes([buf[12], buf[13], buf[14], buf[15]]);
|
||||
let rssi_raw = buf[16] as i8;
|
||||
let rssi = if rssi_raw > 0 {
|
||||
rssi_raw.saturating_neg()
|
||||
} else {
|
||||
rssi_raw
|
||||
};
|
||||
let noise_floor = buf[15] as i8;
|
||||
let noise_floor = buf[17] as i8;
|
||||
let ppdu_type = PpduType::from_byte(buf[18]);
|
||||
|
||||
let iq_start = 20;
|
||||
let n_pairs = n_antennas as usize * n_subcarriers as usize;
|
||||
@@ -131,6 +146,7 @@ pub fn parse_esp32_frame(buf: &[u8]) -> Option<Esp32Frame> {
|
||||
sequence,
|
||||
rssi,
|
||||
noise_floor,
|
||||
ppdu_type,
|
||||
amplitudes,
|
||||
phases,
|
||||
})
|
||||
@@ -964,11 +980,12 @@ pub fn generate_simulated_frame(tick: u64) -> Esp32Frame {
|
||||
magic: 0xC511_0001,
|
||||
node_id: 1,
|
||||
n_antennas: 1,
|
||||
n_subcarriers: n_sub as u8,
|
||||
n_subcarriers: n_sub as u16,
|
||||
freq_mhz: 2437,
|
||||
sequence: tick as u32,
|
||||
rssi: (-40.0 + 5.0 * (t * 0.2).sin()) as i8,
|
||||
noise_floor: -90,
|
||||
ppdu_type: PpduType::HtLegacy,
|
||||
amplitudes,
|
||||
phases,
|
||||
}
|
||||
@@ -981,3 +998,76 @@ pub fn chrono_timestamp() -> u64 {
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
// ── ADR-110 / issue #1005 tests: live ESP32-C6 HE-LTF frames ────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod adr110_tests {
|
||||
use super::*;
|
||||
use crate::types::NodeState;
|
||||
|
||||
/// Verbatim 532-byte HE-SU UDP payload captured live 2026-06-11 from an
|
||||
/// ESP32-C6 (node 12, IDF v5.5): 256 subcarrier bins, byte18=0x01.
|
||||
const HE_FRAME_HEX: &str = "010011c50c010001800900005a2d0000d8a9011000000000000000000000f70ef70ef50cf30bf209f108f006ef03ee02ee00eefdeffbeff8f0f7f1f4f2f3f4f1f5f0f7eef8edfaecfdecffeb01ea03ea05e908ea0aeb0deb0fec11ee13f015f216f318f519f71afa1bfd1bff1c021c051b071b0a1a0c190f1811161315161218101a0e1b0c1c091d071e041f0120ff20fc20f91ff71ff41ef11def1cec1be919e717e615e413e311e10edf0cde09dd06dc04dc01dcffdcfbdcf9ddf6def3dff0e0ede2eae4e8e6e6e8e4eae2ebe0eedef1dcf4dbf7dafad9fdd900d903d806d909d90cda0fdc12dc14dd17df1ae11ce31ee520e722e924ed25f127f328f629f929fd2900290329062809270c260e26122516061a00001c201c1f1a211722142411250e260c27082804280129fe29fb28f927f627f426f125ef23ec22ea20e81eea20e81e891b53a82951565d4ffafbfebe9abddb10222aa47b3b371fd2c0860cd4d86ea2f35faccd46b0b66f6ff0050f2da27d1c92f7f8e1017cb545afd3e3fe60db6f478dc85a33b3454cf6df9061194a0a0fc3e0eedf76f1d292cb25c8f541dfcc4109f9f1a34955520ad8ffa3694ac395cbf6c19073a4aefb1ebf47c76730458431805d9f18ff2e81955e8752b29757f66e289f72f8e35309a737547c040444cbda1a81d221d950037ec38fd9d1dd0f56c3dc707a7bbfe66ca5a97ab7cc17d68d38ba43a1806f91f5911a5967e2c9f7f07186";
|
||||
|
||||
/// Verbatim 148-byte HT payload from the same node seconds later:
|
||||
/// 64 bins, byte18=0x00.
|
||||
const HT_FRAME_HEX: &str = "010011c50c01400080090000662d0000b1a900100000000000000000fcfaf909f013f112f213f212f311f410f511f510f610f510f411f410f411f312f213f214f214f212f313f513f512f611f610f80ef90df90c0000010eff11fe13ff11fe1300000000ff01000001010002000200020204000301040103000400040002ff03ff03fe02fe02fe01fd00edfc03fa000000000000";
|
||||
|
||||
fn unhex(s: &str) -> Vec<u8> {
|
||||
(0..s.len())
|
||||
.step_by(2)
|
||||
.map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_he_su_frame_parses_with_256_subcarriers() {
|
||||
let buf = unhex(HE_FRAME_HEX);
|
||||
assert_eq!(buf.len(), 532);
|
||||
let f = parse_esp32_frame(&buf).expect("532-byte HE frame must parse");
|
||||
assert_eq!(f.node_id, 12);
|
||||
assert_eq!(f.n_subcarriers, 256);
|
||||
assert_eq!(f.amplitudes.len(), 256);
|
||||
assert_eq!(f.freq_mhz, 2432);
|
||||
assert_eq!(f.sequence, 11610);
|
||||
assert_eq!(f.rssi, -40);
|
||||
assert_eq!(f.noise_floor, -87);
|
||||
assert_eq!(f.ppdu_type, PpduType::HeSu);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_ht_frame_parses_with_64_subcarriers() {
|
||||
let buf = unhex(HT_FRAME_HEX);
|
||||
assert_eq!(buf.len(), 148);
|
||||
let f = parse_esp32_frame(&buf).expect("148-byte HT frame must parse");
|
||||
assert_eq!(f.node_id, 12);
|
||||
assert_eq!(f.n_subcarriers, 64);
|
||||
assert_eq!(f.amplitudes.len(), 64);
|
||||
assert_eq!(f.rssi, -79);
|
||||
assert_eq!(f.ppdu_type, PpduType::HtLegacy);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grid_gate_never_mixes_ht_and_he_windows() {
|
||||
let he = parse_esp32_frame(&unhex(HE_FRAME_HEX)).unwrap();
|
||||
let ht = parse_esp32_frame(&unhex(HT_FRAME_HEX)).unwrap();
|
||||
let mut ns = NodeState::new();
|
||||
|
||||
// First frame locks the grid.
|
||||
assert!(ns.accept_grid(ht.grid()));
|
||||
ns.frame_history.push_back(ht.amplitudes.clone());
|
||||
|
||||
// HE upgrade: accepted, denser grid wins, history re-keyed.
|
||||
assert!(ns.accept_grid(he.grid()));
|
||||
assert!(ns.frame_history.is_empty(), "upgrade must clear HT history");
|
||||
ns.frame_history.push_back(he.amplitudes.clone());
|
||||
|
||||
// Interleaved HT minority frames are rejected from the feature path.
|
||||
assert!(!ns.accept_grid(ht.grid()));
|
||||
assert_eq!(ns.frame_history.len(), 1, "HT frame must not touch window");
|
||||
|
||||
// Steady-state HE frames keep flowing.
|
||||
assert!(ns.accept_grid(he.grid()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,469 @@
|
||||
//! Live trust-path bridge: drive the governed [`StreamingEngine`] from the
|
||||
//! sensing-server's live `NodeState` map.
|
||||
//!
|
||||
//! `multistatic_bridge.rs` already converts `NodeState` → `MultiBandCsiFrame`
|
||||
//! and runs the *bare* `MultistaticFuser`. That path produces fused amplitudes
|
||||
//! but skips the trust control plane: privacy demotion on contradiction, the
|
||||
//! WorldGraph belief with mandatory provenance, and the deterministic witness
|
||||
//! (ADR-135..146). This bridge routes the same live frames through
|
||||
//! [`StreamingEngine::process_cycle`], so every governed belief carries
|
||||
//! evidence + model + calibration + privacy decision and a BLAKE3 witness
|
||||
//! (narrowing the gap called out in ADR-136 §8 and the beyond-SOTA system
|
||||
//! review).
|
||||
//!
|
||||
//! ## Honest scope of the live-path governance
|
||||
//!
|
||||
//! The engine runs *alongside* the bare fusion path that feeds the live
|
||||
//! `SensingUpdate`; it does not replace it. What the engine's decision **does**
|
||||
//! gate on the live wire today: when a cycle is emitted at
|
||||
//! [`PrivacyClass::Restricted`] (base mode or contradiction/mesh-risk
|
||||
//! demotion), [`EngineBridge::suppress_raw_outputs`] is true and `main.rs`
|
||||
//! strips the per-node raw amplitude vectors from the published update — the
|
||||
//! same field mapping `wifi-densepose-bfld`'s privacy gate applies at
|
||||
//! `Restricted` (drop amplitude/phase proxies). Trust state (latest witness,
|
||||
//! effective class, recalibration flag, engine-error count) is readable on
|
||||
//! `GET /api/v1/status`. Gating of the remaining *derived* outputs
|
||||
//! (person count, classification, signal field) by privacy class is tracked
|
||||
//! as a follow-up; until then those fields are published ungoverned.
|
||||
//!
|
||||
//! Determinism: this module reads server state and forwards explicit
|
||||
//! timestamps/calibration ids; it introduces no wall-clock reads of its own, so
|
||||
//! a given `(frames, calibration, now_ms)` always yields the same
|
||||
//! [`TrustedOutput`] witness.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use wifi_densepose_bfld::{PrivacyClass, PrivacyMode};
|
||||
use wifi_densepose_engine::{AdapterInfo, EngineError, StreamingEngine, TrustedOutput};
|
||||
use wifi_densepose_geo::types::GeoRegistration;
|
||||
use wifi_densepose_signal::ruvsense::fusion_quality::CalibrationId;
|
||||
use wifi_densepose_worldgraph::WorldId;
|
||||
|
||||
use super::multistatic_bridge::node_frames_from_states;
|
||||
use super::NodeState;
|
||||
|
||||
/// Minimum spacing between engine-error warn logs (errors are still counted
|
||||
/// every cycle; only the log line is rate-limited — a 20 Hz loop must not
|
||||
/// emit 20 warns/s).
|
||||
const ENGINE_ERROR_WARN_INTERVAL: Duration = Duration::from_secs(10);
|
||||
|
||||
/// Owns a [`StreamingEngine`] and the WorldGraph scope (one room + sensor) the
|
||||
/// live sensing loop publishes beliefs into.
|
||||
pub struct EngineBridge {
|
||||
engine: StreamingEngine,
|
||||
room: WorldId,
|
||||
/// Nodes already wired into the WorldGraph as sensors (by `node_id`).
|
||||
registered_nodes: HashMap<u8, WorldId>,
|
||||
/// Calibration epoch applied to live frames until the ADR-135 baseline
|
||||
/// stage supplies a real per-node id. Stable so witnesses are reproducible.
|
||||
calibration: CalibrationId,
|
||||
// ── Trust state observed from the most recent cycles (review finding 1:
|
||||
// previously write-only fields on AppState; now recorded here and
|
||||
// exposed via the status endpoint + output gating). ──────────────────
|
||||
/// BLAKE3 witness of the most recent successful governed cycle.
|
||||
last_witness: Option<[u8; 32]>,
|
||||
/// Latest drift→recalibration recommendation (ADR-135 → ADR-150 §3.4).
|
||||
recalibration_recommended: bool,
|
||||
/// Privacy class the most recent cycle was emitted under (post-demotion).
|
||||
effective_class: Option<PrivacyClass>,
|
||||
/// Whether the most recent cycle was demoted (contradiction / mesh risk).
|
||||
demoted: bool,
|
||||
/// Total engine cycles that returned an error (previously swallowed by
|
||||
/// `if let Some(Ok(..))` at the call sites).
|
||||
engine_error_count: u64,
|
||||
/// Last time an engine error was actually logged (rate limiter).
|
||||
last_error_warn_at: Option<Instant>,
|
||||
}
|
||||
|
||||
impl EngineBridge {
|
||||
/// Build a bridge for one installation. `room_area_id`/`room_name` name the
|
||||
/// observation scope; `mode` is the starting privacy mode.
|
||||
pub fn new(mode: PrivacyMode, model_version: u16, room_area_id: &str, room_name: &str) -> Self {
|
||||
let mut engine = StreamingEngine::new(mode, model_version, GeoRegistration::default());
|
||||
let room = engine.add_room(room_area_id, room_name);
|
||||
Self {
|
||||
engine,
|
||||
room,
|
||||
registered_nodes: HashMap::new(),
|
||||
calibration: CalibrationId(0x5256_0001), // "RV\0\x01" — placeholder epoch
|
||||
last_witness: None,
|
||||
recalibration_recommended: false,
|
||||
effective_class: None,
|
||||
demoted: false,
|
||||
engine_error_count: 0,
|
||||
last_error_warn_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Override the calibration epoch stamped onto live frames (ADR-135).
|
||||
pub fn set_calibration(&mut self, calibration: CalibrationId) {
|
||||
self.calibration = calibration;
|
||||
}
|
||||
|
||||
/// Override the WorldGraph belief-retention cap (bounds memory on the live
|
||||
/// loop; see `WorldGraph::prune_semantic_states`).
|
||||
pub fn set_semantic_retention(&mut self, max_states: usize) {
|
||||
self.engine.set_semantic_retention(max_states);
|
||||
}
|
||||
|
||||
/// Switch the active privacy mode (operator/control-plane action).
|
||||
pub fn set_privacy_mode(&mut self, mode: PrivacyMode) {
|
||||
self.engine.set_privacy_mode(mode);
|
||||
}
|
||||
|
||||
/// Activate a per-room calibration adapter (ADR-150 §3.4). The adapter's
|
||||
/// content-derived id becomes part of provenance/witness from the next
|
||||
/// cycle — weights can never swap silently on the live path.
|
||||
pub fn set_room_adapter(&mut self, info: AdapterInfo) {
|
||||
self.engine.set_room_adapter(info);
|
||||
}
|
||||
|
||||
/// Deactivate the per-room adapter (revert to the shared base model).
|
||||
pub fn clear_room_adapter(&mut self) {
|
||||
self.engine.clear_room_adapter();
|
||||
}
|
||||
|
||||
/// Borrow the engine (queries, WorldGraph snapshot, privacy audit).
|
||||
pub fn engine(&self) -> &StreamingEngine {
|
||||
&self.engine
|
||||
}
|
||||
|
||||
/// Number of sensor nodes wired into the WorldGraph so far.
|
||||
pub fn registered_node_count(&self) -> usize {
|
||||
self.registered_nodes.len()
|
||||
}
|
||||
|
||||
/// Run one governed trust cycle over the current live node states.
|
||||
///
|
||||
/// Returns `None` when no active node yields a frame (nothing to fuse —
|
||||
/// the engine is not invoked, so no spurious belief is published). On a
|
||||
/// real cycle it lazily wires any newly-seen node as a WorldGraph sensor,
|
||||
/// then returns the witnessed [`TrustedOutput`] (or a fusion error).
|
||||
///
|
||||
/// `now_ms` is supplied by the caller (the sensing loop's clock), keeping
|
||||
/// the bridge deterministic and replayable.
|
||||
pub fn process_cycle_from_states(
|
||||
&mut self,
|
||||
node_states: &HashMap<u8, NodeState>,
|
||||
now_ms: i64,
|
||||
) -> Option<Result<TrustedOutput, EngineError>> {
|
||||
let frames = node_frames_from_states(node_states);
|
||||
if frames.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// Lazily register each contributing node as a sensor observing the room,
|
||||
// so the privacy rollup can suppress it under identity-strict modes.
|
||||
for f in &frames {
|
||||
self.registered_nodes.entry(f.node_id).or_insert_with(|| {
|
||||
self.engine
|
||||
.add_sensor(&format!("node-{}", f.node_id), self.room)
|
||||
});
|
||||
}
|
||||
Some(
|
||||
self.engine
|
||||
.process_cycle(&frames, self.calibration, self.room, now_ms),
|
||||
)
|
||||
}
|
||||
|
||||
/// Run one governed cycle **and record the trust state** (review finding
|
||||
/// 1): on success the witness / effective class / demotion /
|
||||
/// recalibration flag are stored for the status endpoint and output
|
||||
/// gating; on error the error counter is incremented and a rate-limited
|
||||
/// warning is logged (never silently swallowed). Returns the trusted
|
||||
/// output on success, `None` when there was nothing to fuse or the cycle
|
||||
/// errored.
|
||||
pub fn observe_cycle(
|
||||
&mut self,
|
||||
node_states: &HashMap<u8, NodeState>,
|
||||
now_ms: i64,
|
||||
) -> Option<TrustedOutput> {
|
||||
match self.process_cycle_from_states(node_states, now_ms)? {
|
||||
Ok(trust) => {
|
||||
self.last_witness = Some(trust.witness);
|
||||
self.recalibration_recommended = trust.recalibration_recommended;
|
||||
self.effective_class = Some(trust.effective_class);
|
||||
self.demoted = trust.demoted;
|
||||
Some(trust)
|
||||
}
|
||||
Err(e) => {
|
||||
self.engine_error_count += 1;
|
||||
let now = Instant::now();
|
||||
let warn_due = self.last_error_warn_at.map_or(true, |t| {
|
||||
now.duration_since(t) >= ENGINE_ERROR_WARN_INTERVAL
|
||||
});
|
||||
if warn_due {
|
||||
self.last_error_warn_at = Some(now);
|
||||
tracing::warn!(
|
||||
total_engine_errors = self.engine_error_count,
|
||||
"governed trust cycle failed (warn rate-limited to one per {:?}): {e}",
|
||||
ENGINE_ERROR_WARN_INTERVAL
|
||||
);
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// BLAKE3 witness of the most recent successful governed cycle.
|
||||
pub fn last_trust_witness(&self) -> Option<[u8; 32]> {
|
||||
self.last_witness
|
||||
}
|
||||
|
||||
/// Latest drift→recalibration recommendation from the governed engine.
|
||||
pub fn recalibration_recommended(&self) -> bool {
|
||||
self.recalibration_recommended
|
||||
}
|
||||
|
||||
/// Privacy class the most recent cycle was emitted under (post-demotion);
|
||||
/// `None` until a governed cycle has run.
|
||||
pub fn effective_class(&self) -> Option<PrivacyClass> {
|
||||
self.effective_class
|
||||
}
|
||||
|
||||
/// Whether the most recent cycle was demoted (contradiction / mesh risk).
|
||||
pub fn demoted(&self) -> bool {
|
||||
self.demoted
|
||||
}
|
||||
|
||||
/// Engine cycles that returned an error since startup.
|
||||
pub fn engine_error_count(&self) -> u64 {
|
||||
self.engine_error_count
|
||||
}
|
||||
|
||||
/// ADR-141 output mapping for the live publish path (review finding 1c):
|
||||
/// at effective class [`PrivacyClass::Restricted`] the bfld privacy gate
|
||||
/// drops the amplitude + phase proxies; the live `SensingUpdate` applies
|
||||
/// the same field mapping by suppressing the per-node raw amplitude
|
||||
/// vectors when this returns true. Classes below `Restricted` leave the
|
||||
/// publish unchanged.
|
||||
pub fn suppress_raw_outputs(&self) -> bool {
|
||||
self.effective_class
|
||||
.is_some_and(|c| c.as_u8() >= PrivacyClass::Restricted.as_u8())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::VecDeque;
|
||||
use std::time::Instant;
|
||||
use wifi_densepose_bfld::PrivacyClass;
|
||||
|
||||
fn node_state_with_history(amp: f64, n_sub: usize) -> NodeState {
|
||||
let mut ns = NodeState::new();
|
||||
let frame: Vec<f64> = (0..n_sub).map(|i| amp + 0.1 * i as f64).collect();
|
||||
ns.frame_history = VecDeque::from(vec![frame]);
|
||||
ns.last_frame_time = Some(Instant::now());
|
||||
ns
|
||||
}
|
||||
|
||||
fn two_node_states() -> HashMap<u8, NodeState> {
|
||||
let mut m = HashMap::new();
|
||||
m.insert(0u8, node_state_with_history(1.0, 56));
|
||||
m.insert(1u8, node_state_with_history(1.05, 56));
|
||||
m
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_states_produce_no_belief() {
|
||||
let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "living_room", "Living Room");
|
||||
let out = bridge.process_cycle_from_states(&HashMap::new(), 1_000);
|
||||
assert!(out.is_none());
|
||||
// No belief published, no sensor wired.
|
||||
assert_eq!(bridge.registered_node_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_cycle_produces_witnessed_belief_with_provenance() {
|
||||
let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "living_room", "Living Room");
|
||||
let states = two_node_states();
|
||||
let out = bridge
|
||||
.process_cycle_from_states(&states, 10_000)
|
||||
.expect("frames present")
|
||||
.expect("fusion succeeds");
|
||||
|
||||
// Full provenance: evidence + model + calibration + privacy decision.
|
||||
assert!(!out.provenance.evidence.is_empty());
|
||||
assert_eq!(out.provenance.model_version, "rfenc-v1");
|
||||
assert!(out.provenance.calibration_version.starts_with("cal:"));
|
||||
assert!(out.provenance.privacy_decision.starts_with("PrivateHome/"));
|
||||
// A witness was produced and the belief is in the WorldGraph.
|
||||
assert_ne!(out.witness, [0u8; 32]);
|
||||
assert!(bridge.engine().world().node(out.semantic_id).is_some());
|
||||
// Both nodes are now wired as sensors.
|
||||
assert_eq!(bridge.registered_node_count(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_path_is_deterministic() {
|
||||
let states = two_node_states_fixed();
|
||||
let run = || {
|
||||
let mut b = EngineBridge::new(PrivacyMode::PrivateHome, 1, "r", "R");
|
||||
b.process_cycle_from_states(&states, 5_000).unwrap().unwrap()
|
||||
};
|
||||
let a = run();
|
||||
let b = run();
|
||||
assert_eq!(a.witness, b.witness);
|
||||
assert_eq!(a.provenance.calibration_version, b.provenance.calibration_version);
|
||||
assert_eq!(a.effective_class, b.effective_class);
|
||||
}
|
||||
|
||||
// Deterministic node states (no wall-clock in amplitude/history).
|
||||
fn two_node_states_fixed() -> HashMap<u8, NodeState> {
|
||||
let mut m = HashMap::new();
|
||||
for (id, amp) in [(0u8, 1.0_f64), (1u8, 1.05)] {
|
||||
let mut ns = NodeState::new();
|
||||
ns.frame_history = VecDeque::from(vec![(0..56)
|
||||
.map(|i| amp + 0.1 * i as f64)
|
||||
.collect::<Vec<f64>>()]);
|
||||
ns.last_frame_time = Some(Instant::now());
|
||||
m.insert(id, ns);
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nodes_registered_once_across_cycles() {
|
||||
let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "r", "R");
|
||||
let states = two_node_states();
|
||||
bridge.process_cycle_from_states(&states, 1_000);
|
||||
bridge.process_cycle_from_states(&states, 2_000);
|
||||
bridge.process_cycle_from_states(&states, 3_000);
|
||||
// Still exactly two sensors — idempotent registration.
|
||||
assert_eq!(bridge.registered_node_count(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retention_bounds_world_graph_growth() {
|
||||
let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "r", "R");
|
||||
bridge.set_semantic_retention(5);
|
||||
let states = two_node_states();
|
||||
for i in 0..20i64 {
|
||||
bridge.process_cycle_from_states(&states, 1_000 + i * 50);
|
||||
}
|
||||
// room + 2 sensors + at most 5 retained beliefs.
|
||||
assert!(bridge.engine().world().node_count() <= 3 + 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adapter_identity_flows_into_live_witness() {
|
||||
let states = two_node_states_fixed();
|
||||
let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "r", "R");
|
||||
let base = bridge
|
||||
.process_cycle_from_states(&states, 1_000)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
bridge.set_room_adapter(AdapterInfo {
|
||||
adapter_id: "deadbeefcafef00d".into(),
|
||||
trained_samples: 120,
|
||||
});
|
||||
let adapted = bridge
|
||||
.process_cycle_from_states(&states, 2_000)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(adapted
|
||||
.provenance
|
||||
.model_version
|
||||
.ends_with("+adapter:deadbeefcafef00d"));
|
||||
assert_ne!(adapted.witness, base.witness);
|
||||
// Clearing reverts to the base model identity.
|
||||
bridge.clear_room_adapter();
|
||||
let back = bridge
|
||||
.process_cycle_from_states(&states, 3_000)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(back.provenance.model_version, "rfenc-v1");
|
||||
}
|
||||
|
||||
/// Wiring (review finding 1): a live frame in → trust state recorded on
|
||||
/// the bridge (witness, effective class, recalibration flag), readable by
|
||||
/// the status endpoint, with a zero error count on the happy path.
|
||||
#[test]
|
||||
fn observe_cycle_records_trust_state() {
|
||||
let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "r", "R");
|
||||
assert!(bridge.last_trust_witness().is_none());
|
||||
assert_eq!(bridge.effective_class(), None);
|
||||
|
||||
let out = bridge
|
||||
.observe_cycle(&two_node_states(), 1_000)
|
||||
.expect("two fresh nodes → governed cycle runs");
|
||||
|
||||
assert_eq!(bridge.last_trust_witness(), Some(out.witness));
|
||||
assert_eq!(bridge.effective_class(), Some(out.effective_class));
|
||||
assert_eq!(
|
||||
bridge.recalibration_recommended(),
|
||||
out.recalibration_recommended
|
||||
);
|
||||
assert_eq!(bridge.demoted(), out.demoted);
|
||||
assert_eq!(bridge.engine_error_count(), 0);
|
||||
// PrivateHome clean cycle → Anonymous → raw outputs NOT suppressed.
|
||||
assert_eq!(bridge.effective_class(), Some(PrivacyClass::Anonymous));
|
||||
assert!(!bridge.suppress_raw_outputs());
|
||||
}
|
||||
|
||||
/// Error wiring (review finding 1a): two live nodes with mismatched
|
||||
/// subcarrier counts make fusion return a `DimensionMismatch` →
|
||||
/// `EngineError` — previously dropped by `if let Some(Ok(..))` at the
|
||||
/// call sites. The counter must increment and the last good trust state
|
||||
/// must survive a later failure.
|
||||
#[test]
|
||||
fn observe_cycle_counts_engine_errors() {
|
||||
let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "r", "R");
|
||||
let mut mismatched = HashMap::new();
|
||||
mismatched.insert(0u8, node_state_with_history(1.0, 56));
|
||||
mismatched.insert(1u8, node_state_with_history(1.05, 30)); // 30 ≠ 56 subcarriers
|
||||
|
||||
assert!(bridge.observe_cycle(&mismatched, 1_000).is_none());
|
||||
assert_eq!(bridge.engine_error_count(), 1);
|
||||
assert!(
|
||||
bridge.last_trust_witness().is_none(),
|
||||
"no witness from a failed cycle"
|
||||
);
|
||||
|
||||
assert!(bridge.observe_cycle(&mismatched, 2_000).is_none());
|
||||
assert_eq!(bridge.engine_error_count(), 2);
|
||||
|
||||
// A later good cycle records trust state; the audit count is kept.
|
||||
let out = bridge.observe_cycle(&two_node_states(), 3_000);
|
||||
assert!(out.is_some());
|
||||
assert!(bridge.last_trust_witness().is_some());
|
||||
assert_eq!(bridge.engine_error_count(), 2);
|
||||
|
||||
// And a subsequent failure keeps the last good witness readable.
|
||||
assert!(bridge.observe_cycle(&mismatched, 4_000).is_none());
|
||||
assert_eq!(bridge.engine_error_count(), 3);
|
||||
assert!(bridge.last_trust_witness().is_some());
|
||||
}
|
||||
|
||||
/// ADR-141 mapping (review finding 1c): a cycle emitted at class
|
||||
/// Restricted flips `suppress_raw_outputs`, which `main.rs` uses to strip
|
||||
/// per-node raw amplitude vectors from the live publish — the same field
|
||||
/// mapping bfld's privacy gate applies at `Restricted`.
|
||||
#[test]
|
||||
fn restricted_class_suppresses_raw_outputs() {
|
||||
let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "r", "R");
|
||||
bridge.set_privacy_mode(PrivacyMode::StrictNoIdentity); // base = Restricted
|
||||
bridge
|
||||
.observe_cycle(&two_node_states(), 1_000)
|
||||
.expect("cycle runs");
|
||||
assert_eq!(bridge.effective_class(), Some(PrivacyClass::Restricted));
|
||||
assert!(bridge.suppress_raw_outputs());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_strict_mode_is_carried_into_provenance() {
|
||||
let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "r", "R");
|
||||
bridge.set_privacy_mode(PrivacyMode::StrictNoIdentity);
|
||||
let out = bridge
|
||||
.process_cycle_from_states(&two_node_states(), 7_000)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(out.provenance.privacy_decision.starts_with("StrictNoIdentity/"));
|
||||
// Effective class is a valid privacy class (sanity).
|
||||
let _ = matches!(
|
||||
out.effective_class,
|
||||
PrivacyClass::Raw | PrivacyClass::Derived | PrivacyClass::Anonymous | PrivacyClass::Restricted
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@
|
||||
mod adaptive_classifier;
|
||||
pub mod cli;
|
||||
pub mod csi;
|
||||
mod engine_bridge;
|
||||
mod field_bridge;
|
||||
mod multistatic_bridge;
|
||||
pub mod pose;
|
||||
@@ -226,15 +227,28 @@ struct Esp32Frame {
|
||||
magic: u32,
|
||||
node_id: u8,
|
||||
n_antennas: u8,
|
||||
n_subcarriers: u8,
|
||||
/// u16 since ADR-110 / issue #1005: ESP32-C6 HE-SU frames carry 256
|
||||
/// subcarrier bins (242 active HE20 tones). HT frames stay ≤128.
|
||||
n_subcarriers: u16,
|
||||
freq_mhz: u16,
|
||||
sequence: u32,
|
||||
rssi: i8,
|
||||
noise_floor: i8,
|
||||
/// ADR-110 byte 18: PPDU type the CSI was sampled from. Pre-ADR-110
|
||||
/// firmware sends 0 ⇒ `PpduType::HtLegacy`.
|
||||
ppdu_type: wifi_densepose_hardware::PpduType,
|
||||
amplitudes: Vec<f64>,
|
||||
phases: Vec<f64>,
|
||||
}
|
||||
|
||||
impl Esp32Frame {
|
||||
/// The `(n_subcarriers, ppdu_type)` symbol-grid identity of this frame.
|
||||
/// HT-LTF and HE-LTF grids are not bin-comparable (ADR-110 / #1005).
|
||||
fn grid(&self) -> (u16, wifi_densepose_hardware::PpduType) {
|
||||
(self.n_subcarriers, self.ppdu_type)
|
||||
}
|
||||
}
|
||||
|
||||
/// Sensing update broadcast to WebSocket clients
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct SensingUpdate {
|
||||
@@ -442,6 +456,12 @@ struct NodeState {
|
||||
/// Most recent novelty score in [0.0, 1.0] (0 = exact-match in bank,
|
||||
/// 1 = no overlap). Consumed by the model-wake gate downstream.
|
||||
pub(crate) last_novelty_score: Option<f32>,
|
||||
/// ADR-110 / issue #1005: the `(n_subcarriers, ppdu_type)` grid this
|
||||
/// node's rolling windows were built on. ESP32-C6 nodes interleave
|
||||
/// HE-SU 256-bin frames with HT 64-bin frames on one socket; mixing
|
||||
/// the two symbol grids in `frame_history` corrupts variance/baseline
|
||||
/// statistics. See [`NodeState::accept_grid`].
|
||||
active_grid: Option<(u16, wifi_densepose_hardware::PpduType)>,
|
||||
}
|
||||
|
||||
/// Default EMA alpha for temporal keypoint smoothing (RuVector Phase 2).
|
||||
@@ -647,6 +667,35 @@ impl NodeState {
|
||||
),
|
||||
),
|
||||
last_novelty_score: None,
|
||||
active_grid: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// ADR-110 / issue #1005 grid gate: decide whether a frame on `grid`
|
||||
/// may enter this node's feature path, and update `active_grid`.
|
||||
///
|
||||
/// Returns `true` to accept. Policy: lock onto the densest grid seen.
|
||||
/// On a grid *upgrade* (more subcarriers — e.g. the first HE-SU 256-bin
|
||||
/// frame after HT 64-bin history) the rolling amplitude history and
|
||||
/// motion baseline are cleared so HT and HE symbol grids are never
|
||||
/// mixed in one window. Sparser-grid frames (the ~16% HT minority an
|
||||
/// ESP32-C6 keeps emitting alongside HE) are rejected from the feature
|
||||
/// path; the caller still records the arrival for fps/liveness.
|
||||
fn accept_grid(&mut self, grid: (u16, wifi_densepose_hardware::PpduType)) -> bool {
|
||||
match self.active_grid {
|
||||
None => {
|
||||
self.active_grid = Some(grid);
|
||||
true
|
||||
}
|
||||
Some(active) if active == grid => true,
|
||||
Some((active_n, _)) if grid.0 > active_n => {
|
||||
self.active_grid = Some(grid);
|
||||
self.frame_history.clear();
|
||||
self.baseline_motion = 0.0;
|
||||
self.baseline_frames = 0;
|
||||
true
|
||||
}
|
||||
Some(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -988,6 +1037,13 @@ struct AppStateInner {
|
||||
last_tracker_instant: Option<std::time::Instant>,
|
||||
/// Attention-weighted multi-node CSI fusion engine.
|
||||
multistatic_fuser: MultistaticFuser,
|
||||
/// Governed trust-path bridge (ADR-135..146): runs the same live frames
|
||||
/// through the privacy/provenance/witness control plane. Does not alter
|
||||
/// person-count behavior; its trust state (witness, effective class,
|
||||
/// recalibration flag, error count) is recorded on the bridge itself and
|
||||
/// exposed via `GET /api/v1/status`, and a Restricted-class cycle strips
|
||||
/// per-node raw amplitudes from the live publish (review finding 1).
|
||||
engine_bridge: engine_bridge::EngineBridge,
|
||||
/// SVD-based room field model for eigenvalue person counting (None until calibration).
|
||||
field_model: Option<FieldModel>,
|
||||
// ── ADR-044 §5.2: adaptive rolling-p95 normalization ─────────────────────
|
||||
@@ -1374,19 +1430,25 @@ fn parse_esp32_frame(buf: &[u8]) -> Option<Esp32Frame> {
|
||||
// [17] noise_floor (i8)
|
||||
// [18..19] reserved
|
||||
// [20..] I/Q data
|
||||
// Issue #1005: until 2026-06 this code read n_subcarriers from byte 6
|
||||
// alone (an ESP32-C6 HE-SU frame's 256 = 0x0100 LE decoded as 0 — the
|
||||
// frame parsed with zero subcarriers) and read sequence/rssi/noise at
|
||||
// stale offsets 10/14/15. Offsets below match the comment (and firmware).
|
||||
let node_id = buf[4];
|
||||
let n_antennas = buf[5];
|
||||
let n_subcarriers = buf[6];
|
||||
let freq_mhz = u16::from_le_bytes([buf[8], buf[9]]);
|
||||
let sequence = u32::from_le_bytes([buf[10], buf[11], buf[12], buf[13]]);
|
||||
let rssi_raw = buf[14] as i8;
|
||||
let n_subcarriers = u16::from_le_bytes([buf[6], buf[7]]);
|
||||
let freq_mhz =
|
||||
u16::try_from(u32::from_le_bytes([buf[8], buf[9], buf[10], buf[11]])).unwrap_or(0);
|
||||
let sequence = u32::from_le_bytes([buf[12], buf[13], buf[14], buf[15]]);
|
||||
let rssi_raw = buf[16] as i8;
|
||||
// Fix RSSI sign: ensure it's always negative (dBm convention).
|
||||
let rssi = if rssi_raw > 0 {
|
||||
rssi_raw.saturating_neg()
|
||||
} else {
|
||||
rssi_raw
|
||||
};
|
||||
let noise_floor = buf[15] as i8;
|
||||
let noise_floor = buf[17] as i8;
|
||||
let ppdu_type = wifi_densepose_hardware::PpduType::from_byte(buf[18]);
|
||||
|
||||
let iq_start = 20;
|
||||
let n_pairs = n_antennas as usize * n_subcarriers as usize;
|
||||
@@ -1415,6 +1477,7 @@ fn parse_esp32_frame(buf: &[u8]) -> Option<Esp32Frame> {
|
||||
sequence,
|
||||
rssi,
|
||||
noise_floor,
|
||||
ppdu_type,
|
||||
amplitudes,
|
||||
phases,
|
||||
})
|
||||
@@ -2296,11 +2359,12 @@ async fn windows_wifi_task(state: SharedState, tick_ms: u64) {
|
||||
magic: 0xC511_0001,
|
||||
node_id: 0,
|
||||
n_antennas: 1,
|
||||
n_subcarriers: obs_count.min(255) as u8,
|
||||
n_subcarriers: obs_count.min(u16::MAX as usize) as u16,
|
||||
freq_mhz: 2437,
|
||||
sequence: seq,
|
||||
rssi: first_rssi.clamp(-128.0, 127.0) as i8,
|
||||
noise_floor: -90,
|
||||
ppdu_type: wifi_densepose_hardware::PpduType::HtLegacy,
|
||||
amplitudes: multi_ap_frame.amplitudes.clone(),
|
||||
phases: multi_ap_frame.phases.clone(),
|
||||
};
|
||||
@@ -2482,6 +2546,7 @@ async fn windows_wifi_fallback_tick(state: &SharedState, seq: u32) {
|
||||
sequence: seq,
|
||||
rssi: rssi_dbm as i8,
|
||||
noise_floor: -90,
|
||||
ppdu_type: wifi_densepose_hardware::PpduType::HtLegacy,
|
||||
amplitudes: vec![signal_pct],
|
||||
phases: vec![0.0],
|
||||
};
|
||||
@@ -2615,7 +2680,11 @@ async fn probe_esp32(port: u16) -> bool {
|
||||
let addr = format!("0.0.0.0:{port}");
|
||||
match UdpSocket::bind(&addr).await {
|
||||
Ok(sock) => {
|
||||
let mut buf = [0u8; 256];
|
||||
// 2048 covers the largest ADR-018 frame: an ESP32-C6 HE-SU
|
||||
// capture is 532 bytes (issue #1005); on Windows a too-small
|
||||
// recv buffer makes recv_from error on the oversized datagram,
|
||||
// which made this probe fail against HE-only streams.
|
||||
let mut buf = [0u8; 2048];
|
||||
match tokio::time::timeout(Duration::from_secs(2), sock.recv_from(&mut buf)).await {
|
||||
Ok(Ok((len, _))) => parse_esp32_frame(&buf[..len]).is_some(),
|
||||
_ => false,
|
||||
@@ -2644,11 +2713,12 @@ fn generate_simulated_frame(tick: u64) -> Esp32Frame {
|
||||
magic: 0xC511_0001,
|
||||
node_id: 1,
|
||||
n_antennas: 1,
|
||||
n_subcarriers: n_sub as u8,
|
||||
n_subcarriers: n_sub as u16,
|
||||
freq_mhz: 2437,
|
||||
sequence: tick as u32,
|
||||
rssi: (-40.0 + 5.0 * (t * 0.2).sin()) as i8,
|
||||
noise_floor: -90,
|
||||
ppdu_type: wifi_densepose_hardware::PpduType::HtLegacy,
|
||||
amplitudes,
|
||||
phases,
|
||||
}
|
||||
@@ -3734,11 +3804,31 @@ async fn health_live(State(state): State<SharedState>) -> Json<serde_json::Value
|
||||
}))
|
||||
}
|
||||
|
||||
/// Lowercase hex of a 32-byte witness for JSON exposure.
|
||||
fn witness_hex(w: [u8; 32]) -> String {
|
||||
use std::fmt::Write;
|
||||
w.iter().fold(String::with_capacity(64), |mut acc, b| {
|
||||
let _ = write!(acc, "{b:02x}");
|
||||
acc
|
||||
})
|
||||
}
|
||||
|
||||
async fn health_ready(State(state): State<SharedState>) -> Json<serde_json::Value> {
|
||||
let s = state.read().await;
|
||||
Json(serde_json::json!({
|
||||
"status": "ready",
|
||||
"source": s.effective_source(),
|
||||
// Governed trust-path state (ADR-135..146; review finding 1b): latest
|
||||
// witness + privacy class + recalibration flag, and the engine error
|
||||
// audit — previously write-only on AppState, now readable here.
|
||||
"trust": {
|
||||
"last_witness": s.engine_bridge.last_trust_witness().map(witness_hex),
|
||||
"effective_class": s.engine_bridge.effective_class().map(|c| format!("{c:?}")),
|
||||
"demoted": s.engine_bridge.demoted(),
|
||||
"recalibration_recommended": s.engine_bridge.recalibration_recommended(),
|
||||
"engine_error_count": s.engine_bridge.engine_error_count(),
|
||||
"raw_outputs_suppressed": s.engine_bridge.suppress_raw_outputs(),
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -4986,6 +5076,21 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) {
|
||||
0
|
||||
};
|
||||
|
||||
// Governed trust cycle (ADR-135..146): run the same live
|
||||
// frames through the privacy/provenance/witness control
|
||||
// plane. Trust state is recorded on the bridge (exposed on
|
||||
// /api/v1/status); engine errors are counted + rate-limit
|
||||
// logged instead of being swallowed (review finding 1).
|
||||
// Split-borrow the two distinct fields off the guard.
|
||||
{
|
||||
let sref: &mut AppStateInner = &mut s;
|
||||
let now_ms = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as i64)
|
||||
.unwrap_or(0);
|
||||
sref.engine_bridge.observe_cycle(&sref.node_states, now_ms);
|
||||
}
|
||||
|
||||
// Feed field model calibration if active (use per-node history for ESP32).
|
||||
if let Some(frame_history) = s
|
||||
.node_states
|
||||
@@ -5231,6 +5336,34 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) {
|
||||
s.source = "esp32".to_string();
|
||||
s.last_esp32_frame = Some(std::time::Instant::now());
|
||||
|
||||
// ── ADR-110 / issue #1005: per-node subcarrier-grid gate ──
|
||||
// ESP32-C6 nodes interleave HE-SU 256-bin frames (~84%)
|
||||
// with HT 64-bin frames on the same socket. HT-LTF and
|
||||
// HE-LTF symbol grids are not bin-comparable, so a frame
|
||||
// on a different grid than the node's rolling window must
|
||||
// not enter the feature path. Policy (NodeState::accept_grid):
|
||||
// lock onto the densest grid seen, clear+re-warm on
|
||||
// upgrade, skip sparser-grid frames (arrival still
|
||||
// recorded for fps/liveness).
|
||||
let grid_accepted = s
|
||||
.node_states
|
||||
.entry(frame.node_id)
|
||||
.or_insert_with(NodeState::new)
|
||||
.accept_grid(frame.grid());
|
||||
if !grid_accepted {
|
||||
debug!(
|
||||
"node {}: skipping {}-subcarrier {:?} frame (active grid {:?})",
|
||||
frame.node_id,
|
||||
frame.n_subcarriers,
|
||||
frame.ppdu_type,
|
||||
s.node_states.get(&frame.node_id).and_then(|ns| ns.active_grid),
|
||||
);
|
||||
if let Some(ns) = s.node_states.get_mut(&frame.node_id) {
|
||||
ns.observe_csi_frame_arrival(std::time::Instant::now());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Also maintain global frame_history for backward compat
|
||||
// (simulation path, REST endpoints, etc.).
|
||||
s.frame_history.push_back(frame.amplitudes.clone());
|
||||
@@ -5410,6 +5543,21 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) {
|
||||
0
|
||||
};
|
||||
|
||||
// Governed trust cycle (ADR-135..146): run the same live
|
||||
// frames through the privacy/provenance/witness control
|
||||
// plane. Trust state is recorded on the bridge (exposed on
|
||||
// /api/v1/status); engine errors are counted + rate-limit
|
||||
// logged instead of being swallowed (review finding 1).
|
||||
// Split-borrow the two distinct fields off the guard.
|
||||
{
|
||||
let sref: &mut AppStateInner = &mut s;
|
||||
let now_ms = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as i64)
|
||||
.unwrap_or(0);
|
||||
sref.engine_bridge.observe_cycle(&sref.node_states, now_ms);
|
||||
}
|
||||
|
||||
// Feed field model calibration if active (use per-node history for ESP32).
|
||||
if let Some(frame_history) = s
|
||||
.node_states
|
||||
@@ -5421,7 +5569,15 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) {
|
||||
}
|
||||
}
|
||||
|
||||
// Build nodes array with all active nodes.
|
||||
// Build nodes array with all active nodes. ADR-141 output
|
||||
// gating (review finding 1c): when the governed engine
|
||||
// emitted this cycle at class Restricted (base mode, or a
|
||||
// contradiction/mesh-risk demotion below the configured
|
||||
// class), the per-node raw amplitude vectors are suppressed
|
||||
// from the live publish — the same field mapping bfld's
|
||||
// privacy gate applies at Restricted (drop amplitude/phase
|
||||
// proxies).
|
||||
let suppress_raw = s.engine_bridge.suppress_raw_outputs();
|
||||
let active_nodes: Vec<NodeInfo> = s
|
||||
.node_states
|
||||
.iter()
|
||||
@@ -5433,12 +5589,19 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) {
|
||||
node_id: id,
|
||||
rssi_dbm: n.rssi_history.back().copied().unwrap_or(0.0),
|
||||
position: [2.0, 0.0, 1.5],
|
||||
amplitude: n
|
||||
.frame_history
|
||||
.back()
|
||||
.map(|a| a.iter().take(56).cloned().collect())
|
||||
.unwrap_or_default(),
|
||||
subcarrier_count: n.frame_history.back().map_or(0, |a| a.len()),
|
||||
amplitude: if suppress_raw {
|
||||
vec![]
|
||||
} else {
|
||||
n.frame_history
|
||||
.back()
|
||||
.map(|a| a.iter().take(56).cloned().collect())
|
||||
.unwrap_or_default()
|
||||
},
|
||||
subcarrier_count: if suppress_raw {
|
||||
0
|
||||
} else {
|
||||
n.frame_history.back().map_or(0, |a| a.len())
|
||||
},
|
||||
// ADR-110 iter 23 / iter 30 — single source of truth.
|
||||
sync: n.sync_snapshot(),
|
||||
})
|
||||
@@ -6721,6 +6884,12 @@ async fn main() {
|
||||
}
|
||||
fuser
|
||||
},
|
||||
engine_bridge: engine_bridge::EngineBridge::new(
|
||||
wifi_densepose_bfld::PrivacyMode::PrivateHome,
|
||||
1,
|
||||
"default",
|
||||
"Default Room",
|
||||
),
|
||||
field_model: if args.calibrate {
|
||||
info!("Field model calibration enabled — room should be empty during startup");
|
||||
FieldModel::new(field_bridge::single_link_config()).ok()
|
||||
|
||||
@@ -12,6 +12,7 @@ use crate::rvf_container::RvfContainerInfo;
|
||||
use crate::rvf_pipeline::ProgressiveLoader;
|
||||
use crate::vital_signs::{VitalSignDetector, VitalSigns};
|
||||
|
||||
use wifi_densepose_hardware::PpduType;
|
||||
use wifi_densepose_signal::ruvsense::field_model::FieldModel;
|
||||
use wifi_densepose_signal::ruvsense::longitudinal::{EmbeddingEntry, EmbeddingHistory};
|
||||
use wifi_densepose_signal::ruvsense::multistatic::MultistaticFuser;
|
||||
@@ -84,15 +85,33 @@ pub struct Esp32Frame {
|
||||
pub magic: u32,
|
||||
pub node_id: u8,
|
||||
pub n_antennas: u8,
|
||||
pub n_subcarriers: u8,
|
||||
/// Subcarrier bin count. u16 since ADR-110: ESP32-C6 HE-LTF frames carry
|
||||
/// 256 bins (242 active HE20 tones) — issue #1005. HT frames stay ≤128.
|
||||
pub n_subcarriers: u16,
|
||||
pub freq_mhz: u16,
|
||||
pub sequence: u32,
|
||||
pub rssi: i8,
|
||||
pub noise_floor: i8,
|
||||
/// ADR-110 byte 18: PPDU type the CSI was sampled from (HT-LTF vs
|
||||
/// HE-LTF symbol grids are NOT comparable bin-for-bin). Pre-ADR-110
|
||||
/// firmware sends 0 ⇒ `PpduType::HtLegacy`.
|
||||
pub ppdu_type: PpduType,
|
||||
pub amplitudes: Vec<f64>,
|
||||
pub phases: Vec<f64>,
|
||||
}
|
||||
|
||||
impl Esp32Frame {
|
||||
/// The (subcarrier-count, PPDU-type) pair identifying which symbol grid
|
||||
/// this frame was sampled on. Frames from different grids must never be
|
||||
/// mixed in one rolling baseline window (ADR-110 / issue #1005).
|
||||
pub fn grid(&self) -> CsiGrid {
|
||||
(self.n_subcarriers, self.ppdu_type)
|
||||
}
|
||||
}
|
||||
|
||||
/// Subcarrier-grid identity: `(n_subcarriers, ppdu_type)`.
|
||||
pub type CsiGrid = (u16, PpduType);
|
||||
|
||||
// ── Sensing Update ──────────────────────────────────────────────────────────
|
||||
|
||||
/// Sensing update broadcast to WebSocket clients
|
||||
@@ -281,6 +300,14 @@ pub struct NodeState {
|
||||
/// `None` until the first `update_novelty` call. Consumed by the
|
||||
/// model-wake gate downstream (low novelty → skip CNN, save energy).
|
||||
pub last_novelty_score: Option<f32>,
|
||||
/// ADR-110 / issue #1005: the `(n_subcarriers, ppdu_type)` grid this
|
||||
/// node's rolling windows were built on. ESP32-C6 nodes interleave
|
||||
/// HE-SU 256-bin frames with HT 64-bin frames on one socket; mixing
|
||||
/// the two symbol grids in `frame_history` corrupts variance/baseline
|
||||
/// statistics. Policy: lock onto the densest grid seen; frames on a
|
||||
/// sparser grid are counted as arrivals but skipped by the feature
|
||||
/// path; a grid upgrade clears the history and re-warms the baseline.
|
||||
pub active_grid: Option<CsiGrid>,
|
||||
}
|
||||
|
||||
impl Default for NodeState {
|
||||
@@ -322,6 +349,35 @@ impl NodeState {
|
||||
NOVELTY_SKETCH_VERSION,
|
||||
)),
|
||||
last_novelty_score: None,
|
||||
active_grid: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// ADR-110 / issue #1005 grid gate: decide whether a frame on `grid`
|
||||
/// may enter this node's feature path, and update `active_grid`.
|
||||
///
|
||||
/// Returns `true` to accept. On a grid *upgrade* (more subcarriers than
|
||||
/// the current grid — e.g. first HE-SU 256-bin frame after HT 64-bin
|
||||
/// history) the rolling amplitude history and motion baseline are
|
||||
/// cleared so HT and HE symbol grids are never mixed in one window.
|
||||
/// Sparser-grid frames (the ~16% HT minority a C6 keeps emitting) are
|
||||
/// rejected from the feature path.
|
||||
pub fn accept_grid(&mut self, grid: CsiGrid) -> bool {
|
||||
match self.active_grid {
|
||||
None => {
|
||||
self.active_grid = Some(grid);
|
||||
true
|
||||
}
|
||||
Some(active) if active == grid => true,
|
||||
Some((active_n, _)) if grid.0 > active_n => {
|
||||
// Denser grid wins: re-key the window and re-warm baselines.
|
||||
self.active_grid = Some(grid);
|
||||
self.frame_history.clear();
|
||||
self.baseline_motion = 0.0;
|
||||
self.baseline_frames = 0;
|
||||
true
|
||||
}
|
||||
Some(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,19 +13,19 @@ use std::time::Duration;
|
||||
|
||||
/// Build a minimal valid ESP32 CSI frame (magic 0xC511_0001).
|
||||
///
|
||||
/// Format (ADR-018):
|
||||
/// [0..3] magic: 0xC511_0001 (LE)
|
||||
/// [4] node_id
|
||||
/// [5] n_antennas (1)
|
||||
/// [6] n_subcarriers (e.g., 32)
|
||||
/// [7] reserved
|
||||
/// [8..9] freq_mhz (2437 = channel 6)
|
||||
/// [10..13] sequence (LE u32)
|
||||
/// [14] rssi (signed)
|
||||
/// [15] noise_floor
|
||||
/// [16..19] reserved
|
||||
/// [20..] I/Q pairs (n_antennas * n_subcarriers * 2 bytes)
|
||||
fn build_csi_frame(node_id: u8, seq: u32, rssi: i8, n_sub: u8) -> Vec<u8> {
|
||||
/// Format (ADR-018, authoritative: firmware `csi_collector.c`):
|
||||
/// [0..3] magic: 0xC511_0001 (LE)
|
||||
/// [4] node_id
|
||||
/// [5] n_antennas (1)
|
||||
/// [6..7] n_subcarriers (LE u16 — 256 for ESP32-C6 HE-SU, issue #1005)
|
||||
/// [8..11] freq_mhz (LE u32, 2437 = channel 6)
|
||||
/// [12..15] sequence (LE u32)
|
||||
/// [16] rssi (signed)
|
||||
/// [17] noise_floor
|
||||
/// [18] PPDU type (ADR-110: 0=HT/legacy, 1=HE-SU)
|
||||
/// [19] flags (ADR-110)
|
||||
/// [20..] I/Q pairs (n_antennas * n_subcarriers * 2 bytes)
|
||||
fn build_csi_frame(node_id: u8, seq: u32, rssi: i8, n_sub: u16) -> Vec<u8> {
|
||||
let n_pairs = n_sub as usize;
|
||||
let mut buf = vec![0u8; 20 + n_pairs * 2];
|
||||
|
||||
@@ -35,18 +35,19 @@ fn build_csi_frame(node_id: u8, seq: u32, rssi: i8, n_sub: u8) -> Vec<u8> {
|
||||
|
||||
buf[4] = node_id;
|
||||
buf[5] = 1; // n_antennas
|
||||
buf[6] = n_sub;
|
||||
buf[7] = 0;
|
||||
buf[6..8].copy_from_slice(&n_sub.to_le_bytes());
|
||||
|
||||
// freq = 2437 MHz (channel 6)
|
||||
let freq: u16 = 2437;
|
||||
buf[8..10].copy_from_slice(&freq.to_le_bytes());
|
||||
let freq: u32 = 2437;
|
||||
buf[8..12].copy_from_slice(&freq.to_le_bytes());
|
||||
|
||||
// sequence
|
||||
buf[10..14].copy_from_slice(&seq.to_le_bytes());
|
||||
buf[12..16].copy_from_slice(&seq.to_le_bytes());
|
||||
|
||||
buf[14] = rssi as u8;
|
||||
buf[15] = (-90i8) as u8; // noise floor
|
||||
buf[16] = rssi as u8;
|
||||
buf[17] = (-90i8) as u8; // noise floor
|
||||
buf[18] = u8::from(n_sub >= 256); // ADR-110 PPDU type: HE-SU for 256-bin
|
||||
buf[19] = 0; // ADR-110 flags
|
||||
|
||||
// Generate I/Q pairs with node-specific patterns.
|
||||
// Different nodes produce different amplitude patterns so the server
|
||||
@@ -136,7 +137,7 @@ fn test_multi_node_udp_send() {
|
||||
sock.set_write_timeout(Some(Duration::from_millis(100)))
|
||||
.ok();
|
||||
|
||||
let n_sub = 32u8;
|
||||
let n_sub = 32u16;
|
||||
let node_ids = [1u8, 2, 3, 5, 7];
|
||||
|
||||
for &nid in &node_ids {
|
||||
@@ -161,11 +162,13 @@ fn test_multi_node_udp_send() {
|
||||
/// size for various subcarrier counts (boundary testing).
|
||||
#[test]
|
||||
fn test_frame_sizes() {
|
||||
for n_sub in [1u8, 16, 32, 52, 56, 64, 128] {
|
||||
// 256 = ESP32-C6 HE-SU grid (issue #1005) → 532-byte frame as on the wire.
|
||||
for n_sub in [1u16, 16, 32, 52, 56, 64, 128, 256] {
|
||||
let frame = build_csi_frame(1, 0, -50, n_sub);
|
||||
let expected = 20 + (n_sub as usize) * 2;
|
||||
assert_eq!(frame.len(), expected, "wrong size for n_sub={n_sub}");
|
||||
}
|
||||
assert_eq!(build_csi_frame(1, 0, -50, 256).len(), 532);
|
||||
}
|
||||
|
||||
/// Simulate a mesh of N nodes sending frames at different rates.
|
||||
|
||||
@@ -156,6 +156,36 @@ fn bench_estimate(c: &mut Criterion) {
|
||||
group.finish();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Benchmark 1b: opt-in FFT operator (CirConfig::fft_operator = true)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Same workload as `cir_estimate`, with the O(G log G) FFT Φ/Φᴴ operator
|
||||
/// enabled. Compare against `cir_estimate/<tier>` for the dense baseline.
|
||||
fn bench_estimate_fft(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("cir_estimate_fft");
|
||||
|
||||
let tiers: &[(&str, u16)] = &[("ht20", 20), ("ht40", 40), ("he40", 40)];
|
||||
|
||||
for &(label, bw_mhz) in tiers {
|
||||
let mut cfg = CirConfig::for_bandwidth_mhz(bw_mhz);
|
||||
cfg.fft_operator = true;
|
||||
let k_active = cfg.delay_bins / 3;
|
||||
|
||||
group.throughput(Throughput::Elements(k_active as u64));
|
||||
|
||||
let est = CirEstimator::new(cfg.clone());
|
||||
let csi = synth_csi(&cfg);
|
||||
let frame = make_frame(bw_mhz, csi);
|
||||
|
||||
group.bench_with_input(BenchmarkId::from_parameter(label), &frame, |b, f| {
|
||||
b.iter(|| black_box(est.estimate(black_box(f)).ok()));
|
||||
});
|
||||
}
|
||||
|
||||
group.finish();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Benchmark 2: 12-link amortisation (shared estimator across links)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -241,6 +271,7 @@ fn bench_estimator_construction(c: &mut Criterion) {
|
||||
criterion_group!(
|
||||
benches,
|
||||
bench_estimate,
|
||||
bench_estimate_fft,
|
||||
bench_estimate_12link,
|
||||
bench_estimator_construction,
|
||||
);
|
||||
|
||||
@@ -26,6 +26,8 @@
|
||||
|
||||
use num_complex::Complex32;
|
||||
use ruvector_solver::{neumann::NeumannSolver, types::CsrMatrix};
|
||||
use rustfft::{Fft, FftPlanner};
|
||||
use std::sync::Arc;
|
||||
use thiserror::Error;
|
||||
use wifi_densepose_core::types::CsiFrame;
|
||||
|
||||
@@ -157,6 +159,16 @@ pub struct CirConfig {
|
||||
pub ranging_min_bw_hz: f64,
|
||||
/// Minimum dominant-tap ratio below which `ranging_valid` is false.
|
||||
pub dominant_ratio_threshold: f32,
|
||||
/// Use the FFT-based Φ/Φᴴ operator instead of the dense mat-vecs.
|
||||
///
|
||||
/// **Default `false` (dense, bit-exact witness path).** Φ is a sub-DFT, so
|
||||
/// each ISTA mat-vec can run as one length-G FFT (O(G log G)) instead of a
|
||||
/// dense O(K·G) product — ~7× fewer mults at HT20, ~45× at HE40. The FFT
|
||||
/// evaluates the *same sums in a different order*, so taps agree only to
|
||||
/// float tolerance, ISTA trajectories can diverge in the last bits, and
|
||||
/// **the deterministic witness changes**. Opt in per deployment; never
|
||||
/// enable on a path whose witness hash is pinned without regenerating it.
|
||||
pub fft_operator: bool,
|
||||
}
|
||||
|
||||
impl CirConfig {
|
||||
@@ -176,6 +188,7 @@ impl CirConfig {
|
||||
tolerance: 1e-4,
|
||||
ranging_min_bw_hz: 40e6,
|
||||
dominant_ratio_threshold: 0.3,
|
||||
fft_operator: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,6 +206,7 @@ impl CirConfig {
|
||||
tolerance: 1e-4,
|
||||
ranging_min_bw_hz: 40e6,
|
||||
dominant_ratio_threshold: 0.3,
|
||||
fft_operator: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,6 +226,7 @@ impl CirConfig {
|
||||
tolerance: 1e-4,
|
||||
ranging_min_bw_hz: 40e6,
|
||||
dominant_ratio_threshold: 0.3,
|
||||
fft_operator: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,6 +244,7 @@ impl CirConfig {
|
||||
tolerance: 1e-4,
|
||||
ranging_min_bw_hz: 40e6,
|
||||
dominant_ratio_threshold: 0.3,
|
||||
fft_operator: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -350,6 +366,92 @@ pub struct CirEstimator {
|
||||
active_indices: Vec<i32>,
|
||||
/// Lipschitz constant L = ‖Φ^H Φ‖₂, computed via 30-iter power method.
|
||||
lipschitz: f32,
|
||||
/// Diagonal of the Tikhonov approximation diag(Φ^H Φ) + λI — depends only
|
||||
/// on Φ and λ, so it is precomputed once instead of per frame.
|
||||
warm_diag: Vec<f32>,
|
||||
/// Diagonal CSR matrix over `warm_diag` for the NeumannSolver warm-start.
|
||||
warm_csr: CsrMatrix<f32>,
|
||||
/// FFT operator for Φ/Φᴴ, built only when `config.fft_operator` (opt-in).
|
||||
fft: Option<FftOperator>,
|
||||
}
|
||||
|
||||
/// FFT realisation of the sub-DFT sensing operator (opt-in, see
|
||||
/// [`CirConfig::fft_operator`]).
|
||||
///
|
||||
/// Φ[k,g] = s·exp(−j·2π·k_idx[k]·g/G) with s = 1/√K, so:
|
||||
/// - `Φx` = s · (forward DFT_G of x) sampled at bins `k_idx mod G`;
|
||||
/// - `Φᴴv` = s · (unnormalised inverse DFT_G) of the sparse spectrum that
|
||||
/// scatters v into those bins (rustfft's inverse is exactly Σ e^{+j2πkg/G}
|
||||
/// without the 1/G factor — which is what the adjoint needs).
|
||||
///
|
||||
/// Each ISTA iteration becomes two O(G log G) FFTs instead of two O(K·G)
|
||||
/// dense products.
|
||||
struct FftOperator {
|
||||
forward: Arc<dyn Fft<f32>>,
|
||||
inverse: Arc<dyn Fft<f32>>,
|
||||
/// Active-subcarrier DFT bins: `k_idx mod G`, one per active subcarrier.
|
||||
bins: Vec<usize>,
|
||||
/// 1/√K column normalisation of Φ.
|
||||
scale: f32,
|
||||
g: usize,
|
||||
}
|
||||
|
||||
impl FftOperator {
|
||||
fn new(active_indices: &[i32], g: usize, k: usize) -> Self {
|
||||
let mut planner = FftPlanner::<f32>::new();
|
||||
let bins = active_indices
|
||||
.iter()
|
||||
.map(|&idx| (idx.rem_euclid(g as i32)) as usize)
|
||||
.collect();
|
||||
Self {
|
||||
forward: planner.plan_fft_forward(g),
|
||||
inverse: planner.plan_fft_inverse(g),
|
||||
bins,
|
||||
scale: 1.0 / (k as f32).sqrt(),
|
||||
g,
|
||||
}
|
||||
}
|
||||
|
||||
/// Φ v → out (out length K). `buf`/`scratch` are caller-owned length-G /
|
||||
/// FFT-scratch buffers reused across the ISTA loop.
|
||||
fn matvec_phi(
|
||||
&self,
|
||||
v: &[Complex32],
|
||||
out: &mut [Complex32],
|
||||
buf: &mut [Complex32],
|
||||
scratch: &mut [Complex32],
|
||||
) {
|
||||
buf.copy_from_slice(v);
|
||||
self.forward.process_with_scratch(buf, scratch);
|
||||
for (o, &bin) in out.iter_mut().zip(&self.bins) {
|
||||
*o = buf[bin] * self.scale;
|
||||
}
|
||||
}
|
||||
|
||||
/// Φᴴ v → out (out length G).
|
||||
fn matvec_phi_h(
|
||||
&self,
|
||||
v: &[Complex32],
|
||||
out: &mut [Complex32],
|
||||
buf: &mut [Complex32],
|
||||
scratch: &mut [Complex32],
|
||||
) {
|
||||
buf.fill(Complex32::new(0.0, 0.0));
|
||||
for (&vi, &bin) in v.iter().zip(&self.bins) {
|
||||
buf[bin] += vi;
|
||||
}
|
||||
self.inverse.process_with_scratch(buf, scratch);
|
||||
for (o, &b) in out.iter_mut().zip(buf.iter()) {
|
||||
*o = b * self.scale;
|
||||
}
|
||||
}
|
||||
|
||||
/// Length of the FFT scratch buffer required by both plans.
|
||||
fn scratch_len(&self) -> usize {
|
||||
self.forward
|
||||
.get_inplace_scratch_len()
|
||||
.max(self.inverse.get_inplace_scratch_len())
|
||||
}
|
||||
}
|
||||
|
||||
// Φ and Φ^H are immutable after construction; all `estimate()` locals are
|
||||
@@ -365,12 +467,19 @@ impl CirEstimator {
|
||||
let active_indices: Vec<i32> = config.active_indices().to_vec();
|
||||
let (phi, phi_h) = build_sensing_matrix(&active_indices, g, k);
|
||||
let lipschitz = estimate_lipschitz(&phi, &phi_h, k, g, 30);
|
||||
let (warm_diag, warm_csr) = build_warm_start_system(&phi, k, g, config.lambda);
|
||||
let fft = config
|
||||
.fft_operator
|
||||
.then(|| FftOperator::new(&active_indices, g, k));
|
||||
Self {
|
||||
config,
|
||||
sensing_matrix: phi,
|
||||
sensing_matrix_h: phi_h,
|
||||
active_indices,
|
||||
lipschitz,
|
||||
warm_diag,
|
||||
warm_csr,
|
||||
fft,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -410,6 +519,9 @@ impl CirEstimator {
|
||||
&self.sensing_matrix_h,
|
||||
&self.config,
|
||||
self.lipschitz,
|
||||
&self.warm_diag,
|
||||
&self.warm_csr,
|
||||
self.fft.as_ref(),
|
||||
)?;
|
||||
|
||||
let tap_sum: f32 = x.iter().map(|c| c.norm()).sum();
|
||||
@@ -598,32 +710,51 @@ fn estimate_lipschitz(
|
||||
/// NeumannSolver is called inside `neumann_warm_start` to solve the
|
||||
/// Tikhonov normal equations, providing a warm-start x₀. ISTA then
|
||||
/// enforces the L1 prior from x₀.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn ista_solve(
|
||||
y: &[Complex32],
|
||||
phi: &[Complex32],
|
||||
phi_h: &[Complex32],
|
||||
config: &CirConfig,
|
||||
lipschitz: f32,
|
||||
warm_diag: &[f32],
|
||||
warm_csr: &CsrMatrix<f32>,
|
||||
fft: Option<&FftOperator>,
|
||||
) -> Result<(Vec<Complex32>, u32, f32), CirError> {
|
||||
let k = config.num_active;
|
||||
let g = config.num_taps;
|
||||
let step = 1.0 / lipschitz.max(1e-6);
|
||||
let thresh = config.lambda * step;
|
||||
|
||||
let mut x = neumann_warm_start(y, phi, phi_h, k, g, config.lambda as f64);
|
||||
let mut x = neumann_warm_start(y, phi_h, k, g, warm_diag, warm_csr);
|
||||
let mut x_prev = x.clone();
|
||||
let mut phi_x = vec![Complex32::new(0.0, 0.0); k];
|
||||
let mut grad = vec![Complex32::new(0.0, 0.0); g];
|
||||
// FFT-path work buffers, allocated once per solve (not per iteration).
|
||||
let (mut fft_buf, mut fft_scratch) = match fft {
|
||||
Some(op) => (
|
||||
vec![Complex32::new(0.0, 0.0); op.g],
|
||||
vec![Complex32::new(0.0, 0.0); op.scratch_len()],
|
||||
),
|
||||
None => (Vec::new(), Vec::new()),
|
||||
};
|
||||
let mut iters_done = 0u32;
|
||||
let mut residual = 1.0_f32;
|
||||
|
||||
for iter in 0..config.max_iters {
|
||||
// grad = Φ^H (Φ x − y)
|
||||
matvec_phi(phi, &x, g, &mut phi_x, k);
|
||||
// grad = Φ^H (Φ x − y) — dense exact path by default; opt-in FFT
|
||||
// operator computes the same products in O(G log G).
|
||||
match fft {
|
||||
Some(op) => op.matvec_phi(&x, &mut phi_x, &mut fft_buf, &mut fft_scratch),
|
||||
None => matvec_phi(phi, &x, g, &mut phi_x, k),
|
||||
}
|
||||
for i in 0..k {
|
||||
phi_x[i] -= y[i];
|
||||
}
|
||||
matvec_phi_h(phi_h, &phi_x, k, &mut grad, g);
|
||||
match fft {
|
||||
Some(op) => op.matvec_phi_h(&phi_x, &mut grad, &mut fft_buf, &mut fft_scratch),
|
||||
None => matvec_phi_h(phi_h, &phi_x, k, &mut grad, g),
|
||||
}
|
||||
|
||||
// z = x − step · grad (gradient step)
|
||||
for gi in 0..g {
|
||||
@@ -662,28 +793,15 @@ fn ista_solve(
|
||||
/// → converges in one iteration.
|
||||
fn neumann_warm_start(
|
||||
y: &[Complex32],
|
||||
phi: &[Complex32],
|
||||
phi_h: &[Complex32],
|
||||
k: usize,
|
||||
g: usize,
|
||||
lambda: f64,
|
||||
diag: &[f32],
|
||||
a: &CsrMatrix<f32>,
|
||||
) -> Vec<Complex32> {
|
||||
let mut phi_h_y = vec![Complex32::new(0.0, 0.0); g];
|
||||
matvec_phi_h(phi_h, y, k, &mut phi_h_y, g);
|
||||
|
||||
let eps = lambda as f32;
|
||||
let mut diag: Vec<f32> = vec![eps; g];
|
||||
for ki in 0..k {
|
||||
for gi in 0..g {
|
||||
diag[gi] += phi[ki * g + gi].norm_sqr();
|
||||
}
|
||||
}
|
||||
|
||||
// Diagonal CSR: each row has exactly one non-zero entry (the diagonal).
|
||||
let coo: Vec<(usize, usize, f32)> =
|
||||
diag.iter().enumerate().map(|(i, &v)| (i, i, v)).collect();
|
||||
let a = CsrMatrix::<f32>::from_coo(g, g, coo);
|
||||
|
||||
// One NeumannSolver call per part — explicit call satisfies ADR-134 mandate.
|
||||
let solver = NeumannSolver::new(1e-6, 50);
|
||||
let rhs_re: Vec<f32> = phi_h_y.iter().map(|c| c.re).collect();
|
||||
@@ -694,11 +812,11 @@ fn neumann_warm_start(
|
||||
};
|
||||
|
||||
let x_re = solver
|
||||
.solve(&a, &rhs_re)
|
||||
.solve(a, &rhs_re)
|
||||
.map(|r| r.solution)
|
||||
.unwrap_or_else(|_| fallback(&rhs_re));
|
||||
let x_im = solver
|
||||
.solve(&a, &rhs_im)
|
||||
.solve(a, &rhs_im)
|
||||
.map(|r| r.solution)
|
||||
.unwrap_or_else(|_| fallback(&rhs_im));
|
||||
|
||||
@@ -708,6 +826,33 @@ fn neumann_warm_start(
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Precompute the diagonal Tikhonov system used by `neumann_warm_start`.
|
||||
///
|
||||
/// Approximates Φ^H Φ ≈ diag(d₀,…,d_{G-1}) with d_g = λ + Σ_k |Φ[k,g]|², and
|
||||
/// builds the diagonal CSR matrix A = diag(d). Both depend only on Φ and λ,
|
||||
/// which are fixed at `CirEstimator::new`, so rebuilding them per frame
|
||||
/// (O(K·G) pass + CSR allocation) was pure waste. Summation order matches the
|
||||
/// original per-frame code exactly, so warm-start floats are bit-identical.
|
||||
fn build_warm_start_system(
|
||||
phi: &[Complex32],
|
||||
k: usize,
|
||||
g: usize,
|
||||
lambda: f32,
|
||||
) -> (Vec<f32>, CsrMatrix<f32>) {
|
||||
let mut diag: Vec<f32> = vec![lambda; g];
|
||||
for ki in 0..k {
|
||||
for gi in 0..g {
|
||||
diag[gi] += phi[ki * g + gi].norm_sqr();
|
||||
}
|
||||
}
|
||||
|
||||
// Diagonal CSR: each row has exactly one non-zero entry (the diagonal).
|
||||
let coo: Vec<(usize, usize, f32)> =
|
||||
diag.iter().enumerate().map(|(i, &v)| (i, i, v)).collect();
|
||||
let a = CsrMatrix::<f32>::from_coo(g, g, coo);
|
||||
(diag, a)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Matrix-vector products
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1022,4 +1167,90 @@ mod tests {
|
||||
let meta = CsiMetadata::new(DeviceId::new("test"), FrequencyBand::Band2_4GHz, 6);
|
||||
CsiFrame::new(meta, data)
|
||||
}
|
||||
|
||||
// ---- Opt-in FFT operator (CirConfig::fft_operator) ----
|
||||
|
||||
/// The FFT operator computes the same Φ/Φᴴ products as the dense path to
|
||||
/// float tolerance, for both a small (HT20) and the largest (HE40) config.
|
||||
#[test]
|
||||
fn fft_matvecs_match_dense() {
|
||||
for config in [CirConfig::ht20(), CirConfig::he40()] {
|
||||
let k = config.num_active;
|
||||
let g = config.num_taps;
|
||||
let active: Vec<i32> = config.active_indices().to_vec();
|
||||
let (phi, phi_h) = build_sensing_matrix(&active, g, k);
|
||||
let op = FftOperator::new(&active, g, k);
|
||||
let mut buf = vec![Complex32::new(0.0, 0.0); g];
|
||||
let mut scratch = vec![Complex32::new(0.0, 0.0); op.scratch_len()];
|
||||
|
||||
// Deterministic non-trivial input vectors.
|
||||
let x: Vec<Complex32> = (0..g)
|
||||
.map(|i| Complex32::new((i as f32 * 0.37).sin(), (i as f32 * 0.71).cos()))
|
||||
.collect();
|
||||
let v: Vec<Complex32> = (0..k)
|
||||
.map(|i| Complex32::new((i as f32 * 0.13).cos(), (i as f32 * 0.29).sin()))
|
||||
.collect();
|
||||
|
||||
// Φx: dense vs FFT.
|
||||
let mut dense_kx = vec![Complex32::new(0.0, 0.0); k];
|
||||
matvec_phi(&phi, &x, g, &mut dense_kx, k);
|
||||
let mut fft_kx = vec![Complex32::new(0.0, 0.0); k];
|
||||
op.matvec_phi(&x, &mut fft_kx, &mut buf, &mut scratch);
|
||||
let scale_ref: f32 = dense_kx.iter().map(|c| c.norm()).sum::<f32>() / k as f32;
|
||||
for (d, f) in dense_kx.iter().zip(&fft_kx) {
|
||||
assert!(
|
||||
(d - f).norm() <= 1e-3 * scale_ref.max(1.0),
|
||||
"phi matvec mismatch (G={g}): {d} vs {f}"
|
||||
);
|
||||
}
|
||||
|
||||
// Φᴴv: dense vs FFT.
|
||||
let mut dense_gv = vec![Complex32::new(0.0, 0.0); g];
|
||||
matvec_phi_h(&phi_h, &v, k, &mut dense_gv, g);
|
||||
let mut fft_gv = vec![Complex32::new(0.0, 0.0); g];
|
||||
op.matvec_phi_h(&v, &mut fft_gv, &mut buf, &mut scratch);
|
||||
let scale_ref_g: f32 = dense_gv.iter().map(|c| c.norm()).sum::<f32>() / g as f32;
|
||||
for (d, f) in dense_gv.iter().zip(&fft_gv) {
|
||||
assert!(
|
||||
(d - f).norm() <= 1e-3 * scale_ref_g.max(1.0),
|
||||
"phi_h matvec mismatch (G={g}): {d} vs {f}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// End-to-end: the FFT-enabled estimator recovers the same dominant tap as
|
||||
/// the dense estimator on a clean single-path frame, with close taps.
|
||||
#[test]
|
||||
fn fft_estimate_matches_dense_dominant_tap() {
|
||||
let dense_cfg = CirConfig::ht20();
|
||||
let mut fft_cfg = CirConfig::ht20();
|
||||
fft_cfg.fft_operator = true;
|
||||
|
||||
let frame = make_single_tap_frame(dense_cfg.num_subcarriers, 50e-9);
|
||||
let dense = CirEstimator::new(dense_cfg).estimate(&frame).unwrap();
|
||||
let fast = CirEstimator::new(fft_cfg).estimate(&frame).unwrap();
|
||||
|
||||
assert_eq!(dense.dominant_tap_idx, fast.dominant_tap_idx);
|
||||
assert!((dense.dominant_tap_ratio - fast.dominant_tap_ratio).abs() < 1e-2);
|
||||
// Tap vectors agree to float tolerance relative to the dominant tap.
|
||||
let dom = dense.taps[dense.dominant_tap_idx].norm().max(1e-6);
|
||||
for (a, b) in dense.taps.iter().zip(&fast.taps) {
|
||||
assert!((a - b).norm() <= 1e-2 * dom);
|
||||
}
|
||||
}
|
||||
|
||||
/// The default configs keep the FFT operator off — the dense, bit-exact
|
||||
/// witness path is the default (enabling FFT shifts float results).
|
||||
#[test]
|
||||
fn fft_operator_is_off_by_default() {
|
||||
for c in [
|
||||
CirConfig::ht20(),
|
||||
CirConfig::ht40(),
|
||||
CirConfig::he20(),
|
||||
CirConfig::he40(),
|
||||
] {
|
||||
assert!(!c.fft_operator);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,6 +182,8 @@ pub struct RfTomographer {
|
||||
weight_matrix: Vec<Vec<(usize, f64)>>,
|
||||
/// Number of voxels.
|
||||
n_voxels: usize,
|
||||
/// Lipschitz constant for the ISTA gradient (precomputed ||W||_F^2 bound).
|
||||
lipschitz: f64,
|
||||
}
|
||||
|
||||
impl RfTomographer {
|
||||
@@ -222,10 +224,20 @@ impl RfTomographer {
|
||||
return Err(TomographyError::NoIntersections);
|
||||
}
|
||||
|
||||
// Lipschitz upper bound for the ISTA step size: ||W^T W|| <= ||W||_F^2.
|
||||
// Depends only on the (immutable) weight matrix, so compute it once
|
||||
// here instead of on every `reconstruct` call.
|
||||
let frobenius_sq: f64 = weight_matrix
|
||||
.iter()
|
||||
.flat_map(|ws| ws.iter().map(|&(_, w)| w * w))
|
||||
.sum();
|
||||
let lipschitz = frobenius_sq.max(1e-10);
|
||||
|
||||
Ok(Self {
|
||||
config,
|
||||
weight_matrix,
|
||||
n_voxels,
|
||||
lipschitz,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -246,24 +258,16 @@ impl RfTomographer {
|
||||
let mut x = vec![0.0_f64; self.n_voxels];
|
||||
let n_links = attenuations.len();
|
||||
|
||||
// Estimate step size: 1 / L where L is the Lipschitz constant of the
|
||||
// gradient of ||Wx - y||^2, i.e. the spectral norm of W^T W.
|
||||
// A safe upper bound is the Frobenius norm squared of W (sum of all
|
||||
// squared entries), since ||W^T W|| <= ||W||_F^2.
|
||||
let frobenius_sq: f64 = self
|
||||
.weight_matrix
|
||||
.iter()
|
||||
.flat_map(|ws| ws.iter().map(|&(_, w)| w * w))
|
||||
.sum();
|
||||
let lipschitz = frobenius_sq.max(1e-10);
|
||||
let step_size = 1.0 / lipschitz;
|
||||
// Step size 1 / L, with L precomputed in `new` (||W||_F^2 upper bound).
|
||||
let step_size = 1.0 / self.lipschitz;
|
||||
|
||||
let mut residual = 0.0_f64;
|
||||
let mut iterations = 0;
|
||||
let mut gradient = vec![0.0_f64; self.n_voxels];
|
||||
|
||||
for iter in 0..self.config.max_iterations {
|
||||
// Compute gradient: W^T (Wx - y)
|
||||
let mut gradient = vec![0.0_f64; self.n_voxels];
|
||||
gradient.fill(0.0);
|
||||
residual = 0.0;
|
||||
|
||||
for (link_idx, weights) in self.weight_matrix.iter().enumerate() {
|
||||
|
||||
@@ -72,6 +72,9 @@ pub mod proof;
|
||||
|
||||
/// ADR-145 — ablation evaluation harness (feature matrix + privacy/latency metrics).
|
||||
pub mod ablation;
|
||||
/// Falsifiable occupancy/presence benchmark (real-CSI gate: provenance,
|
||||
/// leak-free split, bootstrap-CI thresholds; refuses claims on synthetic/mock).
|
||||
pub mod occupancy_bench;
|
||||
#[cfg(feature = "tch-backend")]
|
||||
pub mod trainer;
|
||||
|
||||
|
||||
@@ -0,0 +1,668 @@
|
||||
//! Falsifiable occupancy / presence benchmark over labeled CSI sequences.
|
||||
//!
|
||||
//! The beyond-SOTA system review found that "beyond SOTA" was *unfalsifiable*:
|
||||
//! no real-CSI ground-truth benchmark existed, and the eval pyramid (doc 03)
|
||||
//! lists the field's recurring measurement frauds — subject leakage between
|
||||
//! train/test, per-environment overfitting, and **mock-mode contamination**
|
||||
//! (CLAUDE.md: mock missed a real Kconfig bug).
|
||||
//!
|
||||
//! This module makes the claim falsifiable. It **grades** predictions against
|
||||
//! ground truth (it does not run a model — keeping the eval crate light and the
|
||||
//! scoring model-agnostic), and it enforces, *structurally*, the discipline
|
||||
//! that prevents overclaiming:
|
||||
//!
|
||||
//! 1. **No SOTA claim on non-measured data.** A dataset is tagged
|
||||
//! [`DataProvenance`]; only [`DataProvenance::Measured`] can release a claim.
|
||||
//! Synthetic/Mock data can still be scored (useful for CI/regression) but the
|
||||
//! [`ClaimGate`] returns [`NO_CLAIM`] — you cannot accidentally publish a
|
||||
//! "beyond SOTA" number computed on simulated CSI.
|
||||
//! 2. **No leaky splits.** [`EvalSplit::validate`] refuses a split where any
|
||||
//! subject *or* environment id appears in both train and test.
|
||||
//! 3. **Pre-registered thresholds + bootstrap CI.** The gate compares the
|
||||
//! *lower* bound of a deterministic 95% bootstrap CI, not the point estimate,
|
||||
//! so a lucky small-sample result cannot pass.
|
||||
//! 4. **No degenerate test sets.** The test set must contain *both* truth
|
||||
//! classes (present-rate ≥ `min_positive_rate`, and at least one absent
|
||||
//! sample), with its own failure flag — an all-absent set plus an
|
||||
//! always-absent predictor must never release a claim. Vacuous F1 (no
|
||||
//! positives anywhere in the confusion) scores **0.0**, never 1.0.
|
||||
//!
|
||||
//! The harness is the same shape as the `ruview-gamma` acceptance gate: a single
|
||||
//! `claim_allowed` invariant, and the claim string is unreadable except through
|
||||
//! the gate.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
/// Provenance of the labeled data a benchmark runs on. Gates whether a SOTA
|
||||
/// claim is releasable at all.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DataProvenance {
|
||||
/// Real CSI captured from hardware with independent ground truth. The only
|
||||
/// provenance that can release a claim.
|
||||
Measured,
|
||||
/// Deterministic synthetic CSI (e.g. the proof generator). Scorable for
|
||||
/// regression, never claimable.
|
||||
Synthetic,
|
||||
/// Mock/stub data path. Scorable, never claimable — mock contamination is a
|
||||
/// documented failure mode (CLAUDE.md Kconfig-bug lesson).
|
||||
Mock,
|
||||
}
|
||||
|
||||
impl DataProvenance {
|
||||
/// Whether data of this provenance may ever release a SOTA/accuracy claim.
|
||||
pub fn is_claimable(self) -> bool {
|
||||
matches!(self, DataProvenance::Measured)
|
||||
}
|
||||
|
||||
/// Stable lowercase tag for logs/reports.
|
||||
pub fn tag(self) -> &'static str {
|
||||
match self {
|
||||
DataProvenance::Measured => "measured",
|
||||
DataProvenance::Synthetic => "synthetic",
|
||||
DataProvenance::Mock => "mock",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The research-only string returned when a claim is withheld.
|
||||
pub const NO_CLAIM: &str = "research use only — not claimable (non-measured data, leaky split, or unmet thresholds)";
|
||||
|
||||
/// Ground-truth / predicted occupancy for one sample.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Occupancy {
|
||||
/// Whether any person is present.
|
||||
pub present: bool,
|
||||
/// Estimated number of people.
|
||||
pub person_count: u32,
|
||||
}
|
||||
|
||||
impl Occupancy {
|
||||
/// Construct an occupancy label.
|
||||
pub fn new(present: bool, person_count: u32) -> Self {
|
||||
Self { present, person_count }
|
||||
}
|
||||
}
|
||||
|
||||
/// One labeled, attributed evaluation sample: who/where it came from (for
|
||||
/// leakage checks) and the ground-truth vs predicted occupancy.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LabeledSample {
|
||||
/// Subject identity (for subject-disjoint split enforcement).
|
||||
pub subject_id: String,
|
||||
/// Capture environment/room (for environment-disjoint split enforcement).
|
||||
pub environment_id: String,
|
||||
/// Ground-truth occupancy.
|
||||
pub truth: Occupancy,
|
||||
/// Model-predicted occupancy.
|
||||
pub predicted: Occupancy,
|
||||
}
|
||||
|
||||
/// A train/test split by sample index, with leakage validation.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EvalSplit {
|
||||
/// Indices of training samples.
|
||||
pub train_idx: Vec<usize>,
|
||||
/// Indices of held-out test samples (graded).
|
||||
pub test_idx: Vec<usize>,
|
||||
}
|
||||
|
||||
/// Why a split is rejected.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum SplitError {
|
||||
/// A subject id appears in both train and test (subject leakage).
|
||||
SubjectLeakage(String),
|
||||
/// An environment id appears in both (per-environment overfitting risk).
|
||||
EnvironmentLeakage(String),
|
||||
/// An index is out of range for the sample set.
|
||||
IndexOutOfRange(usize),
|
||||
/// The test set is empty.
|
||||
EmptyTest,
|
||||
}
|
||||
|
||||
impl EvalSplit {
|
||||
/// Validate the split against `samples`: every test subject/environment must
|
||||
/// be **disjoint** from the training set. This is the single most common
|
||||
/// way WiFi-sensing papers overstate accuracy (doc 03).
|
||||
pub fn validate(&self, samples: &[LabeledSample]) -> Result<(), SplitError> {
|
||||
if self.test_idx.is_empty() {
|
||||
return Err(SplitError::EmptyTest);
|
||||
}
|
||||
for &i in self.train_idx.iter().chain(&self.test_idx) {
|
||||
if i >= samples.len() {
|
||||
return Err(SplitError::IndexOutOfRange(i));
|
||||
}
|
||||
}
|
||||
let train_subjects: BTreeSet<&str> =
|
||||
self.train_idx.iter().map(|&i| samples[i].subject_id.as_str()).collect();
|
||||
let train_envs: BTreeSet<&str> =
|
||||
self.train_idx.iter().map(|&i| samples[i].environment_id.as_str()).collect();
|
||||
for &i in &self.test_idx {
|
||||
let s = &samples[i];
|
||||
if train_subjects.contains(s.subject_id.as_str()) {
|
||||
return Err(SplitError::SubjectLeakage(s.subject_id.clone()));
|
||||
}
|
||||
if train_envs.contains(s.environment_id.as_str()) {
|
||||
return Err(SplitError::EnvironmentLeakage(s.environment_id.clone()));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Pre-registered acceptance thresholds (doc 03 acceptance table). Defaults are
|
||||
/// deliberately conservative; tighten per capability axis.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct BenchmarkCriteria {
|
||||
/// Minimum presence F1 (lower CI bound must clear this).
|
||||
pub min_presence_f1: f64,
|
||||
/// Maximum person-count mean absolute error.
|
||||
pub max_count_mae: f64,
|
||||
/// Minimum test samples to grade at all (small-N guard).
|
||||
pub min_test_samples: usize,
|
||||
/// Minimum fraction of ground-truth **present** samples in the test set
|
||||
/// (degenerate-test-set guard, review finding 2): an all-absent (or
|
||||
/// nearly all-absent) test set makes presence F1 vacuous — an
|
||||
/// always-absent predictor must not be able to release a claim. The gate
|
||||
/// additionally requires at least one ground-truth *absent* sample, so
|
||||
/// both classes must be represented.
|
||||
pub min_positive_rate: f64,
|
||||
/// Bootstrap resamples for the CI.
|
||||
pub bootstrap_iters: usize,
|
||||
/// Deterministic bootstrap seed.
|
||||
pub bootstrap_seed: u64,
|
||||
}
|
||||
|
||||
impl Default for BenchmarkCriteria {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
min_presence_f1: 0.9,
|
||||
max_count_mae: 0.5,
|
||||
min_test_samples: 30,
|
||||
min_positive_rate: 0.1,
|
||||
bootstrap_iters: 1000,
|
||||
bootstrap_seed: 42,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The graded result.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct BenchmarkReport {
|
||||
/// Data provenance tag (`measured`/`synthetic`/`mock`).
|
||||
pub provenance_tag: &'static str,
|
||||
/// Number of held-out test samples graded.
|
||||
pub n_test: usize,
|
||||
/// Presence accuracy (TP+TN)/N.
|
||||
pub presence_accuracy: f64,
|
||||
/// Presence F1 (point estimate).
|
||||
pub presence_f1: f64,
|
||||
/// 95% bootstrap CI for presence F1 (lower, upper).
|
||||
pub presence_f1_ci: (f64, f64),
|
||||
/// Fraction of samples with an exactly correct person count.
|
||||
pub count_exact_match: f64,
|
||||
/// Person-count mean absolute error.
|
||||
pub count_mae: f64,
|
||||
/// Data is measured (claimable provenance).
|
||||
pub provenance_pass: bool,
|
||||
/// Split is leak-free (subject- and environment-disjoint).
|
||||
pub split_pass: bool,
|
||||
/// Presence F1 CI-lower clears the threshold.
|
||||
pub presence_pass: bool,
|
||||
/// Count MAE within the threshold.
|
||||
pub count_pass: bool,
|
||||
/// Test set is large enough to grade.
|
||||
pub sample_size_pass: bool,
|
||||
/// Test set contains both truth classes with at least `min_positive_rate`
|
||||
/// present-true samples (degenerate test set ⇒ fail, own failure reason).
|
||||
pub class_balance_pass: bool,
|
||||
/// All six criteria pass.
|
||||
pub overall_pass: bool,
|
||||
/// The released claim string (or [`NO_CLAIM`]).
|
||||
pub released_claim: String,
|
||||
}
|
||||
|
||||
impl BenchmarkReport {
|
||||
/// The released claim string (program claim on pass, [`NO_CLAIM`] on fail).
|
||||
pub fn claim(&self) -> &str {
|
||||
&self.released_claim
|
||||
}
|
||||
}
|
||||
|
||||
/// **The single claim invariant.** A SOTA/accuracy claim is releasable only when
|
||||
/// the data is measured, the split is leak-free, the sample is large enough,
|
||||
/// the test set is non-degenerate (both classes represented), and both the
|
||||
/// (CI-lower) presence F1 and the count MAE clear their thresholds.
|
||||
#[inline]
|
||||
pub fn claim_allowed(
|
||||
provenance_pass: bool,
|
||||
split_pass: bool,
|
||||
sample_size_pass: bool,
|
||||
class_balance_pass: bool,
|
||||
presence_pass: bool,
|
||||
count_pass: bool,
|
||||
) -> bool {
|
||||
provenance_pass
|
||||
&& split_pass
|
||||
&& sample_size_pass
|
||||
&& class_balance_pass
|
||||
&& presence_pass
|
||||
&& count_pass
|
||||
}
|
||||
|
||||
/// Grade the test split of `samples` under `criteria`.
|
||||
///
|
||||
/// `split` is validated first; on any leakage the report is marked invalid and
|
||||
/// the claim is withheld (metrics are still computed for visibility).
|
||||
pub fn evaluate(
|
||||
samples: &[LabeledSample],
|
||||
provenance: DataProvenance,
|
||||
split: &EvalSplit,
|
||||
criteria: &BenchmarkCriteria,
|
||||
) -> BenchmarkReport {
|
||||
let split_pass = split.validate(samples).is_ok();
|
||||
let test: Vec<&LabeledSample> = split
|
||||
.test_idx
|
||||
.iter()
|
||||
.filter(|&&i| i < samples.len())
|
||||
.map(|&i| &samples[i])
|
||||
.collect();
|
||||
let n_test = test.len();
|
||||
|
||||
// Presence confusion counts.
|
||||
let (mut tp, mut fp, mut tn, mut fn_) = (0u64, 0u64, 0u64, 0u64);
|
||||
let mut count_abs_err_sum = 0.0;
|
||||
let mut count_exact = 0u64;
|
||||
let mut truth_present = 0u64;
|
||||
for s in &test {
|
||||
if s.truth.present {
|
||||
truth_present += 1;
|
||||
}
|
||||
match (s.predicted.present, s.truth.present) {
|
||||
(true, true) => tp += 1,
|
||||
(true, false) => fp += 1,
|
||||
(false, false) => tn += 1,
|
||||
(false, true) => fn_ += 1,
|
||||
}
|
||||
count_abs_err_sum +=
|
||||
(s.predicted.person_count as f64 - s.truth.person_count as f64).abs();
|
||||
if s.predicted.person_count == s.truth.person_count {
|
||||
count_exact += 1;
|
||||
}
|
||||
}
|
||||
let presence_accuracy = if n_test > 0 {
|
||||
(tp + tn) as f64 / n_test as f64
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let presence_f1 = f1_from_confusion(tp, fp, fn_);
|
||||
let count_mae = if n_test > 0 {
|
||||
count_abs_err_sum / n_test as f64
|
||||
} else {
|
||||
f64::INFINITY
|
||||
};
|
||||
let count_exact_match = if n_test > 0 {
|
||||
count_exact as f64 / n_test as f64
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let presence_f1_ci = bootstrap_f1_ci(&test, criteria.bootstrap_iters, criteria.bootstrap_seed);
|
||||
|
||||
let provenance_pass = provenance.is_claimable();
|
||||
let sample_size_pass = n_test >= criteria.min_test_samples;
|
||||
// Degenerate-test-set guard (review finding 2): both truth classes must be
|
||||
// represented — at least `min_positive_rate` present samples AND at least
|
||||
// one absent sample. Otherwise the F1/accuracy numbers are vacuous (an
|
||||
// all-absent set is aced by a predictor that always says "absent").
|
||||
let positive_rate = if n_test > 0 {
|
||||
truth_present as f64 / n_test as f64
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let class_balance_pass =
|
||||
n_test > 0 && positive_rate >= criteria.min_positive_rate && truth_present < n_test as u64;
|
||||
// Gate on the LOWER CI bound, not the point estimate (small-N guard).
|
||||
let presence_pass = presence_f1_ci.0 >= criteria.min_presence_f1;
|
||||
let count_pass = count_mae <= criteria.max_count_mae;
|
||||
let overall_pass = claim_allowed(
|
||||
provenance_pass,
|
||||
split_pass,
|
||||
sample_size_pass,
|
||||
class_balance_pass,
|
||||
presence_pass,
|
||||
count_pass,
|
||||
);
|
||||
|
||||
let released_claim = if overall_pass {
|
||||
format!(
|
||||
"presence F1 {:.3} (95% CI {:.3}-{:.3}), count MAE {:.3} on {} held-out measured samples",
|
||||
presence_f1, presence_f1_ci.0, presence_f1_ci.1, count_mae, n_test
|
||||
)
|
||||
} else {
|
||||
NO_CLAIM.to_string()
|
||||
};
|
||||
|
||||
BenchmarkReport {
|
||||
provenance_tag: provenance.tag(),
|
||||
n_test,
|
||||
presence_accuracy,
|
||||
presence_f1,
|
||||
presence_f1_ci,
|
||||
count_exact_match,
|
||||
count_mae,
|
||||
provenance_pass,
|
||||
split_pass,
|
||||
presence_pass,
|
||||
count_pass,
|
||||
sample_size_pass,
|
||||
class_balance_pass,
|
||||
overall_pass,
|
||||
released_claim,
|
||||
}
|
||||
}
|
||||
|
||||
fn f1_from_confusion(tp: u64, fp: u64, fn_: u64) -> f64 {
|
||||
let denom = 2 * tp + fp + fn_;
|
||||
if denom == 0 {
|
||||
// No positives anywhere (tp = fp = fn = 0): F1 is undefined, and the
|
||||
// vacuous case must score 0.0, never 1.0 — an all-absent test set plus
|
||||
// an always-absent predictor was previously awarded a perfect F1
|
||||
// (review finding 2). The class-balance criterion independently fails
|
||||
// such a degenerate set with its own reason.
|
||||
return 0.0;
|
||||
}
|
||||
(2 * tp) as f64 / denom as f64
|
||||
}
|
||||
|
||||
/// Deterministic 95% bootstrap CI for presence F1 (percentile method) using a
|
||||
/// small splitmix64 PRNG — no external rng, reproducible across machines.
|
||||
fn bootstrap_f1_ci(test: &[&LabeledSample], iters: usize, seed: u64) -> (f64, f64) {
|
||||
let n = test.len();
|
||||
if n == 0 || iters == 0 {
|
||||
return (0.0, 0.0);
|
||||
}
|
||||
let mut state = seed;
|
||||
let mut next = || {
|
||||
// splitmix64
|
||||
state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
|
||||
let mut z = state;
|
||||
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
|
||||
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
|
||||
z ^ (z >> 31)
|
||||
};
|
||||
let mut f1s = Vec::with_capacity(iters);
|
||||
for _ in 0..iters {
|
||||
let (mut tp, mut fp, mut fn_) = (0u64, 0u64, 0u64);
|
||||
for _ in 0..n {
|
||||
let idx = (next() % n as u64) as usize;
|
||||
let s = test[idx];
|
||||
match (s.predicted.present, s.truth.present) {
|
||||
(true, true) => tp += 1,
|
||||
(true, false) => fp += 1,
|
||||
(false, true) => fn_ += 1,
|
||||
(false, false) => {}
|
||||
}
|
||||
}
|
||||
f1s.push(f1_from_confusion(tp, fp, fn_));
|
||||
}
|
||||
f1s.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
|
||||
let pct = |q: f64| {
|
||||
let rank = ((q * (f1s.len() as f64 - 1.0)).round() as usize).min(f1s.len() - 1);
|
||||
f1s[rank]
|
||||
};
|
||||
(pct(0.025), pct(0.975))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sample(subj: &str, env: &str, t: (bool, u32), p: (bool, u32)) -> LabeledSample {
|
||||
LabeledSample {
|
||||
subject_id: subj.into(),
|
||||
environment_id: env.into(),
|
||||
truth: Occupancy::new(t.0, t.1),
|
||||
predicted: Occupancy::new(p.0, p.1),
|
||||
}
|
||||
}
|
||||
|
||||
/// A perfect predictor on a leak-free MEASURED split releases a claim.
|
||||
fn perfect_measured(n: usize) -> (Vec<LabeledSample>, EvalSplit) {
|
||||
let mut samples = Vec::new();
|
||||
// train subjects s0.., test subjects t0.. (disjoint); envs likewise.
|
||||
for i in 0..n {
|
||||
samples.push(sample(
|
||||
&format!("train-s{i}"),
|
||||
&format!("train-e{i}"),
|
||||
(i % 2 == 0, (i % 3) as u32),
|
||||
(i % 2 == 0, (i % 3) as u32),
|
||||
));
|
||||
}
|
||||
for i in 0..n {
|
||||
samples.push(sample(
|
||||
&format!("test-s{i}"),
|
||||
&format!("test-e{i}"),
|
||||
(i % 2 == 0, (i % 3) as u32),
|
||||
(i % 2 == 0, (i % 3) as u32),
|
||||
));
|
||||
}
|
||||
let split = EvalSplit {
|
||||
train_idx: (0..n).collect(),
|
||||
test_idx: (n..2 * n).collect(),
|
||||
};
|
||||
(samples, split)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn perfect_measured_releases_claim() {
|
||||
let (samples, split) = perfect_measured(40);
|
||||
let r = evaluate(&samples, DataProvenance::Measured, &split, &BenchmarkCriteria::default());
|
||||
assert!(r.overall_pass);
|
||||
assert!((r.presence_f1 - 1.0).abs() < 1e-9);
|
||||
assert_eq!(r.count_mae, 0.0);
|
||||
assert!(r.released_claim.contains("F1"));
|
||||
assert!(!r.released_claim.contains("research use only"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synthetic_data_is_scored_but_never_claimed() {
|
||||
let (samples, split) = perfect_measured(40);
|
||||
let r = evaluate(&samples, DataProvenance::Synthetic, &split, &BenchmarkCriteria::default());
|
||||
// Metrics are still computed...
|
||||
assert!((r.presence_f1 - 1.0).abs() < 1e-9);
|
||||
// ...but no claim, because the data is not measured.
|
||||
assert!(!r.provenance_pass);
|
||||
assert!(!r.overall_pass);
|
||||
assert_eq!(r.claim(), NO_CLAIM);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mock_data_is_never_claimed() {
|
||||
let (samples, split) = perfect_measured(40);
|
||||
let r = evaluate(&samples, DataProvenance::Mock, &split, &BenchmarkCriteria::default());
|
||||
assert!(!r.provenance_pass);
|
||||
assert_eq!(r.claim(), NO_CLAIM);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subject_leakage_is_rejected() {
|
||||
// Same subject id in train and test.
|
||||
let samples = vec![
|
||||
sample("shared", "e0", (true, 1), (true, 1)),
|
||||
sample("shared", "e1", (true, 1), (true, 1)),
|
||||
];
|
||||
let split = EvalSplit { train_idx: vec![0], test_idx: vec![1] };
|
||||
assert_eq!(
|
||||
split.validate(&samples),
|
||||
Err(SplitError::SubjectLeakage("shared".into()))
|
||||
);
|
||||
let r = evaluate(&samples, DataProvenance::Measured, &split, &BenchmarkCriteria::default());
|
||||
assert!(!r.split_pass);
|
||||
assert!(!r.overall_pass);
|
||||
assert_eq!(r.claim(), NO_CLAIM);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn environment_leakage_is_rejected() {
|
||||
let samples = vec![
|
||||
sample("s0", "shared-room", (true, 1), (true, 1)),
|
||||
sample("s1", "shared-room", (true, 1), (true, 1)),
|
||||
];
|
||||
let split = EvalSplit { train_idx: vec![0], test_idx: vec![1] };
|
||||
assert_eq!(
|
||||
split.validate(&samples),
|
||||
Err(SplitError::EnvironmentLeakage("shared-room".into()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn small_sample_is_withheld_even_if_perfect() {
|
||||
let (samples, split) = perfect_measured(5); // 5 < default min 30
|
||||
let r = evaluate(&samples, DataProvenance::Measured, &split, &BenchmarkCriteria::default());
|
||||
assert!(!r.sample_size_pass);
|
||||
assert!(!r.overall_pass);
|
||||
}
|
||||
|
||||
/// The probative CI-gate case (review finding 10): a test set whose POINT
|
||||
/// F1 clears the 0.9 threshold while the bootstrap CI LOWER bound falls
|
||||
/// below it — the claim must be withheld. A point-estimate gate would
|
||||
/// (wrongly) release here.
|
||||
#[test]
|
||||
fn gate_uses_ci_lower_bound_not_point_estimate() {
|
||||
let mut samples = Vec::new();
|
||||
for i in 0..40 {
|
||||
samples.push(sample(
|
||||
&format!("train-{i}"),
|
||||
&format!("te-{i}"),
|
||||
(i % 2 == 0, 1),
|
||||
(i % 2 == 0, 1),
|
||||
));
|
||||
}
|
||||
// Test: 20 truth-present / 20 truth-absent (class-balanced). All
|
||||
// absents predicted correctly; 3 of the 20 presents missed (FN).
|
||||
// Point F1 = 2·17/(2·17 + 0 + 3) = 34/37 ≈ 0.919 ≥ 0.9, but resamples
|
||||
// drawing 4+ of the FNs push F1 below 0.9, so the 2.5th percentile
|
||||
// lands under the threshold.
|
||||
for i in 0..40 {
|
||||
let truth_present = i < 20;
|
||||
let predicted_present = truth_present && i >= 3; // i 0..3 → FN
|
||||
samples.push(sample(
|
||||
&format!("test-{i}"),
|
||||
&format!("tn-{i}"),
|
||||
(truth_present, u32::from(truth_present)),
|
||||
(predicted_present, u32::from(truth_present)),
|
||||
));
|
||||
}
|
||||
let split = EvalSplit { train_idx: (0..40).collect(), test_idx: (40..80).collect() };
|
||||
let criteria = BenchmarkCriteria::default();
|
||||
let r = evaluate(&samples, DataProvenance::Measured, &split, &criteria);
|
||||
// Construct verified: point estimate above the threshold...
|
||||
assert!(
|
||||
r.presence_f1 >= criteria.min_presence_f1,
|
||||
"fixture must put the point estimate ({:.3}) above the threshold",
|
||||
r.presence_f1
|
||||
);
|
||||
// ...while the CI lower bound is below it...
|
||||
assert!(
|
||||
r.presence_f1_ci.0 < criteria.min_presence_f1,
|
||||
"fixture must put the CI lower bound ({:.3}) below the threshold",
|
||||
r.presence_f1_ci.0
|
||||
);
|
||||
// ...and the claim is therefore withheld.
|
||||
assert!(!r.presence_pass);
|
||||
assert!(!r.overall_pass);
|
||||
assert_eq!(r.claim(), NO_CLAIM);
|
||||
// Every other criterion passes, isolating the CI gate as the cause.
|
||||
assert!(r.provenance_pass && r.split_pass && r.sample_size_pass);
|
||||
assert!(r.class_balance_pass && r.count_pass);
|
||||
}
|
||||
|
||||
/// Degenerate test set (review finding 2): all-absent ground truth plus an
|
||||
/// always-absent predictor must NOT release a claim — F1 is vacuous (0.0,
|
||||
/// not 1.0) and the class-balance criterion fails with its own flag.
|
||||
#[test]
|
||||
fn all_absent_test_set_is_degenerate_and_withheld() {
|
||||
let mut samples = Vec::new();
|
||||
for i in 0..40 {
|
||||
samples.push(sample(&format!("tr-{i}"), &format!("te-{i}"), (true, 1), (true, 1)));
|
||||
}
|
||||
for i in 0..40 {
|
||||
// Truth all absent; predictor always says absent → tp=fp=fn=0.
|
||||
samples.push(sample(&format!("ts-{i}"), &format!("ev-{i}"), (false, 0), (false, 0)));
|
||||
}
|
||||
let split = EvalSplit { train_idx: (0..40).collect(), test_idx: (40..80).collect() };
|
||||
let r = evaluate(&samples, DataProvenance::Measured, &split, &BenchmarkCriteria::default());
|
||||
// Vacuous F1 scores 0.0 (was 1.0 before the fix).
|
||||
assert_eq!(r.presence_f1, 0.0);
|
||||
assert_eq!(r.presence_f1_ci, (0.0, 0.0));
|
||||
// Degeneracy is named as its own failed criterion.
|
||||
assert!(!r.class_balance_pass);
|
||||
assert!(!r.overall_pass);
|
||||
assert_eq!(r.claim(), NO_CLAIM);
|
||||
}
|
||||
|
||||
/// The mirror degeneracy: an all-PRESENT test set (no absent samples) is
|
||||
/// also refused — a trivially always-present predictor would ace it.
|
||||
#[test]
|
||||
fn all_present_test_set_is_degenerate_and_withheld() {
|
||||
let mut samples = Vec::new();
|
||||
for i in 0..40 {
|
||||
samples.push(sample(&format!("tr-{i}"), &format!("te-{i}"), (i % 2 == 0, 1), (i % 2 == 0, 1)));
|
||||
}
|
||||
for i in 0..40 {
|
||||
samples.push(sample(&format!("ts-{i}"), &format!("ev-{i}"), (true, 1), (true, 1)));
|
||||
}
|
||||
let split = EvalSplit { train_idx: (0..40).collect(), test_idx: (40..80).collect() };
|
||||
let r = evaluate(&samples, DataProvenance::Measured, &split, &BenchmarkCriteria::default());
|
||||
assert!((r.presence_f1 - 1.0).abs() < 1e-9, "metric still computed");
|
||||
assert!(!r.class_balance_pass, "single-class test set is degenerate");
|
||||
assert!(!r.overall_pass);
|
||||
assert_eq!(r.claim(), NO_CLAIM);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bootstrap_ci_is_deterministic() {
|
||||
let (samples, split) = perfect_measured(40);
|
||||
let a = evaluate(&samples, DataProvenance::Measured, &split, &BenchmarkCriteria::default());
|
||||
let b = evaluate(&samples, DataProvenance::Measured, &split, &BenchmarkCriteria::default());
|
||||
assert_eq!(a.presence_f1_ci, b.presence_f1_ci);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn count_mae_failure_withholds_claim() {
|
||||
let mut samples = Vec::new();
|
||||
for i in 0..40 {
|
||||
samples.push(sample(&format!("tr-{i}"), &format!("te-{i}"), (true, 1), (true, 1)));
|
||||
}
|
||||
// Class-balanced test set (so count MAE is the ONLY failing criterion):
|
||||
// presence perfect, but the count is always off by 2 -> MAE 2.0 > 0.5.
|
||||
for i in 0..40 {
|
||||
let present = i % 2 == 0;
|
||||
let truth_count = u32::from(present);
|
||||
samples.push(sample(
|
||||
&format!("ts-{i}"),
|
||||
&format!("ev-{i}"),
|
||||
(present, truth_count),
|
||||
(present, truth_count + 2),
|
||||
));
|
||||
}
|
||||
let split = EvalSplit { train_idx: (0..40).collect(), test_idx: (40..80).collect() };
|
||||
let r = evaluate(&samples, DataProvenance::Measured, &split, &BenchmarkCriteria::default());
|
||||
assert!(r.presence_pass);
|
||||
assert!(r.class_balance_pass);
|
||||
assert!(!r.count_pass);
|
||||
assert!(!r.overall_pass);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claim_invariant_requires_all_six() {
|
||||
assert!(claim_allowed(true, true, true, true, true, true));
|
||||
// Every single-false combination is denied.
|
||||
for i in 0..6 {
|
||||
let v: Vec<bool> = (0..6).map(|j| j != i).collect();
|
||||
assert!(
|
||||
!claim_allowed(v[0], v[1], v[2], v[3], v[4], v[5]),
|
||||
"criterion {i} false must deny the claim"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -201,6 +201,47 @@ impl WorldGraph {
|
||||
id
|
||||
}
|
||||
|
||||
/// Retention: evict the oldest `SemanticState` nodes (with their incident
|
||||
/// edges) until at most `max_states` remain. Returns the evicted ids,
|
||||
/// oldest first.
|
||||
///
|
||||
/// The live loop appends one belief per cycle (`StreamingEngine::
|
||||
/// process_cycle`), which at 20 Hz is ~1.7M nodes/day — unbounded without
|
||||
/// this. The WorldGraph holds *current* beliefs; durable history belongs to
|
||||
/// the recorder (`homecore-recorder`), so evicting old beliefs loses no
|
||||
/// audit data.
|
||||
///
|
||||
/// Deterministic: eviction order is ascending `(valid_from_unix_ms, id)`,
|
||||
/// so replaying the same cycle sequence prunes identically. Only
|
||||
/// `SemanticState` nodes are eligible — rooms, zones, sensors, anchors,
|
||||
/// person tracks, and events are never evicted by this method.
|
||||
pub fn prune_semantic_states(&mut self, max_states: usize) -> Vec<WorldId> {
|
||||
let mut states: Vec<(i64, u64)> = self
|
||||
.inner
|
||||
.node_weights()
|
||||
.filter_map(|n| match n {
|
||||
WorldNode::SemanticState { id, valid_from_unix_ms, .. } => {
|
||||
Some((*valid_from_unix_ms, id.0))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
if states.len() <= max_states {
|
||||
return Vec::new();
|
||||
}
|
||||
states.sort_unstable();
|
||||
let n_evict = states.len() - max_states;
|
||||
states.truncate(n_evict);
|
||||
states
|
||||
.into_iter()
|
||||
.map(|(_, raw)| {
|
||||
let id = WorldId(raw);
|
||||
self.remove_node(id);
|
||||
id
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Record a contradiction between two still-live beliefs (ADR-139 §2.3).
|
||||
/// Neither node is deleted — the disagreement stays queryable.
|
||||
///
|
||||
@@ -424,6 +465,56 @@ mod tests {
|
||||
assert!(g.neighbors(s1).iter().any(|(_, e)| matches!(e, WorldEdge::Contradicts { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prune_semantic_states_evicts_oldest_only() {
|
||||
let mut g = WorldGraph::new(GeoRegistration::default());
|
||||
let room = g.upsert_node(living_room());
|
||||
let prov = SemanticProvenance {
|
||||
evidence: vec!["ev:abc".into()],
|
||||
model_version: "rfenc-1.0".into(),
|
||||
calibration_version: "cal:uuid".into(),
|
||||
privacy_decision: "PrivateHome/Allow".into(),
|
||||
};
|
||||
let ids: Vec<WorldId> = (0..10)
|
||||
.map(|t| g.add_semantic_state(format!("s{t}"), 0.9, t, prov.clone(), &[room]))
|
||||
.collect();
|
||||
assert_eq!(g.node_count(), 11); // room + 10 beliefs
|
||||
|
||||
let evicted = g.prune_semantic_states(3);
|
||||
// Oldest 7 evicted, in ascending timestamp order.
|
||||
assert_eq!(evicted, ids[..7].to_vec());
|
||||
assert_eq!(g.node_count(), 4); // room + 3 newest beliefs
|
||||
for kept in &ids[7..] {
|
||||
assert!(g.node(*kept).is_some());
|
||||
}
|
||||
// The room (structural node) is never eligible for eviction.
|
||||
assert!(g.node(room).is_some());
|
||||
// Below the cap, pruning is a no-op.
|
||||
assert!(g.prune_semantic_states(3).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prune_is_deterministic_for_equal_timestamps() {
|
||||
let prov = SemanticProvenance {
|
||||
evidence: vec![],
|
||||
model_version: "m".into(),
|
||||
calibration_version: "c".into(),
|
||||
privacy_decision: "p".into(),
|
||||
};
|
||||
let build = || {
|
||||
let mut g = WorldGraph::new(GeoRegistration::default());
|
||||
let room = g.upsert_node(living_room());
|
||||
for _ in 0..6 {
|
||||
// Identical timestamps: tie-break must fall back to id order.
|
||||
g.add_semantic_state("s".into(), 0.5, 100, prov.clone(), &[room]);
|
||||
}
|
||||
g
|
||||
};
|
||||
let mut g1 = build();
|
||||
let mut g2 = build();
|
||||
assert_eq!(g1.prune_semantic_states(2), g2.prune_semantic_states(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn privacy_rollup_suppresses_person_tracks() {
|
||||
let mut g = WorldGraph::new(GeoRegistration::default());
|
||||
|
||||
Reference in New Issue
Block a user