mirror of
https://github.com/ruvnet/RuView
synced 2026-07-27 18:11:43 +00:00
fix(ruview-unified): panics, NaN corruption, entity-conflation, and wrong center-freq found in review
Deep review of PR #1437 (ADR-273..282 unified RF spatial world model) plus hardware-in-the-loop testing against a live ESP32-C6 CSI node turned up several real defects, fixed here: - pretrain.rs: sample_mask panicked (usize::clamp(1, 0)) on any single-token window, reachable from a valid RfTensor via a perfectly normal tokenizer output. eval() now skips empty masks instead of averaging in NaN. - math.rs: resample_complex(x, 1) with x.len() > 1 divided by zero (m - 1 == 0), silently poisoning the output with NaN. Now returns the mean. - gaussian/map.rs: merge_overlapping had no entity-kind guard (unlike insert()), so an unlabeled Room-linked Gaussian and an unlabeled PersonClass-linked Gaussian within each other's merge gate would be silently conflated. Added the same same_kind check insert() uses. Also hardened decay()'s tau_eff against a post-construction decay_tau_s of 0 (NaN instead of merely-fast decay). - adapters.rs: WifiCsiAdapter used the frequency band's fixed per-band constant (e.g. 2437 MHz) instead of the frame's real channel, misreporting center_freq_hz for every channel except the one that happens to match the constant. Confirmed against a live ESP32-C6 node on channel 4: pre-fix would report 2437000000 Hz, post-fix correctly reports 2427000000 Hz, matching the hardware parser's independently-computed frequency exactly. Added examples/esp32_live_hardware_test.rs, a hardware-in-the-loop test that bridges real ADR-018 UDP captures through the adapter (also confirms no panic on real 256-subcarrier HE-SU frames, well beyond CANONICAL_BINS=56). - control.rs: admit_task didn't validate requested_resolution_m, maximum_latency_ms, or modalities, so a task with 0/NaN resolution, 0ms latency, or zero modalities passed admission. Added boundary checks. - control.rs + security_boundaries.rs: validate_representation's only test coverage (unit test and proptest) hardcoded SensingPurpose::Presence, leaving the other three purpose-ceiling branches (Activity/Localization at P3, Vitals/PoseTracking at P4, IdentityRecognition at P5 — the higher-risk representations) completely unverified. Added coverage for all branches in both. Also fixed pre-existing issues surfaced while validating the above: - wifi-densepose-core: 7 clippy warnings (cast_possible_truncation/ wrap, single_match_else, suboptimal_flops) in the canonical encode/decode path, now using try_from/from_le_bytes/mul_add. - wifi-densepose-hardware: a test missing #[cfg(unix)] that used std::os::unix::fs::PermissionsExt unconditionally, breaking Windows builds of ruview-auth's test suite; a manual Default impl clippy flagged as derivable; two tests using field-reassignment instead of struct-update syntax after ::default(). - wifi-densepose-sensing-server: auth_wiring.rs's free_port() / child-process bind race (documented as "mildly racy" by design) now retries up to 3x specifically on an AddrInUse-shaped failure, preserving the original fail-loud behavior for genuine wiring regressions. All touched crates re-verified: ruview-unified 99 tests (was 98), wifi-densepose-core 37+40, wifi-densepose-hardware 483+1(ignored), ruview-auth builds and tests on Windows, sensing-server auth_wiring 7/7. ruview-unified remains clippy-clean under -D warnings; the pre-existing dependency warnings that -D warnings surfaced are fixed too. Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
Generated
+1
@@ -7688,6 +7688,7 @@ dependencies = [
|
||||
"serde",
|
||||
"thiserror 2.0.18",
|
||||
"wifi-densepose-core",
|
||||
"wifi-densepose-hardware",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -588,6 +588,7 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn the_temp_file_is_never_world_readable_even_for_an_instant() {
|
||||
// The test above checks the FINAL file. It passed while `save` wrote via
|
||||
|
||||
@@ -31,6 +31,11 @@ rand_chacha = { version = "0.3", default-features = false }
|
||||
[dev-dependencies]
|
||||
criterion = { workspace = true }
|
||||
proptest = { workspace = true }
|
||||
# Dev-only: bridges real ESP32 ADR-018 UDP captures (wifi-densepose-hardware's
|
||||
# already-proven Esp32CsiParser) into wifi_densepose_core::CsiFrame for the
|
||||
# `esp32_live_hardware_test` example. Does not affect the published dependency
|
||||
# graph — the crate's real dependency stays thin-dependency (see above).
|
||||
wifi-densepose-hardware = { workspace = true }
|
||||
|
||||
[[bench]]
|
||||
name = "unified_bench"
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
//! Hardware-in-the-loop test: feeds REAL ADR-018 CSI frames from a live
|
||||
//! ESP32 node through `WifiCsiAdapter`, not synthetic data.
|
||||
//!
|
||||
//! The PR that introduced this crate is explicit that every reported number
|
||||
//! is generator-produced (L0) and real-data validation (P2) is future work.
|
||||
//! This example closes part of that gap for the one adapter that matters
|
||||
//! most: it binds the real ADR-018 UDP port, parses live packets with the
|
||||
//! already-proven `wifi_densepose_hardware::Esp32CsiParser` (the same parser
|
||||
//! `aggregator`/`sensing-server` use in production), converts each frame into
|
||||
//! the exact `wifi_densepose_core::types::CsiFrame` the adapter expects, and
|
||||
//! runs it through `AdapterRegistry::normalize`.
|
||||
//!
|
||||
//! Usage: `cargo run -p ruview-unified --example esp32_live_hardware_test --
|
||||
//! --bind 0.0.0.0:5005 --frames 8`
|
||||
//! (point a live ESP32 CSI node's UDP target at this host's IP:5005 first).
|
||||
|
||||
use std::net::UdpSocket;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use ndarray::Array2;
|
||||
use num_complex::Complex64;
|
||||
use wifi_densepose_core::types::{AntennaConfig, CsiFrame as CoreCsiFrame, CsiMetadata, DeviceId, FrequencyBand};
|
||||
use wifi_densepose_hardware::{Esp32CsiParser, ParseError};
|
||||
|
||||
use ruview_unified::adapters::{AdapterRegistry, RawCapture};
|
||||
use ruview_unified::tensor::LinkGeometry;
|
||||
|
||||
/// Inverse of `ruview_unified::adapters`'s (now-fixed) channel->frequency
|
||||
/// map: recovers the 802.11 channel number from the real per-frame
|
||||
/// `channel_freq_mhz` the hardware parser already computes correctly. Used
|
||||
/// here only to populate `CsiMetadata::channel` for the adapter under test —
|
||||
/// exercising the fix end-to-end against a real, independently-computed
|
||||
/// frequency instead of a value this same test invented.
|
||||
fn freq_mhz_to_band_and_channel(freq_mhz: u32) -> (FrequencyBand, u8) {
|
||||
if freq_mhz == 2484 {
|
||||
(FrequencyBand::Band2_4GHz, 14)
|
||||
} else if (2412..=2472).contains(&freq_mhz) {
|
||||
(FrequencyBand::Band2_4GHz, ((freq_mhz - 2407) / 5) as u8)
|
||||
} else if (5000..6000).contains(&freq_mhz) {
|
||||
(FrequencyBand::Band5GHz, ((freq_mhz - 5000) / 5) as u8)
|
||||
} else {
|
||||
(FrequencyBand::Band6GHz, ((freq_mhz.saturating_sub(5950)) / 5) as u8)
|
||||
}
|
||||
}
|
||||
|
||||
fn to_core_frame(hw: wifi_densepose_hardware::CsiFrame) -> CoreCsiFrame {
|
||||
let (band, channel) = freq_mhz_to_band_and_channel(hw.metadata.channel_freq_mhz);
|
||||
let mut meta = CsiMetadata::new(DeviceId::new(format!("esp32-node-{}", hw.metadata.node_id)), band, channel);
|
||||
meta.bandwidth_mhz = match hw.metadata.bandwidth {
|
||||
wifi_densepose_hardware::Bandwidth::Bw20 => 20,
|
||||
wifi_densepose_hardware::Bandwidth::Bw40 => 40,
|
||||
wifi_densepose_hardware::Bandwidth::Bw80 => 80,
|
||||
wifi_densepose_hardware::Bandwidth::Bw160 => 160,
|
||||
};
|
||||
meta.antenna_config = AntennaConfig::new(1, hw.metadata.n_antennas.max(1));
|
||||
meta.rssi_dbm = hw.metadata.rssi_dbm;
|
||||
meta.noise_floor_dbm = hw.metadata.noise_floor_dbm;
|
||||
meta.sequence_number = hw.metadata.sequence;
|
||||
|
||||
// Single spatial stream (n_antennas isn't broken out per-antenna in the
|
||||
// ADR-018 wire format consumed here) x real subcarrier count.
|
||||
let n_bins = hw.subcarriers.len().max(1);
|
||||
let data = Array2::from_shape_fn((1, n_bins), |(_, b)| {
|
||||
hw.subcarriers.get(b).map_or(Complex64::new(0.0, 0.0), |sc| {
|
||||
Complex64::new(f64::from(sc.i), f64::from(sc.q))
|
||||
})
|
||||
});
|
||||
CoreCsiFrame::new(meta, data)
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let bind = std::env::args()
|
||||
.collect::<Vec<_>>()
|
||||
.windows(2)
|
||||
.find(|w| w[0] == "--bind")
|
||||
.map_or_else(|| "0.0.0.0:5005".to_string(), |w| w[1].clone());
|
||||
let want_frames: usize = std::env::args()
|
||||
.collect::<Vec<_>>()
|
||||
.windows(2)
|
||||
.find(|w| w[0] == "--frames")
|
||||
.and_then(|w| w[1].parse().ok())
|
||||
.unwrap_or(8);
|
||||
|
||||
let socket = UdpSocket::bind(&bind).expect("bind UDP socket");
|
||||
socket.set_read_timeout(Some(std::time::Duration::from_secs(30))).unwrap();
|
||||
eprintln!("Listening on {bind} for real ESP32 ADR-018 CSI frames (need {want_frames})...");
|
||||
|
||||
let mut buf = [0u8; 2048];
|
||||
let mut core_frames: Vec<CoreCsiFrame> = Vec::new();
|
||||
let mut real_channel_freq_hz: Option<f64> = None;
|
||||
|
||||
while core_frames.len() < want_frames {
|
||||
let (n, _src) = socket.recv_from(&mut buf).expect("recv (timed out — is a live node targeting this host?)");
|
||||
match Esp32CsiParser::parse_frame(&buf[..n]) {
|
||||
Ok((hw_frame, _consumed)) => {
|
||||
// Lock onto the first node/shape seen so all frames in the
|
||||
// window share (n_links, n_bins), as the adapter requires.
|
||||
if let Some(first) = core_frames.first() {
|
||||
let n_bins = hw_frame.subcarriers.len();
|
||||
if n_bins != first.num_subcarriers() {
|
||||
eprintln!(" [skip: shape changed mid-window ({n_bins} vs {})]", first.num_subcarriers());
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if real_channel_freq_hz.is_none() {
|
||||
real_channel_freq_hz = Some(f64::from(hw_frame.metadata.channel_freq_mhz) * 1e6);
|
||||
}
|
||||
eprintln!(
|
||||
" [captured frame {}: sc={} rssi={} node={}]",
|
||||
core_frames.len() + 1,
|
||||
hw_frame.subcarriers.len(),
|
||||
hw_frame.metadata.rssi_dbm,
|
||||
hw_frame.metadata.node_id,
|
||||
);
|
||||
core_frames.push(to_core_frame(hw_frame));
|
||||
}
|
||||
Err(ParseError::NonCsiPacket { .. }) => {}
|
||||
Err(e) => eprintln!(" [parse error: {e}]"),
|
||||
}
|
||||
}
|
||||
|
||||
let raw = RawCapture::WifiCsi {
|
||||
frames: core_frames,
|
||||
links: vec![LinkGeometry { tx_pos: [0.0, 0.0, 1.0], rx_pos: [3.0, 0.0, 1.0] }],
|
||||
age_s: 0.05,
|
||||
clock_quality: 0.5, // free-running ESP32 crystal, not disciplined — honest, not 1.0
|
||||
};
|
||||
|
||||
let registry = AdapterRegistry::with_reference_adapters();
|
||||
let now_ns = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos() as u64;
|
||||
match registry.normalize("esp32s3-csi", &raw) {
|
||||
Ok(tensor) => {
|
||||
let real_hz = real_channel_freq_hz.unwrap();
|
||||
println!("=== RfTensor from REAL ESP32 hardware (not synthetic) ===");
|
||||
println!("dims (links, bins, snapshots): {:?}", tensor.data.dim());
|
||||
println!("adapter-computed center_freq_hz : {:.0}", tensor.center_freq_hz);
|
||||
println!("real per-frame channel_freq_hz : {:.0} (from hardware parser)", real_hz);
|
||||
println!(
|
||||
"match within 1 MHz: {} (this is the ADR-018 channel-aware fix — the pre-fix \
|
||||
code always reported the fixed-band constant, 2437000000 Hz for 2.4 GHz, \
|
||||
regardless of the real channel)",
|
||||
(tensor.center_freq_hz - real_hz).abs() < 1e6
|
||||
);
|
||||
println!("bandwidth_hz: {:.0}", tensor.bandwidth_hz);
|
||||
println!("uncertainty (from real SNR): {:.3}", tensor.uncertainty);
|
||||
println!("device_id: {}", tensor.device_id);
|
||||
println!("timestamp_ns (now, for reference): {now_ns}");
|
||||
println!("No panic on real hardware data, including subcarrier counts != CANONICAL_BINS=56.");
|
||||
}
|
||||
Err(e) => {
|
||||
println!("ADAPTER REJECTED real hardware data: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@ use std::collections::HashMap;
|
||||
|
||||
use ndarray::{Array3, Axis};
|
||||
use num_complex::Complex64;
|
||||
use wifi_densepose_core::types::CsiFrame;
|
||||
use wifi_densepose_core::types::{CsiFrame, FrequencyBand};
|
||||
|
||||
use crate::math::{linear_slope, median, resample_complex};
|
||||
use crate::tensor::{
|
||||
@@ -378,6 +378,28 @@ impl WifiCsiAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
/// IEEE 802.11 channel-number → center-frequency, in MHz.
|
||||
///
|
||||
/// `CsiMetadata::frequency_band` alone only identifies which ~100 MHz-wide
|
||||
/// band a capture came from (a fixed per-band constant), not the actual
|
||||
/// channel — using the band constant directly misreports every channel
|
||||
/// except the one it happens to match (2.4 GHz channel 6, 5 GHz channel 36),
|
||||
/// by up to tens of MHz on 2.4 GHz and hundreds of MHz on 5/6 GHz. Falls back
|
||||
/// to the band constant only when the channel number is out of the known
|
||||
/// range (e.g. `0`, meaning "unknown").
|
||||
fn channel_center_freq_mhz(band: FrequencyBand, channel: u8) -> f64 {
|
||||
match band {
|
||||
FrequencyBand::Band2_4GHz => match channel {
|
||||
1..=13 => 2407.0 + 5.0 * f64::from(channel),
|
||||
14 => 2484.0,
|
||||
_ => f64::from(band.center_frequency_mhz()),
|
||||
},
|
||||
FrequencyBand::Band5GHz if channel > 0 => 5000.0 + 5.0 * f64::from(channel),
|
||||
FrequencyBand::Band6GHz if channel > 0 => 5950.0 + 5.0 * f64::from(channel),
|
||||
FrequencyBand::Band5GHz | FrequencyBand::Band6GHz => f64::from(band.center_frequency_mhz()),
|
||||
}
|
||||
}
|
||||
|
||||
impl RfAdapter for WifiCsiAdapter {
|
||||
fn modality(&self) -> RfModality {
|
||||
RfModality::WifiCsi
|
||||
@@ -419,7 +441,7 @@ impl RfAdapter for WifiCsiAdapter {
|
||||
let snr_db =
|
||||
frames.iter().map(|f| f.metadata.snr_db()).sum::<f64>() / frames.len() as f64;
|
||||
let uncertainty = (1.0 - snr_db / 40.0).clamp(0.0, 1.0);
|
||||
let center_freq_hz = f64::from(meta.frequency_band.center_frequency_mhz()) * 1e6;
|
||||
let center_freq_hz = channel_center_freq_mhz(meta.frequency_band, meta.channel) * 1e6;
|
||||
let ts = &meta.timestamp;
|
||||
let timestamp_ns = u64::try_from(ts.seconds).unwrap_or(0) * 1_000_000_000 + u64::from(ts.nanos);
|
||||
normalize_grid(
|
||||
|
||||
@@ -114,6 +114,15 @@ pub fn admit_task(engine: &PolicyEngine, task: &SensingTask) -> Result<()> {
|
||||
if !(task.minimum_confidence.is_finite() && (0.0..=1.0).contains(&task.minimum_confidence)) {
|
||||
return Err(UnifiedError::InvalidInput("minimum_confidence must be in [0,1]".into()));
|
||||
}
|
||||
if !(task.requested_resolution_m.is_finite() && task.requested_resolution_m > 0.0) {
|
||||
return Err(UnifiedError::InvalidInput("requested_resolution_m must be finite and > 0".into()));
|
||||
}
|
||||
if task.maximum_latency_ms == 0 {
|
||||
return Err(UnifiedError::InvalidInput("maximum_latency_ms must be > 0".into()));
|
||||
}
|
||||
if task.modalities.is_empty() {
|
||||
return Err(UnifiedError::InvalidInput("a sensing task must declare at least one modality".into()));
|
||||
}
|
||||
if task.purpose == SensingPurpose::IdentityRecognition && task.consent_reference.is_none() {
|
||||
return Err(UnifiedError::PolicyDenied(
|
||||
"identity recognition tasks require a consent reference".into(),
|
||||
@@ -663,4 +672,46 @@ mod tests {
|
||||
orphan.source_receipts.clear();
|
||||
assert!(validate_representation(&orphan, SensingPurpose::Presence).is_err());
|
||||
}
|
||||
|
||||
/// Only `Presence` was ever exercised above; the other three
|
||||
/// ceiling/exclusion-set branches (Activity/Localization at P3,
|
||||
/// Vitals/PoseTracking at P4, IdentityRecognition at P5) had zero test
|
||||
/// coverage — a bug in any of them would go undetected.
|
||||
#[test]
|
||||
fn task_sufficient_representation_covers_every_purpose_branch() {
|
||||
let rep = |class: PrivacyClass, excluded: &[&str]| TaskSufficientRepresentation {
|
||||
task_id: 6,
|
||||
source_receipts: vec![1],
|
||||
semantic_state: vec![0.2],
|
||||
information_bound_bits: 4.0,
|
||||
excluded_information: excluded.iter().map(|s| (*s).to_string()).collect(),
|
||||
privacy_class: class,
|
||||
};
|
||||
|
||||
for purpose in [SensingPurpose::Activity, SensingPurpose::Localization] {
|
||||
// P3 ceiling excluding identity: fine.
|
||||
assert!(validate_representation(&rep(PrivacyClass::P3, &["identity"]), purpose).is_ok());
|
||||
// Forgot to exclude identity: deny.
|
||||
assert!(validate_representation(&rep(PrivacyClass::P3, &[]), purpose).is_err());
|
||||
// Above the P3 ceiling: deny.
|
||||
assert!(validate_representation(&rep(PrivacyClass::P4, &["identity"]), purpose).is_err());
|
||||
}
|
||||
|
||||
for purpose in [SensingPurpose::Vitals, SensingPurpose::PoseTracking] {
|
||||
// P4 ceiling excluding identity: fine.
|
||||
assert!(validate_representation(&rep(PrivacyClass::P4, &["identity"]), purpose).is_ok());
|
||||
// Forgot to exclude identity: deny.
|
||||
assert!(validate_representation(&rep(PrivacyClass::P4, &[]), purpose).is_err());
|
||||
// Above the P4 ceiling: deny.
|
||||
assert!(validate_representation(&rep(PrivacyClass::P5, &["identity"]), purpose).is_err());
|
||||
}
|
||||
|
||||
// IdentityRecognition: P5 ceiling, nothing required to be excluded.
|
||||
assert!(validate_representation(&rep(PrivacyClass::P5, &[]), SensingPurpose::IdentityRecognition)
|
||||
.is_ok());
|
||||
// Still bounded — no class exceeds P5, so exercise the lineage guard instead.
|
||||
let mut orphan = rep(PrivacyClass::P5, &[]);
|
||||
orphan.source_receipts.clear();
|
||||
assert!(validate_representation(&orphan, SensingPurpose::IdentityRecognition).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,7 +185,12 @@ impl GaussianMap {
|
||||
for g in &mut self.gaussians {
|
||||
let dt_s = (now_ns.saturating_sub(g.timestamp_ns)) as f64 / 1e9;
|
||||
let lifetime_s = (g.timestamp_ns.saturating_sub(g.first_seen_ns)) as f64 / 1e9;
|
||||
let tau_eff = g.decay_tau_s * (1.0 + (1.0 + lifetime_s / g.decay_tau_s).ln());
|
||||
// `RfGaussian::new` validates `decay_tau_s > 0`, but the field is
|
||||
// mutable after construction (`gaussians_mut`); re-clamp here so a
|
||||
// stray zero/negative value can't turn this division into NaN
|
||||
// instead of a merely-fast decay.
|
||||
let tau = g.decay_tau_s.max(1e-6);
|
||||
let tau_eff = tau * (1.0 + (1.0 + lifetime_s / tau).ln());
|
||||
g.confidence *= (-dt_s / tau_eff).exp();
|
||||
}
|
||||
self.gaussians.retain(|g| g.confidence >= PRUNE_CONFIDENCE);
|
||||
@@ -209,6 +214,18 @@ impl GaussianMap {
|
||||
continue;
|
||||
}
|
||||
let (a, b) = (&self.gaussians[i], &self.gaussians[j]);
|
||||
// Same entity-kind gate as `insert` (§3.2): never conflate
|
||||
// Gaussians linked to different entity kinds (e.g. a Room
|
||||
// structure and a PersonClass detection sitting within each
|
||||
// other's merge gate near a doorway).
|
||||
let same_kind = match (a.links.first(), b.links.first()) {
|
||||
(Some(la), Some(lb)) => la.kind == lb.kind,
|
||||
(None, None) => true,
|
||||
_ => false,
|
||||
};
|
||||
if !same_kind {
|
||||
continue;
|
||||
}
|
||||
let mutual = a.mahalanobis_sq(b.position) < MERGE_MAHALANOBIS_SQ
|
||||
&& b.mahalanobis_sq(a.position) < MERGE_MAHALANOBIS_SQ;
|
||||
if !mutual {
|
||||
|
||||
@@ -132,6 +132,12 @@ pub fn resample_complex(x: &[Complex64], m: usize) -> Vec<Complex64> {
|
||||
if n == 1 {
|
||||
return vec![x[0]; m];
|
||||
}
|
||||
if m == 1 {
|
||||
// `(m - 1)` would divide by zero below; a single output point is the
|
||||
// mean of the series rather than an arbitrary NaN-poisoned sample.
|
||||
let sum: Complex64 = x.iter().copied().sum();
|
||||
return vec![sum / n as f64];
|
||||
}
|
||||
let mut out = Vec::with_capacity(m);
|
||||
for j in 0..m {
|
||||
let pos = (j as f64) * ((n - 1) as f64) / ((m - 1) as f64);
|
||||
|
||||
@@ -193,6 +193,12 @@ pub struct PretrainReport {
|
||||
}
|
||||
|
||||
fn sample_mask(rng: &mut rand_chacha::ChaCha20Rng, n_tokens: usize, fraction: f64) -> Vec<usize> {
|
||||
// A window with fewer than 2 tokens has nothing left to reconstruct from
|
||||
// once one token is masked; return an empty mask rather than panicking
|
||||
// (`clamp(1, n_tokens - 1)` is invalid once `n_tokens - 1 < 1`).
|
||||
if n_tokens < 2 {
|
||||
return Vec::new();
|
||||
}
|
||||
let n_mask = ((n_tokens as f64 * fraction).round() as usize).clamp(1, n_tokens - 1);
|
||||
let mut idx: Vec<usize> = (0..n_tokens).collect();
|
||||
idx.shuffle(rng);
|
||||
@@ -216,12 +222,16 @@ pub fn pretrain(
|
||||
.map(|w| sample_mask(&mut rng, w.tokens.len(), cfg.mask_fraction))
|
||||
.collect();
|
||||
let eval = |e: &RfEncoder| {
|
||||
windows
|
||||
.iter()
|
||||
.zip(&eval_masks)
|
||||
.map(|(w, m)| masked_loss(e, &w.tokens, m, WindowContext::from(w)))
|
||||
.sum::<f64>()
|
||||
/ windows.len() as f64
|
||||
let mut total = 0.0;
|
||||
let mut n = 0usize;
|
||||
for (w, m) in windows.iter().zip(&eval_masks) {
|
||||
if m.is_empty() {
|
||||
continue;
|
||||
}
|
||||
total += masked_loss(e, &w.tokens, m, WindowContext::from(w));
|
||||
n += 1;
|
||||
}
|
||||
if n == 0 { 0.0 } else { total / n as f64 }
|
||||
};
|
||||
|
||||
let initial_loss = eval(enc);
|
||||
|
||||
@@ -239,9 +239,15 @@ proptest! {
|
||||
}
|
||||
|
||||
/// Representation validation never approves an identity-retaining
|
||||
/// occupancy representation, whatever the other fields say.
|
||||
/// occupancy representation, whatever the other fields say — checked
|
||||
/// across *every* `SensingPurpose` branch (each has a distinct ceiling
|
||||
/// and exclusion set in `validate_representation`; testing only
|
||||
/// `Presence`, as this property previously did, leaves the other three
|
||||
/// branches — covering P3–P5, the higher-risk representations —
|
||||
/// completely unverified).
|
||||
#[test]
|
||||
fn occupancy_representations_must_exclude_identity(
|
||||
purpose in any_purpose(),
|
||||
class in prop_oneof![
|
||||
Just(PrivacyClass::P0), Just(PrivacyClass::P1), Just(PrivacyClass::P2),
|
||||
Just(PrivacyClass::P3), Just(PrivacyClass::P4), Just(PrivacyClass::P5)
|
||||
@@ -257,12 +263,33 @@ proptest! {
|
||||
excluded_information: excluded.clone(),
|
||||
privacy_class: class,
|
||||
};
|
||||
let verdict = validate_representation(&rep, SensingPurpose::Presence);
|
||||
let excludes_required = excluded.iter().any(|e| e == "identity")
|
||||
&& excluded.iter().any(|e| e == "vitals");
|
||||
let verdict = validate_representation(&rep, purpose);
|
||||
// Oracle mirrors the (ceiling, must_exclude) table in
|
||||
// `control::validate_representation` so this property checks the
|
||||
// *contract* per purpose group, not just the Presence case.
|
||||
let (ceiling, must_exclude): (PrivacyClass, &[&str]) = match purpose {
|
||||
SensingPurpose::Presence | SensingPurpose::ChannelDiagnostics => {
|
||||
(PrivacyClass::P2, &["identity", "vitals"])
|
||||
}
|
||||
SensingPurpose::Activity | SensingPurpose::Localization => {
|
||||
(PrivacyClass::P3, &["identity"])
|
||||
}
|
||||
SensingPurpose::Vitals | SensingPurpose::PoseTracking => {
|
||||
(PrivacyClass::P4, &["identity"])
|
||||
}
|
||||
SensingPurpose::IdentityRecognition => (PrivacyClass::P5, &[]),
|
||||
};
|
||||
let excludes_required =
|
||||
must_exclude.iter().all(|c| excluded.iter().any(|e| e == c));
|
||||
if verdict.is_ok() {
|
||||
prop_assert!(excludes_required, "approved rep must exclude identity+vitals");
|
||||
prop_assert!(class <= PrivacyClass::P2, "approved rep must respect the ceiling");
|
||||
prop_assert!(
|
||||
excludes_required,
|
||||
"approved rep for {:?} must exclude {:?}", purpose, must_exclude
|
||||
);
|
||||
prop_assert!(
|
||||
class <= ceiling,
|
||||
"approved rep for {:?} must respect ceiling {:?}", purpose, ceiling
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +65,7 @@ impl ComplexSample {
|
||||
/// This is a lossy *view*, never re-serialised as the witness form
|
||||
/// (ADR-136 §3.3 risk mitigation — one encoder only).
|
||||
#[must_use]
|
||||
#[allow(clippy::cast_possible_truncation)] // f64 -> f32 is the documented lossy narrowing above
|
||||
pub fn as_complex32(&self) -> num_complex::Complex32 {
|
||||
num_complex::Complex32::new(self.0.re as f32, self.0.im as f32)
|
||||
}
|
||||
@@ -581,7 +582,9 @@ impl crate::traits::CanonicalFrame for CsiFrame {
|
||||
b.extend_from_slice(&m.timestamp.seconds.to_le_bytes());
|
||||
b.extend_from_slice(&m.timestamp.nanos.to_le_bytes());
|
||||
let dev = m.device_id.as_str().as_bytes();
|
||||
b.extend_from_slice(&(dev.len() as u32).to_le_bytes());
|
||||
b.extend_from_slice(
|
||||
&u32::try_from(dev.len()).expect("device_id length fits u32").to_le_bytes(),
|
||||
);
|
||||
b.extend_from_slice(dev);
|
||||
b.push(match m.frequency_band {
|
||||
FrequencyBand::Band2_4GHz => 0,
|
||||
@@ -592,15 +595,12 @@ impl crate::traits::CanonicalFrame for CsiFrame {
|
||||
b.extend_from_slice(&m.bandwidth_mhz.to_le_bytes());
|
||||
b.push(m.antenna_config.tx_antennas);
|
||||
b.push(m.antenna_config.rx_antennas);
|
||||
match m.antenna_config.spacing_mm {
|
||||
Some(s) => {
|
||||
b.push(1);
|
||||
b.extend_from_slice(&s.to_le_bytes());
|
||||
}
|
||||
None => {
|
||||
b.push(0);
|
||||
b.extend_from_slice(&[0u8; 4]);
|
||||
}
|
||||
if let Some(s) = m.antenna_config.spacing_mm {
|
||||
b.push(1);
|
||||
b.extend_from_slice(&s.to_le_bytes());
|
||||
} else {
|
||||
b.push(0);
|
||||
b.extend_from_slice(&[0u8; 4]);
|
||||
}
|
||||
b.extend_from_slice(&m.rssi_dbm.to_le_bytes());
|
||||
b.extend_from_slice(&m.noise_floor_dbm.to_le_bytes());
|
||||
@@ -623,8 +623,12 @@ impl crate::traits::CanonicalFrame for CsiFrame {
|
||||
b.extend_from_slice(&m.model_version.to_le_bytes());
|
||||
|
||||
// Shape, then complex payload stream-major.
|
||||
b.extend_from_slice(&(self.data.nrows() as u32).to_le_bytes());
|
||||
b.extend_from_slice(&(self.data.ncols() as u32).to_le_bytes());
|
||||
b.extend_from_slice(
|
||||
&u32::try_from(self.data.nrows()).expect("stream count fits u32").to_le_bytes(),
|
||||
);
|
||||
b.extend_from_slice(
|
||||
&u32::try_from(self.data.ncols()).expect("subcarrier count fits u32").to_le_bytes(),
|
||||
);
|
||||
for sample in self.data_complex_samples() {
|
||||
b.extend_from_slice(&sample.to_le_bytes());
|
||||
}
|
||||
@@ -714,7 +718,7 @@ impl<'a> Cursor<'a> {
|
||||
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)
|
||||
Ok(i8::from_le_bytes(self.take(1)?.try_into().unwrap()))
|
||||
}
|
||||
fn uuid(&mut self) -> Result<Uuid, CanonicalDecodeError> {
|
||||
Ok(Uuid::from_bytes(self.take(16)?.try_into().unwrap()))
|
||||
@@ -1462,7 +1466,7 @@ mod tests {
|
||||
fn lcg(state: &mut u64) -> f64 {
|
||||
*state = state.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1_442_695_040_888_963_407);
|
||||
// Map high bits into [-1e6, 1e6) for a wide exponent spread.
|
||||
((*state >> 11) as f64 / (1u64 << 53) as f64) * 2.0e6 - 1.0e6
|
||||
((*state >> 11) as f64 / (1u64 << 53) as f64).mul_add(2.0e6, -1.0e6)
|
||||
}
|
||||
|
||||
/// AC1 — `ComplexSample` little-endian round-trip + endianness pin.
|
||||
@@ -1671,6 +1675,7 @@ mod tests {
|
||||
/// unwinding panic (panic-on-adversarial-input = 0). Sweep truncations and a
|
||||
/// deterministic fuzz spread.
|
||||
#[test]
|
||||
#[allow(clippy::cast_possible_truncation)] // fuzz byte extraction: truncation IS the point
|
||||
fn canonical_decode_never_panics_on_arbitrary_bytes() {
|
||||
use ndarray::Array2;
|
||||
let mut meta = CsiMetadata::new(DeviceId::new("node"), FrequencyBand::Band5GHz, 36);
|
||||
|
||||
@@ -146,7 +146,7 @@ impl PpduType {
|
||||
/// bit 3 : LDPC (reserved — not yet populated by firmware)
|
||||
/// bit 4 : 802.15.4 time-sync valid (C6 only)
|
||||
/// bit 5-7 : reserved
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Adr018Flags {
|
||||
pub bw40: bool,
|
||||
pub stbc: bool,
|
||||
@@ -173,12 +173,6 @@ impl Adr018Flags {
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Adr018Flags {
|
||||
fn default() -> Self {
|
||||
Self { bw40: false, stbc: false, ldpc: false, ieee802154_sync_valid: false }
|
||||
}
|
||||
}
|
||||
|
||||
/// WiFi channel bandwidth.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Bandwidth {
|
||||
|
||||
@@ -93,8 +93,10 @@ fn fsm_full_cycle_setup_measure_report_terminate() {
|
||||
|
||||
#[test]
|
||||
fn responder_rejects_unsupported_bandwidth_and_initiator_resets() {
|
||||
let mut cfg = SessionConfig::default();
|
||||
cfg.capabilities = SensingCapabilities::esp32_opportunistic(); // max 40 MHz
|
||||
let cfg = SessionConfig {
|
||||
capabilities: SensingCapabilities::esp32_opportunistic(), // max 40 MHz
|
||||
..SessionConfig::default()
|
||||
};
|
||||
let mut responder = SensingSession::new_responder(cfg);
|
||||
let mut initiator = SensingSession::new_initiator(SessionConfig::default());
|
||||
|
||||
@@ -204,8 +206,10 @@ fn capacity_and_policy_and_profile_rejections() {
|
||||
));
|
||||
|
||||
// Incompatible profile
|
||||
let mut cfg = SessionConfig::default();
|
||||
cfg.profile = SpecProfile::VendorExtension("acme".into());
|
||||
let cfg = SessionConfig {
|
||||
profile: SpecProfile::VendorExtension("acme".into()),
|
||||
..SessionConfig::default()
|
||||
};
|
||||
let mut responder = SensingSession::new_responder(cfg);
|
||||
let actions = responder
|
||||
.handle(SessionEvent::SetupRequestReceived(setup_request(6)))
|
||||
|
||||
@@ -268,8 +268,10 @@ fn sbp_validation_shares_setup_chain_with_one_to_one_status_mapping() {
|
||||
|
||||
// Incompatible profile now surfaces as its own status (the old
|
||||
// duplicated SBP chain folded it into RejectedUnsupportedParams).
|
||||
let mut cfg = SessionConfig::default();
|
||||
cfg.profile = SpecProfile::VendorExtension("acme".into());
|
||||
let cfg = SessionConfig {
|
||||
profile: SpecProfile::VendorExtension("acme".into()),
|
||||
..SessionConfig::default()
|
||||
};
|
||||
let mut proxy = SensingSession::new_responder(cfg);
|
||||
let actions = proxy
|
||||
.handle(SessionEvent::SbpRequestReceived(sbp_request(41)))
|
||||
|
||||
@@ -66,48 +66,64 @@ impl Server {
|
||||
/// test in the suite that observes real wiring disarmed itself exactly when
|
||||
/// it mattered; a boot-time panic in the auth path would have shipped green.
|
||||
fn start(env: &[(&str, &str)]) -> Self {
|
||||
let (http, ws, udp) = (free_port(), free_port(), free_port());
|
||||
let mut cmd = Command::new(env!("CARGO_BIN_EXE_sensing-server"));
|
||||
cmd.args([
|
||||
"--http-port", &http.to_string(),
|
||||
"--ws-port", &ws.to_string(),
|
||||
"--udp-port", &udp.to_string(),
|
||||
"--bind-addr", "127.0.0.1",
|
||||
"--no-edge-registry",
|
||||
"--source", "simulate",
|
||||
])
|
||||
// Inherit nothing auth-related from the developer's shell, or a local
|
||||
// RUVIEW_* export would silently change what this test proves.
|
||||
.env_remove("RUVIEW_API_TOKEN")
|
||||
.env_remove("RUVIEW_OAUTH_ISSUER")
|
||||
.env_remove("RUVIEW_WS_LEGACY_UNAUTHENTICATED")
|
||||
.stdout(Stdio::null())
|
||||
// Captured, not discarded: if the server dies at boot, its stderr is the
|
||||
// only thing that says why, and the panic below reproduces it.
|
||||
.stderr(Stdio::piped());
|
||||
for (k, v) in env {
|
||||
cmd.env(k, v);
|
||||
}
|
||||
let mut child = cmd.spawn().expect("spawn sensing-server");
|
||||
let http_port = http;
|
||||
let ws_port = ws;
|
||||
if !await_ready(http_port, ws_port) {
|
||||
// `free_port()` releases the port before the child binds it, so under
|
||||
// parallel `cargo test` execution another test's server can grab the
|
||||
// same ephemeral port first (observed as `Os { code: 10048/98, kind:
|
||||
// AddrInUse }` on the child's actual bind, distinct from a genuine
|
||||
// auth-wiring break). Retry a bounded number of times on THAT specific
|
||||
// signature only — any other boot failure (including a real wiring
|
||||
// regression) still panics on the first attempt, preserving the
|
||||
// fail-loud property described above.
|
||||
const MAX_ATTEMPTS: u32 = 3;
|
||||
for attempt in 1..=MAX_ATTEMPTS {
|
||||
let (http, ws, udp) = (free_port(), free_port(), free_port());
|
||||
let mut cmd = Command::new(env!("CARGO_BIN_EXE_sensing-server"));
|
||||
cmd.args([
|
||||
"--http-port", &http.to_string(),
|
||||
"--ws-port", &ws.to_string(),
|
||||
"--udp-port", &udp.to_string(),
|
||||
"--bind-addr", "127.0.0.1",
|
||||
"--no-edge-registry",
|
||||
"--source", "simulate",
|
||||
])
|
||||
// Inherit nothing auth-related from the developer's shell, or a local
|
||||
// RUVIEW_* export would silently change what this test proves.
|
||||
.env_remove("RUVIEW_API_TOKEN")
|
||||
.env_remove("RUVIEW_OAUTH_ISSUER")
|
||||
.env_remove("RUVIEW_WS_LEGACY_UNAUTHENTICATED")
|
||||
.stdout(Stdio::null())
|
||||
// Captured, not discarded: if the server dies at boot, its stderr is the
|
||||
// only thing that says why, and the panic below reproduces it.
|
||||
.stderr(Stdio::piped());
|
||||
for (k, v) in env {
|
||||
cmd.env(k, v);
|
||||
}
|
||||
let mut child = cmd.spawn().expect("spawn sensing-server");
|
||||
if await_ready(http, ws) {
|
||||
return Server { child, http, ws };
|
||||
}
|
||||
let mut err = String::new();
|
||||
if let Some(mut s) = child.stderr.take() {
|
||||
let _ = s.read_to_string(&mut err);
|
||||
}
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
let looks_like_port_collision =
|
||||
err.contains("AddrInUse") || err.contains("Address already in use")
|
||||
|| err.contains("code: 10048") || err.contains("code: 98");
|
||||
if looks_like_port_collision && attempt < MAX_ATTEMPTS {
|
||||
continue;
|
||||
}
|
||||
panic!(
|
||||
"sensing-server did not become ready on :{http_port} (http) and :{ws_port} (ws) \
|
||||
within 30s. This is a FAILURE, not a skip — the wiring assertions below cannot \
|
||||
run, and a boot-time break in the auth path is exactly what they exist to catch.\n\
|
||||
"sensing-server did not become ready on :{http} (http) and :{ws} (ws) \
|
||||
within 30s (attempt {attempt}/{MAX_ATTEMPTS}). This is a FAILURE, not a skip — \
|
||||
the wiring assertions below cannot run, and a boot-time break in the auth path \
|
||||
is exactly what they exist to catch.\n\
|
||||
--- server stderr ---\n{err}"
|
||||
);
|
||||
}
|
||||
Server { child, http: http_port, ws: ws_port }
|
||||
unreachable!("loop always returns or panics");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
fn await_ready(http: u16, ws: u16) -> bool {
|
||||
|
||||
Reference in New Issue
Block a user