From be068748b3c671d6a232289e65375fb4882ebe9c Mon Sep 17 00:00:00 2001 From: ruv Date: Thu, 11 Jun 2026 19:20:37 -0400 Subject: [PATCH] fix(signal): revive dead CIR coherence gate + NaN bypass + window div0 (ADR-154 M0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Milestone-0 correctness/security fixes for the beyond-SOTA signal/DSP sweep. Every fix ships with a committed regression test (proof, not adjectives). CRITICAL — ADR-134 CIR coherence gate was DEAD in production MultistaticFuser fuses canonical-56 frames (hardware_norm.rs resamples every chipset onto a 56-tone grid), but the gate was wired to CirConfig::ht20() which expects 64/52. Every estimate() returned SubcarrierMismatch and cir_gate_coherence silently fell back to freq-domain coherence — use_cir_gate was indistinguishable from false. Fixes: - new CirConfig::canonical56() (64-bin HT20 framing, 56 active tones, 168 taps) - new MultistaticFuser::with_cir_canonical56() (correct default); ht20 kept, now doc-warned - active_indices() handles (64,56) + length-matched fallback (no silent fall-through to the 52-index slice) - SubcarrierMismatch in the gate now debug_assert!s loudly (config error can no longer hide as a graceful degrade) - cir_estimate_first() exposes the Ok/Err verdict for tests PROOF (ruvsense::multistatic::tests): ht20 → 8/8 Err (dead); canonical56 → 8/8 Ok (alive); coherence(gate on) != coherence(gate off). CRITICAL — adversarial.rs NaN/inf detector bypass One non-finite link energy bypassed the whole detector (every `e>thresh` false on NaN; score clamp returns NaN). A non-finite input is itself the strongest spoof — now short-circuits to a definite anomaly (score 1.0, affected link reported) and does not poison the temporal-continuity state. PROOF: nan_link_energy_flags_anomaly, inf_link_energy_flags_anomaly. CORRECTNESS — divide-by-(n-1) window trio csi_processor hamming_window (n=0 usize underflow, n=1 div0), bvp Hann, spectrogram make_window all guarded for n<=1 (empty / constant-1.0 window). Python deterministic proof still PASS, same pipeline hash (reference uses n>=2). PROOF: *_degenerate_sizes / *_size_one_is_finite / make_window_size_0_and_1. CLARITY — calibration.rs subtract_in_place Removed the vacuous `if active_input {ki} else {ki}` branch that implied a full-FFT->bin remap that never existed; documented the sequential active-index convention (matches sibling extract_first_stream). No behavior change. Tests: cargo test -p wifi-densepose-signal --no-default-features (+--features cir) green; full workspace green; verify.py VERDICT: PASS. Co-Authored-By: claude-flow --- v2/crates/wifi-densepose-signal/src/bvp.rs | 32 +++- .../src/csi_processor.rs | 38 +++- .../src/ruvsense/adversarial.rs | 58 ++++++ .../src/ruvsense/calibration.rs | 17 +- .../wifi-densepose-signal/src/ruvsense/cir.rs | 62 +++++++ .../src/ruvsense/multistatic.rs | 166 +++++++++++++++++- .../wifi-densepose-signal/src/spectrogram.rs | 28 +++ 7 files changed, 385 insertions(+), 16 deletions(-) diff --git a/v2/crates/wifi-densepose-signal/src/bvp.rs b/v2/crates/wifi-densepose-signal/src/bvp.rs index 792929fd..268615dc 100644 --- a/v2/crates/wifi-densepose-signal/src/bvp.rs +++ b/v2/crates/wifi-densepose-signal/src/bvp.rs @@ -93,10 +93,16 @@ pub fn extract_bvp( let n_frames = (n_samples - config.window_size) / config.hop_size + 1; let n_fft_bins = config.window_size / 2 + 1; - // Hann window - let window: Vec = (0..config.window_size) - .map(|i| 0.5 * (1.0 - (2.0 * PI * i as f64 / (config.window_size - 1) as f64).cos())) - .collect(); + // Hann window. ADR-154: `window_size == 0` is rejected above, but + // `window_size == 1` would divide by `(1 - 1) == 0` → NaN samples. Guard the + // length-1 case to the standard constant-1.0 window. + let window: Vec = if config.window_size == 1 { + vec![1.0] + } else { + (0..config.window_size) + .map(|i| 0.5 * (1.0 - (2.0 * PI * i as f64 / (config.window_size - 1) as f64).cos())) + .collect() + }; let mut planner = FftPlanner::new(); let fft = planner.plan_fft_forward(config.window_size); @@ -282,6 +288,24 @@ mod tests { assert_eq!(bvp.velocity_bins.len(), 64); } + // ADR-154: window_size == 1 divided by (1-1) == 0 → NaN Hann window. The + // guard must produce a finite (constant-1.0) window instead. + #[test] + fn bvp_window_size_one_is_finite() { + let csi = Array2::from_shape_fn((64, 4), |(t, _)| (t as f64 * 0.1).sin()); + let config = BvpConfig { + window_size: 1, + hop_size: 1, + n_velocity_bins: 8, + ..Default::default() + }; + let bvp = extract_bvp(&csi, 100.0, &config).unwrap(); + assert!( + bvp.data.iter().all(|v| v.is_finite()), + "window_size=1 must not produce NaN BVP samples" + ); + } + #[test] fn test_bvp_velocity_range() { let csi = Array2::from_shape_fn((500, 5), |(t, _)| (t as f64 * 0.05).sin()); diff --git a/v2/crates/wifi-densepose-signal/src/csi_processor.rs b/v2/crates/wifi-densepose-signal/src/csi_processor.rs index e48eef05..6462cc0e 100644 --- a/v2/crates/wifi-densepose-signal/src/csi_processor.rs +++ b/v2/crates/wifi-densepose-signal/src/csi_processor.rs @@ -475,11 +475,21 @@ impl CsiPreprocessor { }) } - /// Generate Hamming window + /// Generate Hamming window. + /// + /// ADR-154: guards the `n - 1` denominator. For `n == 0` the original code + /// underflowed (`0usize - 1` panics in debug / wraps in release); for + /// `n == 1` it divided by zero (every sample became NaN). Both degenerate + /// sizes now return a safe window (empty / single unit sample) — the + /// standard convention for a length-1 window is the constant 1.0. fn hamming_window(n: usize) -> Vec { - (0..n) - .map(|i| 0.54 - 0.46 * (2.0 * PI * i as f64 / (n - 1) as f64).cos()) - .collect() + match n { + 0 => Vec::new(), + 1 => vec![1.0], + _ => (0..n) + .map(|i| 0.54 - 0.46 * (2.0 * PI * i as f64 / (n - 1) as f64).cos()) + .collect(), + } } /// Calculate standard deviation @@ -776,4 +786,24 @@ mod tests { // First and last values should be approximately 0.08 assert!((window[0] - 0.08).abs() < 0.01); } + + // ADR-154: n=0 underflowed `n-1` (usize), n=1 divided by zero → NaN. + #[test] + fn test_hamming_window_degenerate_sizes() { + assert!( + CsiPreprocessor::hamming_window(0).is_empty(), + "n=0 must return an empty window, not underflow" + ); + let w1 = CsiPreprocessor::hamming_window(1); + assert_eq!(w1.len(), 1); + assert!( + w1[0].is_finite() && (w1[0] - 1.0).abs() < 1e-12, + "n=1 must be a finite unit sample, got {}", + w1[0] + ); + // n=2 is the smallest size that exercises the (n-1) denominator. + let w2 = CsiPreprocessor::hamming_window(2); + assert_eq!(w2.len(), 2); + assert!(w2.iter().all(|v| v.is_finite())); + } } diff --git a/v2/crates/wifi-densepose-signal/src/ruvsense/adversarial.rs b/v2/crates/wifi-densepose-signal/src/ruvsense/adversarial.rs index 942bdb37..c1d8b76f 100644 --- a/v2/crates/wifi-densepose-signal/src/ruvsense/adversarial.rs +++ b/v2/crates/wifi-densepose-signal/src/ruvsense/adversarial.rs @@ -194,6 +194,31 @@ impl AdversarialDetector { self.total_frames += 1; + // ADR-154 (CRITICAL): finite-validate at the boundary. A single NaN/inf + // link energy bypasses the whole detector — every `e > thresh` is false + // on NaN, and the NaN propagates through the score where `.clamp(0,1)` + // returns NaN. A non-finite input is *itself* the strongest possible + // adversarial signal (a real RF link can never have NaN/inf energy), so + // we short-circuit to a definite anomaly instead of degrading silently. + if let Some(bad) = link_energies.iter().position(|e| !e.is_finite()) { + self.anomaly_count += 1; + self.prev_energies = None; // poison frame: don't seed temporal check + self.prev_total_energy = None; + return Ok(AdversarialResult { + anomaly_detected: true, + anomaly_type: Some(AnomalyType::FieldModelViolation), + anomaly_score: 1.0, + checks: CheckResults { + consistency_score: 0.0, + field_model_residual: 1.0, + temporal_continuity: f64::INFINITY, + energy_ratio: f64::INFINITY, + }, + affected_links: vec![bad], + timestamp_us, + }); + } + let total_energy: f64 = link_energies.iter().sum(); // Check 1: Multi-link consistency @@ -439,6 +464,39 @@ mod tests { assert!(result.anomaly_score < 0.5); } + // ADR-154 (CRITICAL): a single NaN/inf link energy must NOT bypass the + // detector. Before the fix, NaN made every `e > thresh` false and the score + // NaN — the strongest possible spoof slipped through as "clean". + #[test] + fn nan_link_energy_flags_anomaly() { + let mut det = AdversarialDetector::new(default_config()).unwrap(); + let energies = vec![1.0, 1.0, f64::NAN, 1.0, 1.0, 1.0]; + let result = det.check(&energies, 1, 0).unwrap(); + assert!( + result.anomaly_detected, + "NaN link energy must flag an anomaly, not bypass the detector" + ); + assert_eq!(result.anomaly_score, 1.0); + assert!(result.affected_links.contains(&2)); + // The NaN-poisoned frame must not seed the temporal check. + assert_eq!(det.anomaly_count(), 1); + } + + #[test] + fn inf_link_energy_flags_anomaly() { + let mut det = AdversarialDetector::new(default_config()).unwrap(); + for bad in [f64::INFINITY, f64::NEG_INFINITY] { + let energies = vec![1.0, bad, 1.0, 1.0, 1.0, 1.0]; + let result = det.check(&energies, 1, 0).unwrap(); + assert!( + result.anomaly_detected, + "inf ({bad}) link energy must flag an anomaly" + ); + assert_eq!(result.anomaly_score, 1.0); + assert!(result.affected_links.contains(&1)); + } + } + #[test] fn test_single_link_injection_detected() { let mut det = AdversarialDetector::new(default_config()).unwrap(); diff --git a/v2/crates/wifi-densepose-signal/src/ruvsense/calibration.rs b/v2/crates/wifi-densepose-signal/src/ruvsense/calibration.rs index c525cf24..8c38db28 100644 --- a/v2/crates/wifi-densepose-signal/src/ruvsense/calibration.rs +++ b/v2/crates/wifi-densepose-signal/src/ruvsense/calibration.rs @@ -307,20 +307,25 @@ impl BaselineCalibration { return Err(CalibrationError::SubcarrierMismatch { expected, got: n_sc }); } let n_streams = frame.num_spatial_streams(); - let n_total = self.tier_num_subcarriers(); - let active_input = n_sc == expected; + // ADR-154: this module uses the **sequential active-index convention** — + // the baseline's i-th `SubcarrierBaseline` aligns with `frame.data[[s, i]]` + // for both the active-only and full-FFT input shapes. This matches the + // sibling `extract_first_stream` (used by `deviation()`), which likewise + // reads `frame.data[[0, ki]]` sequentially. The previous code wrote + // `if active_input { ki } else { ki }` — a vacuous branch that *looked* + // like the full-FFT path remapped to physical FFT bins but did not. The + // branch is removed to stop the comment from lying about behaviour; the + // numeric result is unchanged. for ki in 0..expected { - let col = if active_input { ki } else { ki }; // sequential when active-only let baseline_amp = self.subcarriers[ki].amp_mean as f64; for s in 0..n_streams { - let c = frame.data[[s, col]]; + let c = frame.data[[s, ki]]; let norm = c.norm(); if norm > 1e-30 { let scale = ((norm - baseline_amp).max(0.0)) / norm; - frame.data[[s, col]] = num_complex::Complex64::new(c.re * scale, c.im * scale); + frame.data[[s, ki]] = num_complex::Complex64::new(c.re * scale, c.im * scale); } } - let _ = n_total; } Ok(()) } diff --git a/v2/crates/wifi-densepose-signal/src/ruvsense/cir.rs b/v2/crates/wifi-densepose-signal/src/ruvsense/cir.rs index d8669ff1..68067749 100644 --- a/v2/crates/wifi-densepose-signal/src/ruvsense/cir.rs +++ b/v2/crates/wifi-densepose-signal/src/ruvsense/cir.rs @@ -110,6 +110,30 @@ const HE40_ACTIVE: [i32; 484] = { a }; +/// Canonical-56 active subcarrier indices: ±1..±28 (56 total), DC=0 excluded. +/// +/// ADR-154 §A.1: the RuvSense pipeline (`hardware_norm.rs`) resamples every +/// chipset onto a uniform **canonical 56-tone grid** before fusion. That grid +/// is what `MultistaticFuser` and the CIR coherence gate actually see — *not* +/// the raw 64-bin HT20 stream. We model it as a contiguous 56-active-tone band +/// (−28..−1, +1..+28), which is also the native Atheros 56-subcarrier layout +/// (`HardwareType::Atheros`, hardware_norm.rs:45). Building Φ over these 56 +/// indices lets `CirEstimator::estimate()` run on canonical frames instead of +/// rejecting them with `SubcarrierMismatch`. +const CANONICAL56_ACTIVE: [i32; 56] = { + let mut a = [0i32; 56]; + let mut idx = 0usize; + let mut i = -28i32; + while i <= 28 { + if i != 0 { + a[idx] = i; + idx += 1; + } + i += 1; + } + a +}; + // --------------------------------------------------------------------------- // Error type // --------------------------------------------------------------------------- @@ -248,6 +272,33 @@ impl CirConfig { } } + /// Canonical-56 grid (ADR-154 §A.1): 64-point FFT framing, **56 active + /// tones**, 168 delay taps. This is the config the RuvSense multistatic + /// fuser must use, because `hardware_norm.rs` resamples every node onto the + /// canonical 56-subcarrier grid before fusion. Using `ht20()` (52 active) + /// here makes `estimate()` reject every canonical frame with + /// `SubcarrierMismatch` — the dead-gate bug ADR-154 fixes. + /// + /// `num_subcarriers` is kept at 64 (the HT20 FFT size) so the delay-domain + /// `tap_spacing` and `bandwidth_hz` stay physically correct for a 20 MHz + /// HT20 channel; only the *active-tone* count differs from `ht20()`. + pub fn canonical56() -> Self { + Self { + bandwidth_hz: 20e6, + num_subcarriers: 64, + num_active: 56, + num_taps: 168, // 3 × 56 super-resolution, matches the ht20 3× ratio + delay_bins: 168, + pilot_indices: HT20_PILOTS, + lambda: 0.08, // ADR-134 P2 tuned (see ht20) + max_iters: 100, + tolerance: 1e-4, + ranging_min_bw_hz: 40e6, + dominant_ratio_threshold: 0.3, + fft_operator: false, + } + } + /// Dispatch a config by raw channel bandwidth in MHz (legacy test API). /// /// `20` → `ht20()`, `40` → `ht40()`. For HE-LTF tiers, call @@ -265,12 +316,23 @@ impl CirConfig { } /// Return the static active-subcarrier index slice for this config. + /// + /// The returned slice length is always exactly `num_active`; the canonical-56 + /// grid (ADR-154) is handled explicitly so it never silently falls through to + /// the 52-index HT20 slice (which would mismatch Φ's column count). fn active_indices(&self) -> &'static [i32] { match (self.num_subcarriers, self.num_active) { (64, 52) => &HT20_ACTIVE, + (64, 56) => &CANONICAL56_ACTIVE, (128, 114) => &HT40_ACTIVE, (256, 242) => &HE20_ACTIVE, (512, 484) => &HE40_ACTIVE, + // Fallback selects the slice whose length matches `num_active` so the + // Φ dimensions stay self-consistent even for unconfigured tiers. + (_, 56) => &CANONICAL56_ACTIVE, + (_, 114) => &HT40_ACTIVE, + (_, 242) => &HE20_ACTIVE, + (_, 484) => &HE40_ACTIVE, _ => &HT20_ACTIVE, } } diff --git a/v2/crates/wifi-densepose-signal/src/ruvsense/multistatic.rs b/v2/crates/wifi-densepose-signal/src/ruvsense/multistatic.rs index ce198a25..220fbdfa 100644 --- a/v2/crates/wifi-densepose-signal/src/ruvsense/multistatic.rs +++ b/v2/crates/wifi-densepose-signal/src/ruvsense/multistatic.rs @@ -174,9 +174,32 @@ impl MultistaticFuser { self.cir_estimator = estimator; } - /// Create a fuser with a pre-built `CirEstimator` for HT20 (ADR-134 default). + /// Create a fuser with a pre-built `CirEstimator` for **canonical-56** + /// frames (ADR-154 — the correct default for the RuvSense pipeline). /// - /// Equivalent to `new()` followed by `set_cir_estimator(Some(Arc::new(CirEstimator::new(CirConfig::ht20()))))`. + /// The fuser operates on `CanonicalCsiFrame`s, which `hardware_norm.rs` + /// resamples onto a uniform 56-tone grid. `CirConfig::canonical56()` builds + /// Φ over those 56 tones so `estimate()` actually runs; `CirConfig::ht20()` + /// (52 active) would reject every canonical frame with `SubcarrierMismatch` + /// and silently fall back to the frequency-domain coherence — the dead-gate + /// bug ADR-154 fixes. Prefer this constructor for canonical-56 deployments. + pub fn with_cir_canonical56() -> Self { + let mut fuser = Self::new(); + fuser.cir_estimator = Some(Arc::new(CirEstimator::new(CirConfig::canonical56()))); + fuser + } + + /// Create a fuser with a pre-built `CirEstimator` for **raw HT20** frames + /// (64 FFT bins / 52 active tones). + /// + /// # Warning (ADR-154) + /// + /// This config only runs on frames whose subcarrier count is 64 or 52. The + /// RuvSense multistatic path feeds *canonical-56* frames, so this estimator + /// rejects them with `SubcarrierMismatch` and the CIR gate silently + /// degrades to frequency-domain coherence. Use [`Self::with_cir_canonical56`] + /// for the canonical pipeline; keep this only for paths that genuinely feed + /// raw 64/52-bin HT20 frames. pub fn with_cir_ht20() -> Self { let mut fuser = Self::new(); fuser.cir_estimator = Some(Arc::new(CirEstimator::new(CirConfig::ht20()))); @@ -470,9 +493,43 @@ impl MultistaticFuser { // Frame not sanitized — fall back to freq-domain coherence. freq_coherence } + Err(super::cir::CirError::SubcarrierMismatch { expected, got }) => { + // ADR-154: a mismatch here means the estimator was built for the + // WRONG tier (e.g. ht20's 52-active Φ vs a canonical-56 frame). + // That is a *config* error, not a runtime data condition, so make + // it LOUD in debug builds instead of silently degrading — a silent + // degrade is exactly how the dead-gate bug hid in production. + debug_assert!( + false, + "CIR gate DEAD: estimator expects {expected} subcarriers but got {got}; \ + build it with CirConfig::canonical56() (see MultistaticFuser::with_cir_canonical56). \ + Falling back to frequency-domain coherence." + ); + freq_coherence + } Err(_) => freq_coherence, } } + + /// Test/diagnostic hook (ADR-154): run the CIR estimator on the first frame + /// of `node_frames` and return the raw `estimate()` result. Returns `None` + /// when the gate is disabled or no estimator/frame is available. + /// + /// This exposes the Ok/Err verdict that `cir_gate_coherence` consumes, so a + /// regression test can prove the gate actually runs (counts Ok vs Err on a + /// canonical-56 stream) rather than silently degrading. + pub fn cir_estimate_first( + &self, + node_frames: &[MultiBandCsiFrame], + ) -> Option> { + if !self.config.use_cir_gate { + return None; + } + let estimator = self.cir_estimator.as_ref()?; + let cf = node_frames.first()?.channel_frames.first()?; + let csi_frame = build_csi_frame_from_channel(cf); + Some(estimator.estimate(&csi_frame)) + } } impl Default for MultistaticFuser { @@ -954,4 +1011,109 @@ mod tests { }; assert_eq!(cluster.link_indices.len(), 3); } + + // ----------------------------------------------------------------------- + // ADR-154: CIR coherence gate regression tests (headline anti-slop fix). + // + // Before the fix, `with_cir_ht20()` built a 52-active Φ, so every + // canonical-56 frame returned `SubcarrierMismatch` and the gate silently + // degraded to frequency-domain coherence (100% Err, blend never applied). + // After the fix, `with_cir_canonical56()` runs on canonical-56 frames. + // ----------------------------------------------------------------------- + + /// Build a deterministic canonical-56 stream with sanitized (small) phase + /// so the CIR estimator's ghost-tap guard does not trip. + fn canonical56_stream(n: usize) -> Vec { + (0..n) + .map(|i| make_node_frame(i as u8, 1000 + i as u64, 56, 1.0 + 0.05 * i as f32)) + .collect() + } + + /// PROOF (ADR-154): the old ht20 estimator is DEAD on canonical-56 frames — + /// 100% of `estimate()` calls return `SubcarrierMismatch`. + #[test] + fn cir_gate_ht20_is_dead_on_canonical56() { + let fuser = MultistaticFuser::with_cir_ht20(); + let frames = canonical56_stream(8); + let mut ok = 0; + let mut err_mismatch = 0; + for f in &frames { + match fuser.cir_estimate_first(std::slice::from_ref(f)) { + Some(Ok(_)) => ok += 1, + Some(Err(super::super::cir::CirError::SubcarrierMismatch { .. })) => { + err_mismatch += 1 + } + other => panic!("unexpected estimate result: {other:?}"), + } + } + assert_eq!(ok, 0, "ht20 estimator must NOT decode canonical-56 frames"); + assert_eq!( + err_mismatch, 8, + "every canonical-56 frame must hit SubcarrierMismatch under ht20 (dead gate)" + ); + } + + /// PROOF (ADR-154): after the fix, the canonical-56 estimator decodes every + /// frame (0% Err) — the gate is alive. + #[test] + fn cir_gate_canonical56_is_alive() { + let fuser = MultistaticFuser::with_cir_canonical56(); + let frames = canonical56_stream(8); + let mut ok = 0; + let mut err = 0; + for f in &frames { + match fuser.cir_estimate_first(std::slice::from_ref(f)) { + Some(Ok(_)) => ok += 1, + Some(Err(_)) => err += 1, + None => panic!("gate disabled unexpectedly"), + } + } + assert_eq!(err, 0, "canonical-56 estimator must decode every frame"); + assert_eq!(ok, 8, "all 8 canonical-56 frames must produce a CIR"); + } + + /// PROOF (ADR-154): with the live gate, the blended coherence differs from + /// the gate-off (frequency-domain only) coherence — the CIR term is applied. + #[test] + fn cir_gate_on_changes_coherence_vs_off() { + let frames = canonical56_stream(4); + + // Gate ON, canonical-56 estimator (alive). + let on = MultistaticFuser::with_cir_canonical56(); + let coh_on = on.fuse(&frames).unwrap().cross_node_coherence; + + // Gate OFF: same frames, CIR path disabled → pure freq-domain coherence. + let off = MultistaticFuser::with_config(MultistaticConfig { + use_cir_gate: false, + ..Default::default() + }); + let coh_off = off.fuse(&frames).unwrap().cross_node_coherence; + + assert!( + (coh_on - coh_off).abs() > 1e-6, + "live CIR gate must change coherence: on={coh_on} off={coh_off}" + ); + } + + /// PROOF (ADR-154): the dead ht20 gate is indistinguishable from gate-off — + /// confirming the silent degradation the fix eliminates. (debug_assert is + /// disabled here via release-style check: we call the coherence path which + /// only debug-asserts; this test asserts the *numeric* degeneracy and is + /// gated to release to avoid the intentional debug panic.) + #[test] + #[cfg(not(debug_assertions))] + fn cir_gate_dead_ht20_equals_gate_off() { + let frames = canonical56_stream(4); + let dead = MultistaticFuser::with_cir_ht20(); + let coh_dead = dead.fuse(&frames).unwrap().cross_node_coherence; + let off = MultistaticFuser::with_config(MultistaticConfig { + use_cir_gate: false, + ..Default::default() + }); + let coh_off = off.fuse(&frames).unwrap().cross_node_coherence; + assert!( + (coh_dead - coh_off).abs() < 1e-9, + "dead ht20 gate silently equals gate-off: dead={coh_dead} off={coh_off}" + ); + } } diff --git a/v2/crates/wifi-densepose-signal/src/spectrogram.rs b/v2/crates/wifi-densepose-signal/src/spectrogram.rs index 25bbe8ca..7262e65c 100644 --- a/v2/crates/wifi-densepose-signal/src/spectrogram.rs +++ b/v2/crates/wifi-densepose-signal/src/spectrogram.rs @@ -146,7 +146,15 @@ pub fn compute_multi_subcarrier_spectrogram( } /// Generate a window function. +/// +/// ADR-154: the cosine windows divide by `(size - 1)`, which is zero for +/// `size == 1` (→ NaN samples) and underflows the empty-range maths for tiny +/// sizes. We short-circuit `size <= 1` to a safe constant window (empty for 0, +/// single unit sample for 1) before any `size - 1` arithmetic runs. fn make_window(kind: WindowFunction, size: usize) -> Vec { + if size <= 1 { + return vec![1.0; size]; + } match kind { WindowFunction::Rectangular => vec![1.0; size], WindowFunction::Hann => (0..size) @@ -310,6 +318,26 @@ mod tests { assert!(w.iter().all(|&v| (v - 1.0).abs() < 1e-10)); } + // ADR-154: degenerate window sizes must not divide by (n-1)==0 → NaN. + #[test] + fn make_window_size_0_and_1_are_safe() { + for wf in [ + WindowFunction::Hann, + WindowFunction::Hamming, + WindowFunction::Blackman, + WindowFunction::Rectangular, + ] { + assert!(make_window(wf, 0).is_empty(), "{wf:?} size-0 must be empty"); + let w1 = make_window(wf, 1); + assert_eq!(w1.len(), 1, "{wf:?} size-1 must have one sample"); + assert!( + w1[0].is_finite() && (w1[0] - 1.0).abs() < 1e-12, + "{wf:?} size-1 must be a finite unit sample, got {}", + w1[0] + ); + } + } + #[test] fn test_signal_too_short() { let signal = vec![1.0; 10];