From d1e6d28a4abf3814c3fe57c404785c6935dcaefa Mon Sep 17 00:00:00 2001 From: ruv Date: Thu, 28 May 2026 16:22:46 -0400 Subject: [PATCH] fix(signal): make CIR witness cross-platform-deterministic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first witness (Windows-generated hash 89704bfd…) failed on Linux CI with a different hash (b36741bf…). Root cause: hashing `re`/`im` parts of top-5 taps at 1e-6 precision is too tight against libm differences in sin/cos/sqrt across glibc, MSVC, and Apple-clang. The previous "top-5 sorted by magnitude" form also suffered from rank instability when taps are near-tied — libm jitter could shuffle the ordering even when the algorithm is unchanged. New canonical form: full per-tap quantised-magnitude profile in natural index order, no sort. - 156 taps × 2 bytes (u16 le) per frame = 312 bytes/frame. - Quantisation 1e-2 — robust to ~1e-3 float drift while still tripping on real algorithmic changes (e.g., a 10× lambda shift moves magnitudes by >1e-2). - No top-K selection — eliminates the unstable magnitude-sort step. Regenerated expected_cir_features.sha256 — new hash 120bd7b1… If the next CI run still mismatches, the cause is structural (rustfft SIMD code path selection or NeumannSolver internal ordering), not magnitudes, and the witness needs further coarsening or to be made platform-tagged. Co-Authored-By: claude-flow --- .../data/proof/expected_cir_features.sha256 | 2 +- .../src/bin/cir_proof_runner.rs | 52 +++++++++---------- 2 files changed, 27 insertions(+), 27 deletions(-) diff --git a/archive/v1/data/proof/expected_cir_features.sha256 b/archive/v1/data/proof/expected_cir_features.sha256 index d29ca7de..7d9d7cb1 100644 --- a/archive/v1/data/proof/expected_cir_features.sha256 +++ b/archive/v1/data/proof/expected_cir_features.sha256 @@ -1 +1 @@ -89704bfdb3b1858e1bbfb4ccd32cc31d5a9fd28266e864dbeba51857b0084a91 +120bd7b1f549f57f3773971a389c48c2bdd99b4ab1f205935867a16e95583995 diff --git a/v2/crates/wifi-densepose-signal/src/bin/cir_proof_runner.rs b/v2/crates/wifi-densepose-signal/src/bin/cir_proof_runner.rs index 5fc7c6db..99c33c46 100644 --- a/v2/crates/wifi-densepose-signal/src/bin/cir_proof_runner.rs +++ b/v2/crates/wifi-densepose-signal/src/bin/cir_proof_runner.rs @@ -45,8 +45,8 @@ use wifi_densepose_signal::ruvsense::cir::{CirConfig, CirEstimator}; /// Number of frames to process (matches Python verify.py). const FRAME_COUNT: usize = 100; -/// Number of top taps to record per frame. -const TOP_TAPS: usize = 5; +/// CirConfig::ht20() delay-bin count = 156 — full profile width hashed per frame. +const PROFILE_BIN_COUNT: usize = 156; /// Subcarrier count in the raw legacy reference signal (Atheros 9580 convention). const N_SUBCARRIERS_RAW: usize = 56; @@ -120,27 +120,26 @@ fn frame_from_json(record: &Value) -> CsiFrame { CsiFrame::new(metadata, data) } -/// Canonical serialisation of one frame's top-5 CIR taps. -/// Format: for each tap (sorted by tap_idx descending power): -/// [tap_idx: u64 le][re_q: i64 le][im_q: i64 le] -fn serialise_top_taps(taps: &[Complex32]) -> Vec { - // Find top-N taps by magnitude - let mut indexed: Vec<(usize, f32)> = taps - .iter() - .enumerate() - .map(|(i, c)| (i, c.norm())) - .collect(); - indexed.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)); - - let n = TOP_TAPS.min(indexed.len()); - let mut out = Vec::with_capacity(n * 24); - for &(tap_idx, _) in &indexed[..n] { - let c = taps[tap_idx]; - let re_q = (c.re * 1e6_f32).round() as i64; - let im_q = (c.im * 1e6_f32).round() as i64; - out.extend_from_slice(&(tap_idx as u64).to_le_bytes()); - out.extend_from_slice(&re_q.to_le_bytes()); - out.extend_from_slice(&im_q.to_le_bytes()); +/// Canonical, cross-platform-deterministic serialisation of one frame's CIR. +/// +/// We previously hashed (a) raw real/imag at 1e-6 precision and (b) the top-5 +/// tap pairs sorted by magnitude. Both broke across platforms because libm +/// differences (glibc / MSVC / Apple) on `sin`/`cos`/`sqrt` drift by ~1e-7, +/// which is enough to (i) flip rounded integers and (ii) re-order near-tied +/// taps in a magnitude sort. The witness exists to detect *algorithmic* +/// regressions, not libm jitter. +/// +/// New canonical form: the full per-tap quantised magnitude profile, in +/// natural index order, no sort. At 1e-2 precision a 1% drift in any tap is +/// invisible; a 10× lambda change moves taps by >1e-2 and breaks the hash. +/// +/// Format: `[mag_q: u16 le]` per tap, `num_taps` taps per frame. Saturating to +/// u16 caps magnitudes at 65.535, well above the 1.0-ish normalised range. +fn serialise_profile(taps: &[Complex32]) -> Vec { + let mut out = Vec::with_capacity(taps.len() * 2); + for c in taps { + let mag_q = (c.norm() * 1e2_f32).round().max(0.0).min(u16::MAX as f32) as u16; + out.extend_from_slice(&mag_q.to_le_bytes()); } out } @@ -158,13 +157,14 @@ fn compute_hash(json_path: &Path) -> String { let frame = frame_from_json(record); match estimator.estimate(&frame) { Ok(cir) => { - let bytes = serialise_top_taps(&cir.taps); + let bytes = serialise_profile(&cir.taps); hasher.update(&bytes); } Err(e) => { eprintln!("WARNING: CIR estimate failed for frame: {}", e); - // Write 24*TOP_TAPS zero bytes so the hash is still deterministic - hasher.update(vec![0u8; TOP_TAPS * 24]); + // Write PROFILE_BIN_COUNT * sizeof(u16) zero bytes so the hash + // stays deterministic even when frames consistently fail. + hasher.update(vec![0u8; PROFILE_BIN_COUNT * 2]); } } }