From 8ce3bd090b77ed16aeff9cbea1163a1ae8bec46e Mon Sep 17 00:00:00 2001 From: ruv Date: Sun, 26 Jul 2026 17:08:27 -0400 Subject: [PATCH] fix(ruview-unified): panics, NaN corruption, entity-conflation, and wrong center-freq found in review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- v2/Cargo.lock | 1 + v2/crates/ruview-auth/src/login/store.rs | 1 + v2/crates/ruview-unified/Cargo.toml | 5 + .../examples/esp32_live_hardware_test.rs | 155 ++++++++++++++++++ v2/crates/ruview-unified/src/adapters.rs | 26 ++- v2/crates/ruview-unified/src/control.rs | 51 ++++++ v2/crates/ruview-unified/src/gaussian/map.rs | 19 ++- v2/crates/ruview-unified/src/math.rs | 6 + v2/crates/ruview-unified/src/pretrain.rs | 22 ++- .../tests/security_boundaries.rs | 39 ++++- v2/crates/wifi-densepose-core/src/types.rs | 33 ++-- .../wifi-densepose-hardware/src/csi_frame.rs | 8 +- .../src/ieee80211bf/tests_fsm.rs | 12 +- .../src/ieee80211bf/tests_sbp.rs | 6 +- .../tests/auth_wiring.rs | 78 +++++---- 15 files changed, 389 insertions(+), 73 deletions(-) create mode 100644 v2/crates/ruview-unified/examples/esp32_live_hardware_test.rs diff --git a/v2/Cargo.lock b/v2/Cargo.lock index 97b8d821..02c23491 100644 --- a/v2/Cargo.lock +++ b/v2/Cargo.lock @@ -7688,6 +7688,7 @@ dependencies = [ "serde", "thiserror 2.0.18", "wifi-densepose-core", + "wifi-densepose-hardware", ] [[package]] diff --git a/v2/crates/ruview-auth/src/login/store.rs b/v2/crates/ruview-auth/src/login/store.rs index 80e360e3..7db9fab3 100644 --- a/v2/crates/ruview-auth/src/login/store.rs +++ b/v2/crates/ruview-auth/src/login/store.rs @@ -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 diff --git a/v2/crates/ruview-unified/Cargo.toml b/v2/crates/ruview-unified/Cargo.toml index 0b11324c..807acf76 100644 --- a/v2/crates/ruview-unified/Cargo.toml +++ b/v2/crates/ruview-unified/Cargo.toml @@ -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" diff --git a/v2/crates/ruview-unified/examples/esp32_live_hardware_test.rs b/v2/crates/ruview-unified/examples/esp32_live_hardware_test.rs new file mode 100644 index 00000000..a5f43c81 --- /dev/null +++ b/v2/crates/ruview-unified/examples/esp32_live_hardware_test.rs @@ -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::>() + .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::>() + .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 = Vec::new(); + let mut real_channel_freq_hz: Option = 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); + } + } +} diff --git a/v2/crates/ruview-unified/src/adapters.rs b/v2/crates/ruview-unified/src/adapters.rs index 39d3a655..6296bc4a 100644 --- a/v2/crates/ruview-unified/src/adapters.rs +++ b/v2/crates/ruview-unified/src/adapters.rs @@ -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::() / 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( diff --git a/v2/crates/ruview-unified/src/control.rs b/v2/crates/ruview-unified/src/control.rs index aa686d09..3bd5560e 100644 --- a/v2/crates/ruview-unified/src/control.rs +++ b/v2/crates/ruview-unified/src/control.rs @@ -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()); + } } diff --git a/v2/crates/ruview-unified/src/gaussian/map.rs b/v2/crates/ruview-unified/src/gaussian/map.rs index 7ef1deac..c1389c55 100644 --- a/v2/crates/ruview-unified/src/gaussian/map.rs +++ b/v2/crates/ruview-unified/src/gaussian/map.rs @@ -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 { diff --git a/v2/crates/ruview-unified/src/math.rs b/v2/crates/ruview-unified/src/math.rs index 41e6b2fa..e6dcc2db 100644 --- a/v2/crates/ruview-unified/src/math.rs +++ b/v2/crates/ruview-unified/src/math.rs @@ -132,6 +132,12 @@ pub fn resample_complex(x: &[Complex64], m: usize) -> Vec { 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); diff --git a/v2/crates/ruview-unified/src/pretrain.rs b/v2/crates/ruview-unified/src/pretrain.rs index dac8b2e1..756b3fb1 100644 --- a/v2/crates/ruview-unified/src/pretrain.rs +++ b/v2/crates/ruview-unified/src/pretrain.rs @@ -193,6 +193,12 @@ pub struct PretrainReport { } fn sample_mask(rng: &mut rand_chacha::ChaCha20Rng, n_tokens: usize, fraction: f64) -> Vec { + // 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 = (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::() - / 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); diff --git a/v2/crates/ruview-unified/tests/security_boundaries.rs b/v2/crates/ruview-unified/tests/security_boundaries.rs index 54eea7c8..aac8607f 100644 --- a/v2/crates/ruview-unified/tests/security_boundaries.rs +++ b/v2/crates/ruview-unified/tests/security_boundaries.rs @@ -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 + ); } } } diff --git a/v2/crates/wifi-densepose-core/src/types.rs b/v2/crates/wifi-densepose-core/src/types.rs index a2df7169..86d710e5 100644 --- a/v2/crates/wifi-densepose-core/src/types.rs +++ b/v2/crates/wifi-densepose-core/src/types.rs @@ -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 { - Ok(self.take(1)?[0] as i8) + Ok(i8::from_le_bytes(self.take(1)?.try_into().unwrap())) } fn uuid(&mut self) -> Result { 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); diff --git a/v2/crates/wifi-densepose-hardware/src/csi_frame.rs b/v2/crates/wifi-densepose-hardware/src/csi_frame.rs index 400f91fd..412240ec 100644 --- a/v2/crates/wifi-densepose-hardware/src/csi_frame.rs +++ b/v2/crates/wifi-densepose-hardware/src/csi_frame.rs @@ -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 { diff --git a/v2/crates/wifi-densepose-hardware/src/ieee80211bf/tests_fsm.rs b/v2/crates/wifi-densepose-hardware/src/ieee80211bf/tests_fsm.rs index ae698d63..b27e4138 100644 --- a/v2/crates/wifi-densepose-hardware/src/ieee80211bf/tests_fsm.rs +++ b/v2/crates/wifi-densepose-hardware/src/ieee80211bf/tests_fsm.rs @@ -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))) diff --git a/v2/crates/wifi-densepose-hardware/src/ieee80211bf/tests_sbp.rs b/v2/crates/wifi-densepose-hardware/src/ieee80211bf/tests_sbp.rs index a80623ca..81628f67 100644 --- a/v2/crates/wifi-densepose-hardware/src/ieee80211bf/tests_sbp.rs +++ b/v2/crates/wifi-densepose-hardware/src/ieee80211bf/tests_sbp.rs @@ -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))) diff --git a/v2/crates/wifi-densepose-sensing-server/tests/auth_wiring.rs b/v2/crates/wifi-densepose-sensing-server/tests/auth_wiring.rs index 6dbf9633..79e8eb2e 100644 --- a/v2/crates/wifi-densepose-sensing-server/tests/auth_wiring.rs +++ b/v2/crates/wifi-densepose-sensing-server/tests/auth_wiring.rs @@ -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 {