Files
ruvnet--RuView/v2/crates/wifi-densepose-signal/benches/cir_bench.rs
T
rUv 29de574e63 Beyond-SOTA engine/signal/train improvements: mesh partition guard, FFT CIR solver, canonical frame decoder, falsifiable occupancy benchmark, governed streaming, adapter provenance (#1018)
* docs(research): add RuView beyond-SOTA system review (00)

First document of the beyond-SOTA research series: capability audit of
the current RuView engine with role-to-crate maturity matrix, ruvsense
module inventory, gap analysis, and risk register.

https://claude.ai/code/session_01MjBucx95K4BuUxZi8NWwRH

* docs(research): add beyond-SOTA architecture design (02, in progress)

https://claude.ai/code/session_01MjBucx95K4BuUxZi8NWwRH

* docs(research): finalize beyond-SOTA architecture (02)

https://claude.ai/code/session_01MjBucx95K4BuUxZi8NWwRH

* docs(research): add benchmark/validation methodology snapshot (03)

https://claude.ai/code/session_01MjBucx95K4BuUxZi8NWwRH

* docs(research): add beyond-SOTA series index with validation results; changelog

README index ties the 5 research docs together with the session's
measured validation evidence: 2,797 workspace tests / 0 failed, Python
proof PASS (bit-exact), and paired pre/post criterion CIR benchmarks.

https://claude.ai/code/session_01MjBucx95K4BuUxZi8NWwRH

* perf(signal): precompute CIR warm-start system; hoist tomography solver allocs

Exact, determinism-safe optimizations (bit-identical float results):

- cir.rs: diag(PhiH Phi)+lambda*I and its CSR matrix depend only on Phi
  and lambda (fixed at CirEstimator::new) but were rebuilt every frame
  (O(K*G) pass + CSR allocation). Now built once in new() via
  build_warm_start_system; summation order unchanged.
- tomography.rs: ISTA gradient buffer hoisted out of the 100-iteration
  loop (fill(0.0) reset) and the Frobenius Lipschitz bound moved from
  per-reconstruct to construction.

Verified: signal 456 tests green; engine 11/11 green including
cycle_is_deterministic and witness-stability tests. Criterion paired
pre/post: cir_estimate/he40 -3.9% (p<0.01), multiband -1.2/-1.4%.

https://claude.ai/code/session_01MjBucx95K4BuUxZi8NWwRH

* fix(worldgraph): bound SemanticState growth with deterministic retention

StreamingEngine::process_cycle appended one SemanticState belief per cycle
with no eviction — ~1.7M nodes/day at 20 Hz (beyond-SOTA roadmap finding #6).

Add WorldGraph::prune_semantic_states(max): deterministic eviction of the
oldest beliefs by (valid_from_unix_ms, id); structural nodes (rooms, zones,
sensors, anchors, tracks, events) are never eligible. Wire it into the
engine after each belief append (DEFAULT_SEMANTIC_RETENTION = 7,200, ~6 min
at 20 Hz; set_semantic_retention to tune). The WorldGraph holds current
beliefs; durable history is the recorder's job, so no audit data is lost.

3 new tests: end-to-end bounded growth, oldest-only eviction, deterministic
equal-timestamp tie-break. Workspace gate: 2,865 passed, 0 failed.

https://claude.ai/code/session_01MjBucx95K4BuUxZi8NWwRH

* feat(sensing-server): route live frames through the governed StreamingEngine

Closes the live-trust-path gap (ADR-136 section 8, beyond-SOTA system review):
the running server fused live CSI with the bare MultistaticFuser, while the
privacy/provenance/witness control plane (ADR-135..146) only ever ran on
synthetic in-test frames. The privacy control plane was therefore bypassable
on the real path.

New engine_bridge module drives StreamingEngine::process_cycle from the
server's live NodeState map, reusing the existing NodeState -> MultiBandCsiFrame
conversion. It lazily wires each contributing node as a WorldGraph sensor
(idempotent), bounds belief growth via the retention cap, and forwards explicit
timestamps/calibration ids so the path stays deterministic and replayable.

Wired additively into both live ESP32/WiFi fusion sites in main.rs via a
split-borrow off the write guard, so person-count behavior is unchanged; the
latest BLAKE3 witness is stored on AppState. Every published belief now carries
evidence + model + calibration + privacy decision and a deterministic witness.

Adds wifi-densepose-engine/-worldgraph/-bfld/-geo deps. 6 new bridge tests
(witnessed belief with full provenance, cross-run determinism, idempotent node
registration, retention bound, privacy-mode propagation). sensing-server suite
430+128 green; workspace gate 2,904 passed / 0 failed.

https://claude.ai/code/session_01MjBucx95K4BuUxZi8NWwRH

* feat(train): falsifiable occupancy benchmark with anti-overfitting gate

Makes the presence/person-count "beyond SOTA" claim falsifiable in code
instead of aspirational (the unfalsifiability gap from the beyond-SOTA system
review). occupancy_bench grades predictions vs ground truth and gates a SOTA
claim behind one claim_allowed invariant requiring ALL of:

- DataProvenance::Measured — synthetic/mock data is scorable for regression
  but never claimable (anti-mock-contamination; the CLAUDE.md Kconfig-bug
  lesson made structural).
- A leak-free EvalSplit — validate() refuses any split where a subject OR
  environment id appears in both train and test (subject leakage /
  per-environment overfitting).
- n_test >= min_test_samples (small-N guard).
- Presence F1 whose bootstrap-CI lower bound (deterministic seeded splitmix64)
  clears the threshold — not the point estimate.
- Count MAE within threshold.

The claim string is unreadable except through the gate (NO_CLAIM otherwise),
same discipline as the ruview-gamma acceptance gate. What remains is data, not
method: a frozen, SHA-pinned, subject/environment-disjoint measured replay set
turns the claim into a passing/failing test.

Lives in wifi-densepose-train (the eval bounded context, alongside ablation/
eval/metrics). 10 tests cover each refusal path; warning-clean under the
crate's missing_docs lint. Workspace gate 2,914 passed / 0 failed. Doc 03
updated.

https://claude.ai/code/session_01MjBucx95K4BuUxZi8NWwRH

* feat(engine): per-room adapter provenance + drift-to-recalibration advisor

Closes the trust-chain gap where an ~11 KB per-room LoRA adapter (ADR-150
section 3.4) could silently change inference without the witness noticing:
provenance carried only "rfenc-v<N>" with no notion of adapter identity.

- StreamingEngine::set_room_adapter(AdapterInfo): pins the adapter's
  content-derived id into provenance model_version
  ("rfenc-v1+adapter:<id>") — and therefore into the BLAKE3 witness — so
  swapping or clearing adapter weights always shifts the witness. Engine test
  proves base -> adapter -> other-adapter -> cleared all witness differently
  and cleared == base.
- RecalibrationAdvisor: recommends re-running the ADR-135 empty-room baseline
  / refitting the room adapter on sustained low fusion coherence (streak
  threshold, default 60 cycles ~ 3 s at 20 Hz) or an ADR-142 change-point.
  Surfaced as TrustedOutput::recalibration_recommended, stored on the
  sensing-server AppState alongside the witness at both live fusion sites.
- Bridge plumbing: EngineBridge::{set_room_adapter, clear_room_adapter} +
  live-path test that the adapter id flows into the live witness.

Scope note (honest): this is the deployable provenance/trigger half of the
"retrained model" roadmap item. Fitting the adapter itself runs in the
existing external calibration service (aether-arena/calibration/); a trained
RF-encoder checkpoint still does not exist in-tree.

Engine 15 tests, bridge 7 tests. Workspace gate: 2,918 passed / 0 failed.

https://claude.ai/code/session_01MjBucx95K4BuUxZi8NWwRH

* fix(mat): gate api module behind its feature — standalone no-default-features builds

pub mod api was unconditional while its only dependency, serde, is optional
behind the 'api' feature, so any build without default features failed with
101 unresolved-serde errors (masked in --workspace runs by feature
unification). The api module and its create_router/AppState re-export are now
cfg(feature = "api")-gated with docsrs annotations.

All combos compile: bare --no-default-features (was 101 errors, now 0),
--no-default-features --features api, and full default (177 tests pass).
Workspace gate: 2,918 passed / 0 failed.

https://claude.ai/code/session_01MjBucx95K4BuUxZi8NWwRH

* perf(signal): opt-in FFT operator for the CIR ISTA solver (8-14x measured)

Phi is a sub-DFT, so each ISTA mat-vec can run as one length-G FFT
(O(G log G)) instead of a dense O(K*G) product — the dominant-latency-hazard
finding from the beyond-SOTA optimization roadmap.

New CirConfig::fft_operator, default FALSE: the dense path stays the
bit-exact witness default. The FFT evaluates the same sums in a different
order, so enabling it shifts float results in the last bits and requires
regenerating any pinned witness — strictly opt-in per deployment.

FftOperator (rustfft, planned once at CirEstimator::new, scratch buffers
reused across the ISTA loop) dispatches inside ista_solve:
  Phi x   = scale * forward-FFT(x) sampled at bins (k_idx mod G)
  Phi^H v = scale * unnormalised inverse-FFT of v scattered into those bins
Warm-start and Lipschitz estimation stay dense at construction.

Measured (criterion, same run, same machine):
  ht20: 2.22 ms -> 265 us  (8.4x)
  ht40: 10.26 ms -> 717 us (14.3x)
The real HE40 grid (K=484, G=1452) scales further per the O(K*G)/O(G log G)
ratio.

3 new tests: FFT<->dense matvec equivalence to float tolerance on ht20 and
he40 grids; end-to-end dominant-tap agreement on a single-path frame; all
default configs keep FFT off. New cir_estimate_fft bench group.

Workspace gate: 2,921 passed / 0 failed (default path bit-exact, witnesses
unchanged).

https://claude.ai/code/session_01MjBucx95K4BuUxZi8NWwRH

* feat(core): canonical frame decoder — capture-to-claim replay (ADR-136)

The encode half of the ADR-136 frame contract existed (ComplexSample,
to_canonical_bytes, witness_hash) but there was no decoder: a captured
canonical frame could be witnessed but never reconstructed, blocking
replay-from-capture.

CsiFrame::from_canonical_bytes is the exact inverse: same id, metadata,
complex payload, and witness hash (tested as the round-trip law AC7 — the
replayed frame re-encodes byte-identically). Amplitude/phase are recomputed
from the payload (projections, not independent state). Every malformed-input
class fails closed (AC8): header truncation -> Truncated, payload truncation
-> PayloadMismatch, unknown discriminants, non-UTF-8 device id, trailing
bytes. Nil calibration uuid decodes as None per the documented encoding.

Core: 36 tests pass. Workspace gate: 2,937 passed / 0 failed.

https://claude.ai/code/session_01MjBucx95K4BuUxZi8NWwRH

* feat(engine): dynamic min-cut mesh partition guard (ruvector-mincut)

Maintains an exact min-cut over the live mesh coupling graph — nodes are
sensing nodes, coupling is the product of fusion attention weights — and
surfaces per cycle, as TrustedOutput::mesh:

- cut value: the global "how close is the array to partitioning" number,
  a structural measure per-node heuristics miss;
- weak side: which specific nodes would split off (failure/jamming triage,
  feeds ADR-032 posture);
- at-risk flag: counts as a structural event for the drift->recalibration
  advisor (alongside ADR-142 change-points).

Degenerate cases fail toward risk: a node with zero coupling is reported as
already partitioned (cut 0, that node as the weak side).

Measured cost policy (criterion, 12-node mesh — the honest part):
- weights quantized (1/64) + change-gated: steady-state cycles do ZERO graph
  work and reuse the cached cut (~7.3 us, ~23x cheaper than building);
- on any real change a full exact rebuild (~171 us) is used, because ONE
  DynamicMinCut delete+insert measured ~240 us — the subpolynomial machinery
  amortizes on much larger graphs, so rebuild-on-change is the measured
  optimum at mesh scale (one-edge case -28% after switching policy);
- full process_cycle with the guard: ~33 us for 4 nodes vs the 50 ms budget.

9 mesh_guard tests (weak-node detection, steady-state zero updates,
sub-quantum gating, join/drop rebuild, determinism, disconnection) + an
engine-level wiring test (down-weighted node -> weak side -> recalibration).
Engine 24 tests; workspace gate 2,946 passed / 0 failed.

https://claude.ai/code/session_01MjBucx95K4BuUxZi8NWwRH

* feat(engine): mesh partition risk demotes privacy + enters the witness (ADR-032)

Completes the mesh-guard integration: its at_risk signal was advisory-only
(fed the recalibration advisor). It now also contributes to the ADR-141
privacy demotion alongside fusion- and array-level contradictions — a mesh
close to partitioning makes the fused belief less trustworthy, so the cycle
emits at a more restricted class (monotonic; information only removed).

Because effective_class feeds the BLAKE3 witness, a fragmenting array now
shifts the witness: partition risk is auditable, not just logged. The mesh
computation moved ahead of the demotion step in process_cycle; mesh_guard_mut
exposes risk-threshold tuning.

Test: a forced-risk 3-node cycle demotes PrivateHome Anonymous->Restricted
and shifts the witness vs a clean baseline. Engine 25 tests; workspace gate
2,947 passed / 0 failed.

https://claude.ai/code/session_01MjBucx95K4BuUxZi8NWwRH

* fix: public-PR review findings — privacy-path honesty, gate holes, mesh-guard cliff

- sensing-server: engine errors logged+counted (no silent swallow), trust
  state exposed via status surface, privacy-demotion claims aligned with
  the actual parallel-audit-path behavior
- occupancy_bench: vacuous-F1 hole closed (degenerate test sets fail with
  their own criterion); CI-lower-bound test made probative
- mesh_guard: quantization scaled to observed coupling range — >=65-node
  balanced meshes no longer permanently at_risk (regression test)
- engine: both wiring tests made probative (same-topology witness compare,
  deterministic risk-crossing fixture)
- mat: axum/tokio optional behind api; real serde feature (api enables it)
- core: canonical decoder strict (non-zero reserved bytes and nil UUID
  rejected — injective on accepted domain, forged-bytes tests)
- CHANGELOG: un-spliced the FFT/adapter bullet mangle

Co-Authored-By: claude-flow <ruv@ruv.net>

* chore: strip private-track references for public PR

Reword the occupancy-benchmark changelog bullet to drop a cross-reference
to the private research track, and restore the WorldGraph retention bullet
header that was glued onto the preceding MAT bullet.

Co-Authored-By: claude-flow <ruv@ruv.net>

* chore: lockfile refresh for cherry-picked feature set

Co-Authored-By: claude-flow <ruv@ruv.net>

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-11 16:08:54 -04:00

279 lines
9.6 KiB
Rust

//! Criterion benchmarks for the CIR estimator (ADR-134).
//!
//! Measures per-call throughput of `CirEstimator::estimate()` across all
//! four hardware tiers (HT20, HT40, HE20, HE40) and the 12-link amortization
//! pattern used by the RuvSense multistatic aggregator.
//!
//! Run (compile-only check):
//! cargo bench -p wifi-densepose-signal --no-default-features --bench cir_bench --no-run
//!
//! Run to completion (slow — generates HTML reports in target/criterion/):
//! cargo bench -p wifi-densepose-signal --no-default-features --bench cir_bench
#![cfg(feature = "cir")]
use std::f64::consts::PI;
use criterion::{black_box, criterion_group, criterion_main, BenchmarkId, Criterion, Throughput};
use ndarray::Array2;
use num_complex::Complex64;
use wifi_densepose_core::types::{AntennaConfig, CsiFrame, CsiMetadata, DeviceId, FrequencyBand};
use wifi_densepose_signal::cir::{CirConfig, CirEstimator};
// ---------------------------------------------------------------------------
// Deterministic PRNG (xorshift32, seed=42)
// ---------------------------------------------------------------------------
struct Rng(u32);
impl Rng {
fn new(seed: u32) -> Self {
assert_ne!(seed, 0);
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_f64(&mut self) -> f64 {
(self.next_u32() as f64 + 1.0) / (u32::MAX as f64 + 2.0)
}
fn next_normal(&mut self) -> f64 {
let u1 = self.next_f64();
let u2 = self.next_f64();
(-2.0 * u1.ln()).sqrt() * (2.0 * PI * u2).cos()
}
}
// ---------------------------------------------------------------------------
// Synthetic CSI generator — 3-tap deterministic channel (seed=42)
// ---------------------------------------------------------------------------
/// Build a 3-tap deterministic CSI vector for the given config.
///
/// Tap parameters mirror `cir_synthetic.rs`:
/// direct path: τ=10 ns, amplitude 1.0
/// reflection 1: τ=80 ns, amplitude 0.6
/// reflection 2: τ=180 ns, amplitude 0.3
///
/// SNR = 20 dB, seed = 42.
fn synth_csi(cfg: &CirConfig) -> Vec<Complex64> {
let k_active = cfg.delay_bins / 3;
let delta_f = 312_500.0_f64; // Hz
let taps: &[(f64, f64, f64)] = &[
(10e-9, 1.0, PI / 4.0),
(80e-9, 0.6, PI),
(180e-9, 0.3, -PI / 3.0),
];
// Forward projection
let mut h: Vec<Complex64> = (0..k_active)
.map(|k| {
let val: Complex64 = taps
.iter()
.map(|(tau, amp, phase)| {
let angle = -2.0 * PI * k as f64 * delta_f * tau;
let re = amp * phase.cos() * angle.cos() - amp * phase.sin() * angle.sin();
let im = amp * phase.cos() * angle.sin() + amp * phase.sin() * angle.cos();
Complex64::new(re, im)
})
.sum();
val
})
.collect();
// Add AWGN at SNR=20 dB, seed=42
let signal_power: f64 = h.iter().map(|c| c.norm_sqr()).sum::<f64>() / k_active as f64;
let noise_power = signal_power / 10_f64.powf(20.0 / 10.0);
let noise_std = (noise_power / 2.0).sqrt();
let mut rng = Rng::new(42);
for sample in h.iter_mut() {
let n_i = noise_std * rng.next_normal();
let n_q = noise_std * rng.next_normal();
*sample += Complex64::new(n_i, n_q);
}
h
}
// ---------------------------------------------------------------------------
// CsiFrame construction
// ---------------------------------------------------------------------------
fn make_frame(bandwidth_mhz: u16, csi: Vec<Complex64>) -> CsiFrame {
let k = csi.len();
let mut data = Array2::zeros((1, k));
for (i, &v) in csi.iter().enumerate() {
data[(0, i)] = v;
}
let mut meta = CsiMetadata::new(DeviceId::new("bench"), FrequencyBand::Band2_4GHz, 6);
meta.bandwidth_mhz = bandwidth_mhz;
meta.antenna_config = AntennaConfig::new(1, 1);
CsiFrame::new(meta, data)
}
// ---------------------------------------------------------------------------
// Benchmark 1: single estimate() call per tier
// ---------------------------------------------------------------------------
fn bench_estimate(c: &mut Criterion) {
let mut group = c.benchmark_group("cir_estimate");
let tiers: &[(&str, u16)] = &[
("ht20", 20),
("ht40", 40),
("he20", 20), // HE20: same BW as HT20, different pilot mask — same for_bandwidth_mhz(20)
("he40", 40), // HE40: same BW as HT40
];
for &(label, bw_mhz) in tiers {
let cfg = CirConfig::for_bandwidth_mhz(bw_mhz);
let k_active = cfg.delay_bins / 3;
group.throughput(Throughput::Elements(k_active as u64));
let est = CirEstimator::new(cfg.clone());
let csi = synth_csi(&cfg);
let frame = make_frame(bw_mhz, csi);
group.bench_with_input(
BenchmarkId::from_parameter(label),
&frame,
|b, f| {
b.iter(|| {
black_box(est.estimate(black_box(f)).ok())
});
},
);
}
group.finish();
}
// ---------------------------------------------------------------------------
// Benchmark 1b: opt-in FFT operator (CirConfig::fft_operator = true)
// ---------------------------------------------------------------------------
/// Same workload as `cir_estimate`, with the O(G log G) FFT Φ/Φᴴ operator
/// enabled. Compare against `cir_estimate/<tier>` for the dense baseline.
fn bench_estimate_fft(c: &mut Criterion) {
let mut group = c.benchmark_group("cir_estimate_fft");
let tiers: &[(&str, u16)] = &[("ht20", 20), ("ht40", 40), ("he40", 40)];
for &(label, bw_mhz) in tiers {
let mut cfg = CirConfig::for_bandwidth_mhz(bw_mhz);
cfg.fft_operator = true;
let k_active = cfg.delay_bins / 3;
group.throughput(Throughput::Elements(k_active as u64));
let est = CirEstimator::new(cfg.clone());
let csi = synth_csi(&cfg);
let frame = make_frame(bw_mhz, csi);
group.bench_with_input(BenchmarkId::from_parameter(label), &frame, |b, f| {
b.iter(|| black_box(est.estimate(black_box(f)).ok()));
});
}
group.finish();
}
// ---------------------------------------------------------------------------
// Benchmark 2: 12-link amortisation (shared estimator across links)
// ---------------------------------------------------------------------------
/// Simulates the RuvSense multistatic aggregator pattern: one shared
/// CirEstimator instance processes 12 sequential links per call.
/// This measures the per-cycle cost of a full mesh with 12 active links.
fn bench_estimate_12link(c: &mut Criterion) {
let mut group = c.benchmark_group("cir_estimate_12link");
for &(label, bw_mhz) in &[("ht20", 20u16), ("ht40", 40u16)] {
let cfg = CirConfig::for_bandwidth_mhz(bw_mhz);
let k_active = cfg.delay_bins / 3;
// 12 distinct pre-built CSI frames (seeded differently to prevent
// the compiler from deduplicating them). Vary seed per link.
let frames: Vec<CsiFrame> = (1u32..=12)
.map(|seed| {
let k = k_active;
let delta_f = 312_500.0_f64;
let mut rng = Rng::new(seed * 7 + 1); // deterministic per-link seed
let signal_power = 1.0_f64;
let noise_power = signal_power / 10_f64.powf(20.0 / 10.0);
let noise_std = (noise_power / 2.0).sqrt();
let csi: Vec<Complex64> = (0..k)
.map(|k_idx| {
let angle = -2.0 * PI * k_idx as f64 * delta_f * 30e-9;
let mut c = Complex64::new(angle.cos(), angle.sin());
c += Complex64::new(noise_std * rng.next_normal(), noise_std * rng.next_normal());
c
})
.collect();
make_frame(bw_mhz, csi)
})
.collect();
let est = CirEstimator::new(cfg.clone());
group.throughput(Throughput::Elements(12 * k_active as u64));
group.bench_with_input(
BenchmarkId::from_parameter(label),
&frames,
|b, fs| {
b.iter(|| {
for f in fs {
black_box(est.estimate(black_box(f)).ok());
}
});
},
);
}
group.finish();
}
// ---------------------------------------------------------------------------
// Benchmark 3: estimator construction cost (sensing matrix build)
// ---------------------------------------------------------------------------
/// Measures the one-time cost of CirEstimator::new() for each tier.
/// This is amortised over many frames but useful to understand cold-start cost.
fn bench_estimator_construction(c: &mut Criterion) {
let mut group = c.benchmark_group("cir_estimator_new");
for &(label, bw_mhz) in &[("ht20", 20u16), ("ht40", 40u16)] {
group.bench_function(label, |b| {
b.iter(|| {
let cfg = CirConfig::for_bandwidth_mhz(bw_mhz);
black_box(CirEstimator::new(cfg))
});
});
}
group.finish();
}
// ---------------------------------------------------------------------------
// Criterion harness
// ---------------------------------------------------------------------------
criterion_group!(
benches,
bench_estimate,
bench_estimate_fft,
bench_estimate_12link,
bench_estimator_construction,
);
criterion_main!(benches);