fix(signal): revive dead CIR coherence gate + NaN bypass + window div0 (ADR-154 M0)

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 <ruv@ruv.net>
This commit is contained in:
ruv 2026-06-11 19:20:37 -04:00
parent 07b6bf8084
commit be068748b3
7 changed files with 385 additions and 16 deletions

View File

@ -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<f64> = (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<f64> = 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());

View File

@ -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<f64> {
(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()));
}
}

View File

@ -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();

View File

@ -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(())
}

View File

@ -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,
}
}

View File

@ -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<Result<super::cir::Cir, super::cir::CirError>> {
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<MultiBandCsiFrame> {
(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}"
);
}
}

View File

@ -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<f64> {
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];