Files
ruvnet--RuView/v2/crates/wifi-densepose-signal/src/bin/calibration_proof_runner.rs
T
ruv 8504638187 feat(signal): ADR-135 — empty-room baseline calibration
Operator-initiated calibration that records 30 s of stationary CSI,
emits a per-subcarrier baseline (amplitude mean+variance via Welford,
phase via circular sin/cos sums with von Mises dispersion), and gates
downstream stages on a deviation z-score. Plugs into multistatic
coherence gating, motion/presence detection, and the new ADR-134 CIR
estimator as a reference-subtracted input.

API surface (under wifi_densepose_signal):
  CalibrationConfig::{ht20, ht40, he20, he40}
  CalibrationRecorder { record(), finalize(), frames_recorded() }
  BaselineCalibration {
    subcarriers: Vec<SubcarrierBaseline>,
    deviation(&CsiFrame), subtract_in_place(&mut CsiFrame),
    to_bytes(), from_bytes()
  }
  CalibrationDeviationScore { amplitude_z_median, amplitude_z_max,
                              phase_drift_median, motion_flagged }
  CalibrationError { SubcarrierMismatch, TierMismatch,
                     InsufficientFrames, VersionMismatch, TruncatedBuffer }

Binary baseline format: magic 0xCA1B_0001 + u8 version=1 + u8 tier +
captured_at_unix_s (i64) + frame_count (u64) + num_subcarriers (u32) +
[SubcarrierBaseline; N] as 16 bytes each (amp_mean, amp_variance,
phase_mean, phase_dispersion as f32 LE). Hand-written serialisation so
the format is stable across Rust toolchain versions without serde drift.

CLI: new `wifi-densepose calibrate` subcommand binds a UDP listener
(0xC511_0001 frames), streams them through CalibrationRecorder, prints
a real-time z-score banner per ADR-135 §risk 1 (operator-may-be-moving),
aborts on sustained high deviation, and writes the binary baseline to
disk. Local UDP packet parser duplicated from sensing-server (per ADR
discussion — avoids cross-crate API churn).

Witness: cross-platform-deterministic SHA-256 over the per-subcarrier
quantised baseline profile (u16 LE at 1e-2/1e-4/1e-3, no sort) using
the lesson learnt from the CIR PR #837 libm-jitter fix. Hash:
d6bce07ecb1648e6936561df44bf4a3bfc17bb0ba5f692646b2301d105b52f67

CI guard: new "ADR-135 calibration witness proof (determinism guard)"
step under the Rust Workspace Tests job, adjacent to the existing
ADR-134 CIR guard. Regressions are unambiguously attributable.

Hardware-in-loop validation: full 600-frame capture exercised via the
new scripts/synth-csi-udp.py emitter targeting 127.0.0.1:5005. The CLI
binary received 600 frames at 20 Hz, z_med stable at ~0.7, motion
correctly NOT flagged, finalised baseline written to baseline.bin (860
bytes) with correct magic + version + timestamp in the header. Live
ESP32 capture from COM9 is operator follow-up — requires provisioning
the firmware's UDP target IP to match the host running the CLI.

Test results (cargo test -p wifi-densepose-signal --no-default-features):
  lib:                    382 pass / 0 fail / 1 ignored
  calibration_synthetic:   17 pass / 0 fail
  calibration_drift:        5 pass / 0 fail
  calibration_roundtrip:   10 pass / 0 fail
  cir_*:                    9 pass + 6 documented P2 ignores
  doctest:                 10 pass

Bench: 20 Criterion combinations registered
(recorder_record / recorder_finalize / deviation / record_600 /
to_bytes across HT20/HT40/HE20/HE40 tiers).

Witness: bash scripts/verify-calibration-proof.sh → VERDICT: PASS

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-28 18:57:08 -04:00

278 lines
9.9 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Calibration Deterministic Proof Runner (ADR-135)
//!
//! Verifies or generates the canonical SHA-256 hash of the CalibrationRecorder's
//! deterministic output on a synthetic stationary channel (seed=42, HT20, 600 frames).
//!
//! Cross-platform portability lesson (from cir_proof_runner.rs, line 123):
//! Raw f32 round-trips at high precision (1e-6) and magnitude-sort-then-truncate
//! both break across libm implementations (glibc / MSVC / Apple) because sin/cos/sqrt
//! differ by ~1e-7 — enough to flip a rounded integer or re-order near-tied values.
//! The fix: serialise the full per-subcarrier profile in natural index order at
//! coarse quantisation (1e-2 / 1e-4 / 1e-3). A 1% drift is invisible to the hash;
//! a 10× algorithm change moves values by >1e-2 and breaks the hash.
//! No sort, no truncation, no libm-sensitive comparison.
//!
//! Canonical form (per subcarrier k, 4 × u16 LE):
//! [0] (amp_mean * 1e2).round() as u16
//! [1] (amp_variance * 1e4).round() as u16
//! [2] ((phase_mean + π) * 1e3).round() as u16 ← shifted so always non-negative
//! [3] (phase_dispersion * 1e3).round() as u16
//!
//! Prefix: tier byte (0 = HT20), frame_count u64 LE.
//! All subcarriers in natural index order; no sort.
//!
//! Usage:
//! cargo run -p wifi-densepose-signal --bin calibration_proof_runner \
//! --release --no-default-features -- --generate-hash
//!
//! cargo run -p wifi-densepose-signal --bin calibration_proof_runner \
//! --release --no-default-features
//! (compares against archive/v1/data/proof/expected_calibration_features.sha256)
//!
//! IMPORTANT: This binary cannot compile until CalibrationRecorder is implemented.
//! While the implementation is in progress, a placeholder hash is committed in
//! archive/v1/data/proof/expected_calibration_features.sha256. Regenerate with:
//!
//! cd v2 && cargo run -p wifi-densepose-signal --bin calibration_proof_runner \
//! --release --no-default-features -- --generate-hash \
//! > ../archive/v1/data/proof/expected_calibration_features.sha256
use std::env;
use std::f32::consts::PI;
use std::fs;
use std::io::{self, Write};
use std::path::PathBuf;
use ndarray::Array2;
use num_complex::Complex64;
use sha2::{Digest, Sha256};
use wifi_densepose_core::types::{AntennaConfig, CsiFrame, CsiMetadata, DeviceId, FrequencyBand};
use wifi_densepose_signal::calibration::{CalibrationConfig, CalibrationRecorder};
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const N_ACTIVE: usize = 52; // HT20 active subcarriers
const N_FRAMES: usize = 600; // 30 s × 20 Hz
const TIER_BYTE: u8 = 0; // 0 = HT20
// ---------------------------------------------------------------------------
// Deterministic PRNG (xorshift32, seed=42) — duplicated locally.
// ---------------------------------------------------------------------------
struct Rng(u32);
impl Rng {
fn new(seed: u32) -> Self {
assert_ne!(seed, 0, "xorshift seed must be non-zero");
Self(seed)
}
fn next_u32(&mut self) -> u32 {
let mut x = self.0;
x ^= x << 13;
x ^= x >> 17;
x ^= x << 5;
self.0 = x;
x
}
fn next_normal(&mut self) -> f32 {
let u1 = (self.next_u32() as f32 + 1.0) / (u32::MAX as f32 + 2.0);
let u2 = (self.next_u32() as f32 + 1.0) / (u32::MAX as f32 + 2.0);
let r = (-2.0 * u1.ln()).sqrt();
let theta = 2.0 * PI * u2;
r * theta.cos()
}
}
// ---------------------------------------------------------------------------
// Synthetic CSI frame generator — stationary channel, seed=42
//
// amp[k] = 0.3 + 0.7 * |sin(k * π / K)| (smooth across subcarriers)
// phase[k] = (k * 0.1) mod 2π π (slowly rotating)
// AWGN at ~30 dB SNR added via Box-Muller.
// ---------------------------------------------------------------------------
fn make_frame(rng: &mut Rng) -> CsiFrame {
let n = N_ACTIVE;
let noise_std = 0.01_f32;
let mut data = Array2::<Complex64>::zeros((1, n));
for k in 0..n {
let amp = 0.3 + 0.7 * (k as f32 * PI / n as f32).sin().abs();
let phase = (k as f32 * 0.1).rem_euclid(2.0 * PI) - PI;
let re = amp * phase.cos() + noise_std * rng.next_normal();
let im = amp * phase.sin() + noise_std * rng.next_normal();
data[(0, k)] = Complex64::new(re as f64, im as f64);
}
let mut meta =
CsiMetadata::new(DeviceId::new("proof-runner"), FrequencyBand::Band2_4GHz, 6);
meta.bandwidth_mhz = 20;
meta.antenna_config = AntennaConfig::new(1, 1);
CsiFrame::new(meta, data)
}
// ---------------------------------------------------------------------------
// Canonical, cross-platform-deterministic serialisation.
//
// Per ADR-135 proof spec and the cir_proof_runner.rs lesson (line 123):
// coarse u16 quantisation, natural subcarrier order, no sort.
// ---------------------------------------------------------------------------
fn serialise_baseline_canonical(
subcarriers: &[wifi_densepose_signal::calibration::SubcarrierBaseline],
frame_count: u64,
) -> Vec<u8> {
let k = subcarriers.len();
// Header: tier byte + frame_count as u64 LE
let mut out = Vec::with_capacity(1 + 8 + k * 8);
out.push(TIER_BYTE);
out.extend_from_slice(&frame_count.to_le_bytes());
for sc in subcarriers {
// [0] amp_mean at 1e-2 resolution
let amp_q = (sc.amp_mean * 1e2_f32)
.round()
.max(0.0)
.min(u16::MAX as f32) as u16;
out.extend_from_slice(&amp_q.to_le_bytes());
// [1] amp_variance at 1e-4 resolution
let var_q = (sc.amp_variance * 1e4_f32)
.round()
.max(0.0)
.min(u16::MAX as f32) as u16;
out.extend_from_slice(&var_q.to_le_bytes());
// [2] phase_mean shifted by +π so it is non-negative, at 1e-3 resolution
let phase_q = ((sc.phase_mean + PI) * 1e3_f32)
.round()
.max(0.0)
.min(u16::MAX as f32) as u16;
out.extend_from_slice(&phase_q.to_le_bytes());
// [3] phase_dispersion (von Mises 1R̄, in [0,1]) at 1e-3 resolution
let disp_q = (sc.phase_dispersion * 1e3_f32)
.round()
.max(0.0)
.min(u16::MAX as f32) as u16;
out.extend_from_slice(&disp_q.to_le_bytes());
}
out
}
// ---------------------------------------------------------------------------
// Repo root discovery
// ---------------------------------------------------------------------------
fn repo_root() -> PathBuf {
let cwd = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let candidates = [
cwd.clone(),
cwd.join(".."),
cwd.join("../.."),
cwd.join("../../.."),
];
for candidate in &candidates {
if candidate
.join("archive/v1/data/proof/expected_calibration_features.sha256")
.exists()
|| candidate.join("archive/v1/data/proof/sample_csi_data.json").exists()
{
return candidate.canonicalize().unwrap_or(candidate.clone());
}
}
cwd
}
// ---------------------------------------------------------------------------
// Main hash computation
// ---------------------------------------------------------------------------
fn compute_hash() -> String {
let config = CalibrationConfig::ht20();
let mut recorder = CalibrationRecorder::new(config);
let mut rng = Rng::new(42);
for _ in 0..N_FRAMES {
let frame = make_frame(&mut rng);
recorder
.record(&frame)
.expect("record() must succeed for synthetic frames");
}
let baseline = recorder
.finalize()
.expect("finalize() must succeed after 600 frames");
let payload = serialise_baseline_canonical(&baseline.subcarriers, baseline.frame_count);
let mut hasher = Sha256::new();
hasher.update(&payload);
format!("{:x}", hasher.finalize())
}
// ---------------------------------------------------------------------------
// Entry point
// ---------------------------------------------------------------------------
fn main() {
let args: Vec<String> = env::args().collect();
let generate_hash = args.iter().any(|a| a == "--generate-hash");
let hash = compute_hash();
if generate_hash {
println!("{}", hash);
return;
}
// Compare against stored hash
let root = repo_root();
let hash_path = root.join("archive/v1/data/proof/expected_calibration_features.sha256");
if !hash_path.exists() {
eprintln!(
"ERROR: expected hash file not found at {}",
hash_path.display()
);
eprintln!("Run with --generate-hash to create it.");
std::process::exit(1);
}
let expected_content = fs::read_to_string(&hash_path)
.unwrap_or_else(|e| panic!("Cannot read {}: {}", hash_path.display(), e));
let expected = expected_content
.split_whitespace()
.find(|s| !s.starts_with('#'))
.unwrap_or("")
.to_owned();
if expected.starts_with("PLACEHOLDER") {
eprintln!("BLOCKED: calibration proof hash is a placeholder.");
eprintln!(
"The calibration module (ADR-135) is not yet fully implemented. \
After the implementation lands, regenerate:"
);
eprintln!(
" cd v2 && cargo run -p wifi-densepose-signal --bin calibration_proof_runner \
--release --no-default-features -- --generate-hash \
> ../archive/v1/data/proof/expected_calibration_features.sha256"
);
std::process::exit(2);
}
if hash == expected {
println!("VERDICT: PASS (calibration hash matches)");
std::process::exit(0);
} else {
eprintln!("VERDICT: FAIL");
eprintln!("expected: {}", expected);
eprintln!("actual: {}", hash);
io::stderr().flush().ok();
std::process::exit(1);
}
}