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:
parent
07b6bf8084
commit
be068748b3
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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()));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
Loading…
Reference in New Issue