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>
This commit is contained in:
rUv
2026-06-11 16:08:54 -04:00
committed by GitHub
parent d0e27e652e
commit 29de574e63
24 changed files with 4157 additions and 55 deletions
Generated
+5
View File
@@ -10910,6 +10910,7 @@ version = "0.3.0"
dependencies = [
"blake3",
"criterion",
"ruvector-mincut",
"wifi-densepose-bfld",
"wifi-densepose-core",
"wifi-densepose-geo",
@@ -11079,9 +11080,13 @@ dependencies = [
"tracing",
"tracing-subscriber",
"ureq 2.12.1",
"wifi-densepose-bfld",
"wifi-densepose-engine",
"wifi-densepose-geo",
"wifi-densepose-hardware",
"wifi-densepose-signal",
"wifi-densepose-wifiscan",
"wifi-densepose-worldgraph",
]
[[package]]
+343 -1
View File
@@ -563,6 +563,12 @@ impl crate::traits::CanonicalFrame for CsiFrame {
/// (each fixed-width LE; `device_id` length-prefixed; `calibration_id` as
/// 16 UUID bytes or 16 zero bytes for `None`) ‖ `(nrows, ncols)` as u32 LE
/// ‖ complex payload as `ComplexSample::to_le_bytes()` in stream-major order.
///
/// # Panics
/// If `calibration_id` is `Some(Uuid::nil())`: the nil UUID is the wire
/// sentinel for `None`, so encoding it would alias two distinct frames to
/// the same bytes (and the same witness hash) — a non-injective encoding
/// is refused rather than silently produced.
fn to_canonical_bytes(&self) -> Vec<u8> {
let m = &self.metadata;
// 16 (id) + ~48 (meta) + 8 (shape) + 16 * n_samples
@@ -600,7 +606,17 @@ impl crate::traits::CanonicalFrame for CsiFrame {
b.extend_from_slice(&m.noise_floor_dbm.to_le_bytes());
b.extend_from_slice(&m.sequence_number.to_le_bytes());
match m.calibration_id {
Some(id) => b.extend_from_slice(id.as_bytes()),
Some(id) => {
// Some(nil) would alias the None sentinel on the wire: the
// bytes would decode to a *different* frame (calibration_id
// None) with the same witness. Refuse the non-injective
// encoding (see the trait-impl `# Panics` doc).
assert!(
id != Uuid::nil(),
"calibration_id Some(Uuid::nil()) is unencodable: nil is the None sentinel"
);
b.extend_from_slice(id.as_bytes());
}
None => b.extend_from_slice(&[0u8; 16]),
}
b.extend_from_slice(&m.model_id.to_le_bytes());
@@ -616,6 +632,205 @@ impl crate::traits::CanonicalFrame for CsiFrame {
}
}
/// Errors decoding a frame from its canonical bytes.
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum CanonicalDecodeError {
/// The buffer ended before the layout was fully read.
#[error("canonical buffer truncated at byte {at} (need {need} more)")]
Truncated {
/// Byte offset where reading failed.
at: usize,
/// How many more bytes were needed.
need: usize,
},
/// A discriminant byte held an unknown value.
#[error("invalid {field} discriminant {value}")]
BadDiscriminant {
/// Which field failed.
field: &'static str,
/// The offending byte.
value: u8,
},
/// The device-id bytes were not UTF-8.
#[error("device id is not valid UTF-8")]
BadDeviceId,
/// Shape (nrows × ncols) disagrees with the remaining payload length.
#[error("payload length mismatch: shape {rows}x{cols} needs {expect} bytes, found {found}")]
PayloadMismatch {
/// Declared rows.
rows: usize,
/// Declared cols.
cols: usize,
/// Bytes the shape implies.
expect: usize,
/// Bytes actually present.
found: usize,
},
/// Trailing bytes after the declared payload.
#[error("{0} trailing bytes after payload")]
TrailingBytes(usize),
/// A reserved region that must be all-zero held nonzero bytes. Accepting
/// them would let two distinct byte strings decode to the same frame
/// (re-encoding could not reproduce the original — forged bytes would be
/// indistinguishable after a replay round-trip).
#[error("reserved bytes for {field} must be zero")]
ReservedNotZero {
/// Which field's reserved region was nonzero.
field: &'static str,
},
}
/// Byte cursor for the canonical layout.
struct Cursor<'a> {
b: &'a [u8],
at: usize,
}
impl<'a> Cursor<'a> {
fn take(&mut self, n: usize) -> Result<&'a [u8], CanonicalDecodeError> {
if self.b.len() - self.at < n {
return Err(CanonicalDecodeError::Truncated {
at: self.at,
need: n - (self.b.len() - self.at),
});
}
let s = &self.b[self.at..self.at + n];
self.at += n;
Ok(s)
}
fn u8(&mut self) -> Result<u8, CanonicalDecodeError> {
Ok(self.take(1)?[0])
}
fn u16(&mut self) -> Result<u16, CanonicalDecodeError> {
Ok(u16::from_le_bytes(self.take(2)?.try_into().unwrap()))
}
fn u32(&mut self) -> Result<u32, CanonicalDecodeError> {
Ok(u32::from_le_bytes(self.take(4)?.try_into().unwrap()))
}
fn i64(&mut self) -> Result<i64, CanonicalDecodeError> {
Ok(i64::from_le_bytes(self.take(8)?.try_into().unwrap()))
}
fn f32(&mut self) -> Result<f32, CanonicalDecodeError> {
Ok(f32::from_le_bytes(self.take(4)?.try_into().unwrap()))
}
fn i8(&mut self) -> Result<i8, CanonicalDecodeError> {
Ok(self.take(1)?[0] as i8)
}
fn uuid(&mut self) -> Result<Uuid, CanonicalDecodeError> {
Ok(Uuid::from_bytes(self.take(16)?.try_into().unwrap()))
}
}
impl CsiFrame {
/// Reconstruct a frame from its [`to_canonical_bytes`] encoding — the
/// replay half of the ADR-136 contract. Round-trip law (tested):
/// `from_canonical_bytes(f.to_canonical_bytes())` yields a frame with the
/// **same id, metadata, payload, and witness hash** as `f`.
///
/// Amplitude/phase are recomputed from the complex payload (they are
/// projections, not independent state).
///
/// [`to_canonical_bytes`]: crate::traits::CanonicalFrame::to_canonical_bytes
///
/// # Errors
/// [`CanonicalDecodeError`] on truncation, bad discriminants, non-UTF-8
/// device id, nonzero reserved bytes, shape/payload disagreement, or
/// trailing bytes — every malformed input fails closed. Strictness
/// guarantees injectivity on the accepted domain: any accepted byte
/// string re-encodes to exactly itself.
pub fn from_canonical_bytes(bytes: &[u8]) -> Result<Self, CanonicalDecodeError> {
let mut c = Cursor { b: bytes, at: 0 };
let id = FrameId::from_uuid(c.uuid()?);
let seconds = c.i64()?;
let nanos = c.u32()?;
let dev_len = c.u32()? as usize;
let device_id = core::str::from_utf8(c.take(dev_len)?)
.map_err(|_| CanonicalDecodeError::BadDeviceId)?
.to_string();
let frequency_band = match c.u8()? {
0 => FrequencyBand::Band2_4GHz,
1 => FrequencyBand::Band5GHz,
2 => FrequencyBand::Band6GHz,
v => {
return Err(CanonicalDecodeError::BadDiscriminant {
field: "frequency_band",
value: v,
})
}
};
let channel = c.u8()?;
let bandwidth_mhz = c.u16()?;
let tx_antennas = c.u8()?;
let rx_antennas = c.u8()?;
let spacing_mm = match c.u8()? {
1 => Some(c.f32()?),
0 => {
// Reserved padding must be zero (decoder strictness =
// injectivity on the accepted domain): otherwise forged
// nonzero padding would decode to the same frame as the
// canonical encoding and re-encode differently.
if c.take(4)? != [0u8; 4] {
return Err(CanonicalDecodeError::ReservedNotZero { field: "spacing_mm" });
}
None
}
v => {
return Err(CanonicalDecodeError::BadDiscriminant {
field: "spacing_mm",
value: v,
})
}
};
let rssi_dbm = c.i8()?;
let noise_floor_dbm = c.i8()?;
let sequence_number = c.u32()?;
let cal = c.uuid()?;
let calibration_id = if cal == Uuid::nil() { None } else { Some(cal) };
let model_id = c.u16()?;
let model_version = c.u16()?;
let rows = c.u32()? as usize;
let cols = c.u32()? as usize;
let expect = rows.saturating_mul(cols).saturating_mul(16);
let found = bytes.len() - c.at;
if found < expect {
return Err(CanonicalDecodeError::PayloadMismatch { rows, cols, expect, found });
}
let mut samples = Vec::with_capacity(rows * cols);
for _ in 0..rows * cols {
let raw: [u8; 16] = c.take(16)?.try_into().unwrap();
samples.push(ComplexSample::from_le_bytes(raw).0);
}
if c.at != bytes.len() {
return Err(CanonicalDecodeError::TrailingBytes(bytes.len() - c.at));
}
let data = Array2::from_shape_vec((rows, cols), samples).map_err(|_| {
CanonicalDecodeError::PayloadMismatch { rows, cols, expect, found }
})?;
let metadata = CsiMetadata {
timestamp: Timestamp { seconds, nanos },
device_id: DeviceId::new(device_id),
frequency_band,
channel,
bandwidth_mhz,
antenna_config: AntennaConfig { tx_antennas, rx_antennas, spacing_mm },
rssi_dbm,
noise_floor_dbm,
sequence_number,
calibration_id,
model_id,
model_version,
};
let amplitude = data.mapv(num_complex::Complex::norm);
let phase = data.mapv(num_complex::Complex::arg);
Ok(Self { id, metadata, data, amplitude, phase })
}
}
// =============================================================================
// Signal Types
// =============================================================================
@@ -1307,6 +1522,133 @@ mod tests {
assert_ne!(frame.witness_hash(), frame2.witness_hash());
}
/// AC7 — replay: `from_canonical_bytes` is the exact inverse of
/// `to_canonical_bytes` — same id, metadata, payload, and witness hash.
/// This is the capture-to-claim law: a stored canonical capture replays to
/// a frame the pipeline cannot distinguish from the original.
#[test]
fn ac7_canonical_round_trip_replays_identically() {
use ndarray::Array2;
let mut meta = CsiMetadata::new(DeviceId::new("node-α"), FrequencyBand::Band6GHz, 37);
meta.set_calibration(uuid::Uuid::new_v4());
meta.set_model(9, 0x0203);
meta.antenna_config.spacing_mm = Some(62.5);
meta.rssi_dbm = -41;
meta.sequence_number = 123_456;
let data = Array2::from_shape_fn((2, 56), |(r, c)| {
Complex64::new((r as f64 + 1.0) * (c as f64).cos(), (c as f64 * 0.1).tan())
});
let frame = CsiFrame::new(meta, data);
let bytes = frame.to_canonical_bytes();
let replayed = CsiFrame::from_canonical_bytes(&bytes).expect("decodes");
assert_eq!(replayed.id, frame.id);
// Field-wise metadata equality (CsiMetadata has no PartialEq; the
// byte-identical re-encoding below covers every field regardless).
assert_eq!(replayed.metadata.device_id, frame.metadata.device_id);
assert_eq!(replayed.metadata.calibration_id, frame.metadata.calibration_id);
assert_eq!(replayed.metadata.model_version, frame.metadata.model_version);
assert_eq!(replayed.metadata.antenna_config.spacing_mm, Some(62.5));
assert_eq!(replayed.data, frame.data);
// Witness equality — the strongest statement of equivalence.
assert_eq!(replayed.witness_hash(), frame.witness_hash());
// Re-encoding is byte-identical.
assert_eq!(replayed.to_canonical_bytes(), bytes);
// Projections recomputed consistently.
assert_eq!(replayed.amplitude, frame.amplitude);
}
/// AC8 — the decoder fails closed on every malformed-input class.
#[test]
fn ac8_canonical_decode_fails_closed() {
use ndarray::Array2;
let meta = CsiMetadata::new(DeviceId::new("n"), FrequencyBand::Band2_4GHz, 1);
let data = Array2::from_shape_fn((1, 4), |(_, c)| Complex64::new(c as f64, 0.0));
let frame = CsiFrame::new(meta, data);
let bytes = frame.to_canonical_bytes();
// Truncation anywhere fails: in the payload it is caught by the
// shape-vs-length check (PayloadMismatch); in the header by Truncated.
assert!(matches!(
CsiFrame::from_canonical_bytes(&bytes[..bytes.len() - 1]),
Err(CanonicalDecodeError::PayloadMismatch { .. })
));
assert!(matches!(
CsiFrame::from_canonical_bytes(&bytes[..10]),
Err(CanonicalDecodeError::Truncated { .. })
));
// Trailing junk fails.
let mut padded = bytes.clone();
padded.extend_from_slice(&[0u8; 3]);
assert!(matches!(
CsiFrame::from_canonical_bytes(&padded),
Err(CanonicalDecodeError::TrailingBytes(3))
));
// Bad frequency-band discriminant fails. Band byte sits right after
// id(16) + seconds(8) + nanos(4) + dev_len(4) + dev("n" = 1).
let mut bad = bytes.clone();
bad[16 + 8 + 4 + 4 + 1] = 9;
assert!(matches!(
CsiFrame::from_canonical_bytes(&bad),
Err(CanonicalDecodeError::BadDiscriminant { field: "frequency_band", value: 9 })
));
// A nil calibration uuid decodes as None (the documented encoding).
let replayed = CsiFrame::from_canonical_bytes(&bytes).unwrap();
assert_eq!(replayed.metadata.calibration_id, None);
}
/// AC8b (review finding 7) — decoder strictness = injectivity on the
/// accepted domain: forged nonzero bytes in the `spacing_mm` reserved
/// region are rejected, so for accepted inputs `re-encode != original`
/// is impossible.
#[test]
fn ac8b_forged_reserved_spacing_bytes_rejected() {
use ndarray::Array2;
let meta = CsiMetadata::new(DeviceId::new("n"), FrequencyBand::Band2_4GHz, 1);
let data = Array2::from_shape_fn((1, 4), |(_, c)| Complex64::new(c as f64, 0.0));
let frame = CsiFrame::new(meta, data);
let bytes = frame.to_canonical_bytes();
// Spacing tag sits after id(16)+secs(8)+nanos(4)+dev_len(4)+dev("n"=1)
// + band(1)+channel(1)+bw(2)+tx(1)+rx(1); the 4 reserved bytes follow.
let tag_off = 16 + 8 + 4 + 4 + 1 + 1 + 1 + 2 + 1 + 1;
assert_eq!(bytes[tag_off], 0, "fixture must encode spacing_mm = None");
assert_eq!(&bytes[tag_off + 1..tag_off + 5], &[0u8; 4]);
// Sanity: the canonical bytes decode and re-encode byte-identically.
let ok = CsiFrame::from_canonical_bytes(&bytes).unwrap();
assert_eq!(ok.to_canonical_bytes(), bytes);
// Forge each reserved byte: the decoder must fail closed (before the
// fix it decoded to the same frame, whose re-encoding differed from
// the forged original — a witness-replay ambiguity).
for i in 1..=4 {
let mut forged = bytes.clone();
forged[tag_off + i] = 0xAB;
assert!(matches!(
CsiFrame::from_canonical_bytes(&forged),
Err(CanonicalDecodeError::ReservedNotZero { field: "spacing_mm" })
));
}
}
/// AC8c (review finding 7) — `Some(Uuid::nil())` calibration is an
/// encoding error: nil is the wire sentinel for `None`, so encoding it
/// would alias two distinct frames to one byte string (and one witness).
#[test]
#[should_panic(expected = "nil is the None sentinel")]
fn ac8c_nil_calibration_id_is_an_encoding_error() {
use ndarray::Array2;
let mut meta = CsiMetadata::new(DeviceId::new("n"), FrequencyBand::Band2_4GHz, 1);
meta.calibration_id = Some(uuid::Uuid::nil());
let data = Array2::from_shape_fn((1, 2), |(_, c)| Complex64::new(c as f64, 0.0));
let _ = CsiFrame::new(meta, data).to_canonical_bytes();
}
/// AC3 — `serde(default)` forward-read of pre-ADR-136 metadata JSON.
#[cfg(feature = "serde")]
#[test]
@@ -19,6 +19,9 @@ wifi-densepose-worldgraph = { version = "0.3.0", path = "../wifi-densepose-world
wifi-densepose-geo = { version = "0.1.0", path = "../wifi-densepose-geo" }
# Deterministic witness over the trust decision (ADR-137 §2.7 / ADR-028).
blake3 = { version = "1.5", default-features = false }
# Dynamic min-cut over the live mesh coupling graph (mesh_guard.rs):
# incremental partition-risk monitoring + structural recalibration trigger.
ruvector-mincut = { workspace = true }
[dev-dependencies]
criterion = { version = "0.5", features = ["html_reports"] }
@@ -48,5 +48,41 @@ fn bench_cycle(c: &mut Criterion) {
});
}
criterion_group!(benches, bench_cycle);
/// Mesh guard in isolation: cold build (node set appears) vs steady state
/// (identical weights next cycle → change-gated, zero graph updates) for a
/// 12-node mesh — the full ADR-029 deployment size.
fn bench_mesh_guard(c: &mut Criterion) {
use wifi_densepose_engine::MeshGuard;
let nodes: Vec<u8> = (0..12).collect();
let w = |i: usize, j: usize| 0.4 + 0.01 * ((i + j) % 7) as f64;
c.bench_function("mesh_guard_cold_build_12n", |b| {
b.iter_batched(
MeshGuard::default,
|mut g| g.update(&nodes, w),
BatchSize::SmallInput,
);
});
c.bench_function("mesh_guard_steady_state_12n", |b| {
let mut g = MeshGuard::default();
g.update(&nodes, w); // warm
b.iter(|| g.update(&nodes, w));
});
c.bench_function("mesh_guard_one_edge_change_12n", |b| {
let mut g = MeshGuard::default();
g.update(&nodes, w);
let mut flip = false;
b.iter(|| {
flip = !flip;
let delta = if flip { 0.2 } else { 0.0 };
g.update(&nodes, |i, j| {
if (i.min(j), i.max(j)) == (0, 1) { 0.4 + delta } else { w(i, j) }
})
});
});
}
criterion_group!(benches, bench_cycle, bench_mesh_guard);
criterion_main!(benches);
+370 -6
View File
@@ -46,6 +46,9 @@ use wifi_densepose_worldgraph::{
WorldId, WorldNode, ZoneBoundsEnu,
};
pub mod mesh_guard;
pub use mesh_guard::{MeshGuard, MeshPartitionReport};
/// Errors from an engine cycle.
#[derive(Debug)]
pub enum EngineError {
@@ -97,6 +100,15 @@ pub struct TrustedOutput {
/// BLAKE3 witness over the trust decision (provenance ‖ class ‖ calibration)
/// — a deterministic, signed-belief fingerprint (ADR-137 §2.7 / ADR-028).
pub witness: [u8; 32],
/// Whether the drift→recalibration advisor recommends re-running the
/// ADR-135 baseline / refitting the per-room adapter (ADR-150 §3.4):
/// sustained low coherence or an ADR-142 change-point this cycle.
pub recalibration_recommended: bool,
/// Dynamic min-cut partition report over the live mesh coupling graph
/// (None for meshes of fewer than two nodes). `at_risk` counts as a
/// structural event for the recalibration advisor and names the nodes
/// (`weak_side`) closest to splitting off — failure/jamming triage.
pub mesh: Option<MeshPartitionReport>,
}
/// Composition root for the RuView streaming engine.
@@ -116,6 +128,74 @@ pub struct StreamingEngine {
slam: RfSlam,
// ADR-139 live loop: stable track_id -> PersonTrack WorldId.
person_tracks: BTreeMap<u64, WorldId>,
// WorldGraph belief retention: max live SemanticState nodes. The live loop
// appends one belief per cycle (1.7M/day at 20 Hz); durable history is the
// recorder's job, so old beliefs are evicted deterministically past this cap.
semantic_retention: usize,
// Per-room calibration adapter (ADR-150 §3.4: ~11 KB LoRA on a frozen
// base). Identity is part of the trust chain: when set, the adapter id is
// appended to the provenance model_version, so swapping adapters changes
// the witness. None = shared base model.
adapter: Option<AdapterInfo>,
// Drift→recalibration advisor (ADR-135 trigger for ADR-150 §3.4 refit).
recal: RecalibrationAdvisor,
// Dynamic min-cut mesh partition guard (incremental, change-gated).
mesh: MeshGuard,
}
/// Identity of an active per-room calibration adapter (ADR-150 §3.4). The id
/// must be content-derived (e.g. a hash prefix of the adapter file) so the
/// provenance/witness chain pins the exact weights that shaped inference.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AdapterInfo {
/// Content-derived adapter identity (e.g. first 16 hex of its SHA-256).
pub adapter_id: String,
/// Number of in-room samples the adapter was fitted on (0 if unknown).
pub trained_samples: u32,
}
/// Recommends re-running calibration / adapter refit when the live signal
/// degrades persistently (ADR-135 drift → ADR-150 §3.4 few-shot recalibration).
///
/// Two triggers, both cheap and deterministic:
/// - `low_coherence_streak`: N consecutive cycles whose base coherence fell
/// below the floor (sustained degradation, not a single bad frame);
/// - any ADR-142 change-point this cycle (the environment itself changed).
#[derive(Debug, Clone)]
pub struct RecalibrationAdvisor {
/// Coherence below this counts toward the streak.
pub coherence_floor: f32,
/// Consecutive low-coherence cycles required to recommend recalibration.
pub streak_threshold: u32,
streak: u32,
}
impl Default for RecalibrationAdvisor {
fn default() -> Self {
Self {
coherence_floor: 0.5,
streak_threshold: 60, // ~3 s at 20 Hz of sustained degradation
streak: 0,
}
}
}
impl RecalibrationAdvisor {
/// Feed one cycle's evidence; returns whether recalibration is recommended.
fn observe(&mut self, base_coherence: f32, change_point: bool) -> bool {
if base_coherence < self.coherence_floor {
self.streak = self.streak.saturating_add(1);
} else {
self.streak = 0;
}
change_point || self.streak >= self.streak_threshold
}
/// Current consecutive low-coherence cycle count.
#[must_use]
pub fn streak(&self) -> u32 {
self.streak
}
}
impl StreamingEngine {
@@ -135,9 +215,53 @@ impl StreamingEngine {
evolution: None,
slam: RfSlam::with_discovery(0.5, 5, 0.6),
person_tracks: BTreeMap::new(),
semantic_retention: Self::DEFAULT_SEMANTIC_RETENTION,
adapter: None,
recal: RecalibrationAdvisor::default(),
mesh: MeshGuard::default(),
}
}
/// Activate a per-room calibration adapter (ADR-150 §3.4). From the next
/// cycle on, the adapter id is part of provenance `model_version` — and
/// therefore of the witness — so the exact weights shaping inference are
/// pinned in the trust chain. Pass the result of hashing the adapter file.
pub fn set_room_adapter(&mut self, info: AdapterInfo) {
self.adapter = Some(info);
}
/// Deactivate the adapter (revert to the shared base model).
pub fn clear_room_adapter(&mut self) {
self.adapter = None;
}
/// The active adapter, if any.
#[must_use]
pub fn room_adapter(&self) -> Option<&AdapterInfo> {
self.adapter.as_ref()
}
/// Tune the drift→recalibration advisor (floor + streak threshold).
pub fn set_recalibration_advisor(&mut self, advisor: RecalibrationAdvisor) {
self.recal = advisor;
}
/// Mutable access to the mesh partition guard (risk threshold, quantum,
/// min-node count). Operators tune the partition-risk sensitivity here.
pub fn mesh_guard_mut(&mut self) -> &mut MeshGuard {
&mut self.mesh
}
/// Default cap on live `SemanticState` beliefs in the WorldGraph
/// (~6 minutes of full-rate history at 20 Hz; older beliefs are evicted —
/// durable history belongs to the recorder).
pub const DEFAULT_SEMANTIC_RETENTION: usize = 7_200;
/// Override the `SemanticState` retention cap (minimum 1).
pub fn set_semantic_retention(&mut self, max_states: usize) {
self.semantic_retention = max_states.max(1);
}
/// ADR-139 live loop: create or update a `PersonTrack` node by stable
/// `track_id`, locate it in `room`, and wire an `Observes` edge from
/// `sensor` (so the privacy rollup can suppress it under identity-strict
@@ -321,21 +445,47 @@ impl StreamingEngine {
// 4. Evolution change-point (ADR-142) over per-node mean amplitude.
let change_point = self.track_evolution(node_frames, now_ms, room);
// 5. Privacy control plane (ADR-141): demote on a fusion-level OR an
// array-level contradiction (monotonic — information only removed).
// 5. Mesh partition guard (ADR-032): dynamic min-cut over the coupling
// graph. Coupling between nodes i and j is the product of their
// fusion attention weights scaled by the node count, so a node the
// fuser down-weights is exactly a node weakly coupled in the graph.
// (Change-gated incremental updates: steady state touches 0 edges.)
let node_ids: Vec<u8> = node_frames.iter().map(|f| f.node_id).collect();
let weights = &quality.per_node_weights;
let n = weights.len() as f64;
let mesh = self.mesh.update(&node_ids, |i, j| {
let wi = weights.get(i).copied().unwrap_or(0.0) as f64;
let wj = weights.get(j).copied().unwrap_or(0.0) as f64;
wi * wj * n
});
let mesh_at_risk = mesh.as_ref().is_some_and(|m| m.at_risk);
// 6. Privacy control plane (ADR-141): demote on a fusion-level OR an
// array-level contradiction OR a mesh close to partitioning. The
// last is a security/reliability signal (ADR-032): a fragmenting
// array makes the fused belief less trustworthy, so we emit at a
// more restricted class. Monotonic — information is only ever
// removed — and the demotion is part of the witness.
let base_class = self.privacy.active_class();
let demoted = quality.forces_privacy_demotion() || array_contradiction;
let demoted = quality.forces_privacy_demotion() || array_contradiction || mesh_at_risk;
let effective_class = if demoted { demote_one(base_class) } else { base_class };
// 6. Semantic state with mandatory provenance (ADR-139/140). The
// 7. Semantic state with mandatory provenance (ADR-139/140). The
// calibration version comes from the *agreed* epoch (None on mismatch).
// When a per-room adapter is active (ADR-150 §3.4) its content-derived
// id is part of model_version — and therefore of the witness — so the
// exact weights shaping inference are pinned in the trust chain.
let calibration_version = match quality.calibration_id {
Some(c) => format!("cal:{:016x}", c.0),
None => "cal:none".to_string(),
};
let model_version = match &self.adapter {
Some(a) => format!("rfenc-v{}+adapter:{}", self.model_version, a.adapter_id),
None => format!("rfenc-v{}", self.model_version),
};
let provenance = SemanticProvenance {
evidence: quality.evidence_refs.iter().map(|e| format!("{e:?}")).collect(),
model_version: format!("rfenc-v{}", self.model_version),
model_version,
calibration_version,
privacy_decision: format!("{:?}/{:?}", self.privacy.active_mode(), effective_class),
};
@@ -350,10 +500,23 @@ impl StreamingEngine {
provenance.clone(),
&[room],
);
// Retention: bound the live belief set (one node is appended per cycle;
// without this the graph grows ~1.7M nodes/day at 20 Hz). Deterministic
// eviction; the just-added belief is always newest and survives.
self.world.prune_semantic_states(self.semantic_retention);
// 7. Deterministic witness over the trust decision (ADR-137 §2.7).
// 8. Deterministic witness over the trust decision (ADR-137 §2.7).
// `effective_class` already reflects any mesh-risk demotion, so a
// fragmenting array shifts the witness — partition risk is auditable.
let witness = witness_of(&provenance, effective_class);
// 9. Drift→recalibration advisor (ADR-135 → ADR-150 §3.4): sustained
// low coherence, an environment change-point, or a mesh close to
// partitioning recommends refit.
let recalibration_recommended = self
.recal
.observe(quality.base_coherence, change_point.is_some() || mesh_at_risk);
self.cycle += 1;
Ok(TrustedOutput {
semantic_id,
@@ -364,6 +527,8 @@ impl StreamingEngine {
directional,
change_point,
witness,
recalibration_recommended,
mesh,
})
}
@@ -547,6 +712,205 @@ mod tests {
assert_eq!(o1.quality.per_node_weights, o2.quality.per_node_weights);
}
/// ADR-150 §3.4 adapter provenance: activating a per-room adapter changes
/// the provenance model_version AND the witness — the exact weights shaping
/// inference are pinned in the trust chain, so an adapter can never swap
/// silently. Clearing it restores the base identity (and base witness).
#[test]
fn adapter_identity_is_witnessed() {
let cal = CalibrationId(9);
let frames = [node_frame(0, 1000, 56), node_frame(1, 1001, 56)];
let (mut e, room) = engine();
let base = e.process_cycle(&frames, cal, room, 1_000).unwrap();
assert_eq!(base.provenance.model_version, "rfenc-v1");
e.set_room_adapter(AdapterInfo {
adapter_id: "a1b2c3d4e5f60718".into(),
trained_samples: 150,
});
let adapted = e.process_cycle(&frames, cal, room, 2_000).unwrap();
assert_eq!(
adapted.provenance.model_version,
"rfenc-v1+adapter:a1b2c3d4e5f60718"
);
assert_ne!(adapted.witness, base.witness, "adapter must shift the witness");
// A different adapter id yields a different witness again.
e.set_room_adapter(AdapterInfo {
adapter_id: "ffffffffffffffff".into(),
trained_samples: 150,
});
let other = e.process_cycle(&frames, cal, room, 3_000).unwrap();
assert_ne!(other.witness, adapted.witness);
// Clearing restores the base identity and the base witness.
e.clear_room_adapter();
let back = e.process_cycle(&frames, cal, room, 4_000).unwrap();
assert_eq!(back.provenance.model_version, "rfenc-v1");
assert_eq!(back.witness, base.witness);
}
/// Drift→recalibration advisor logic: a sustained low-coherence streak
/// recommends refit; a single healthy cycle resets the streak; a
/// change-point recommends immediately regardless of streak.
#[test]
fn recalibration_advisor_streak_and_change_point() {
let mut adv = RecalibrationAdvisor {
coherence_floor: 0.5,
streak_threshold: 3,
..Default::default()
};
// Healthy cycles never recommend and keep the streak at zero.
for _ in 0..5 {
assert!(!adv.observe(0.9, false));
}
assert_eq!(adv.streak(), 0);
// Two low cycles: not yet.
assert!(!adv.observe(0.2, false));
assert!(!adv.observe(0.2, false));
// Third consecutive low cycle: fire.
assert!(adv.observe(0.2, false));
// Recovery resets the streak.
assert!(!adv.observe(0.9, false));
assert_eq!(adv.streak(), 0);
// A change-point recommends immediately, even at full coherence.
assert!(adv.observe(0.9, true));
}
/// Engine-level: clean coherent cycles never recommend recalibration (the
/// advisor is wired into process_cycle and stays quiet on healthy input).
#[test]
fn healthy_cycles_do_not_recommend_recalibration() {
let (mut e, room) = engine();
e.set_recalibration_advisor(RecalibrationAdvisor {
coherence_floor: 0.5,
streak_threshold: 3,
..Default::default()
});
let cal = CalibrationId(2);
for i in 0..5u64 {
let frames = [
node_frame(0, 1_000 + i * 50_000, 56),
node_frame(1, 1_001 + i * 50_000, 56),
];
let out = e.process_cycle(&frames, cal, room, i as i64).unwrap();
assert!(!out.recalibration_recommended);
}
}
/// Maximum total coupling mass of an n-node mesh whose attention weights
/// sum to 1 (coupling = wᵢ·wⱼ·n): Σ_{i<j} wᵢwⱼ·n = n(1−Σwᵢ²)/2 ≤ (n1)/2.
/// Any cut is a subset of the edges, so every achievable cut value is
/// bounded by this mass — a risk threshold at or above it is *guaranteed*
/// to be crossed (deterministic fixture, review finding 4).
fn max_coupling_mass(n_nodes: usize) -> f64 {
(n_nodes as f64 - 1.0) / 2.0
}
/// Mesh guard wiring: a balanced 2-node cycle reports a mesh (cut exists)
/// but never flags risk (min_nodes=3); a 3-node mesh whose cut value
/// *deterministically* falls at or below the configured risk threshold
/// (threshold = the provable upper bound on any achievable cut) is flagged
/// at_risk, and the structural event feeds the recalibration advisor
/// immediately — no conditional assertions (review finding 4).
#[test]
fn mesh_partition_risk_feeds_recalibration() {
let (mut e, room) = engine();
let cal = CalibrationId(3);
// Balanced 2-node mesh: report present, no risk.
let out = e
.process_cycle(&[node_frame(0, 1000, 56), node_frame(1, 1001, 56)], cal, room, 1)
.unwrap();
let mesh = out.mesh.expect("2-node mesh reports");
assert!(!mesh.at_risk);
assert!(!out.recalibration_recommended);
// 3-node mesh with the operator risk threshold set to the provable
// cut upper bound: the crossing is deterministic regardless of the
// fuser's exact weighting.
e.mesh_guard_mut().risk_threshold = max_coupling_mass(3);
let frames = [
node_frame(0, 10_000_000, 56),
node_frame(1, 10_000_001, 56),
node_frame(2, 10_000_002, 56),
];
let out3 = e.process_cycle(&frames, cal, room, 2).unwrap();
let m3 = out3.mesh.expect("3-node mesh reports");
assert!(m3.at_risk, "cut ≤ threshold must flag partition risk");
assert!(
out3.recalibration_recommended,
"mesh risk is a structural event — the advisor must fire immediately, no streak"
);
assert!(m3.cut_value.is_finite() && m3.cut_value >= 0.0);
}
/// Mesh partition risk demotes the privacy class and shifts the witness —
/// a fragmenting array makes the fused belief less trustworthy, so it is
/// emitted at a more restricted class, and that demotion is auditable.
/// Both cycles use the *same 3-node topology and frames*; the engines
/// differ only in the forced mesh risk, so the witness delta is
/// attributable to the risk demotion alone (review finding 4).
#[test]
fn mesh_risk_demotes_privacy_and_shifts_witness() {
let cal = CalibrationId(8);
let frames3 = [
node_frame(0, 1000, 56),
node_frame(1, 1001, 56),
node_frame(2, 1002, 56),
];
// Baseline: same topology, default risk threshold — clean cycle, not
// demoted (PrivateHome → Anonymous), mesh healthy.
let (mut e1, r1) = engine();
let base = e1.process_cycle(&frames3, cal, r1, 5_000).unwrap();
assert!(!base.mesh.as_ref().unwrap().at_risk);
assert!(!base.demoted);
assert_eq!(base.effective_class, PrivacyClass::Anonymous);
// Forced risk: identical frames/topology, threshold at the provable
// cut upper bound so the crossing is deterministic.
let (mut e2, r2) = engine();
e2.mesh_guard_mut().risk_threshold = max_coupling_mass(3);
let risky = e2.process_cycle(&frames3, cal, r2, 5_000).unwrap();
assert!(risky.mesh.as_ref().unwrap().at_risk);
assert!(risky.demoted, "mesh risk must demote");
// PrivateHome base Anonymous(2) → demoted to Restricted(3).
assert_eq!(risky.effective_class, PrivacyClass::Restricted);
assert!(risky.provenance.privacy_decision.contains("Restricted"));
assert_ne!(
risky.witness, base.witness,
"same topology, risk-only delta must shift the witness"
);
}
/// WorldGraph belief retention: the live loop appends one SemanticState per
/// cycle; past the cap the oldest beliefs are evicted so graph memory is
/// bounded, while structural nodes and the newest belief always survive.
#[test]
fn semantic_state_growth_is_bounded() {
let (mut e, room) = engine();
e.set_semantic_retention(5);
let cal = CalibrationId(1);
let mut last_id = None;
let baseline_nodes = 2; // room + sensor
for i in 0..20u64 {
let frames = [
node_frame(0, 1000 + i * 50_000, 56),
node_frame(1, 1001 + i * 50_000, 56),
];
let out = e.process_cycle(&frames, cal, room, 5_000 + i as i64).unwrap();
last_id = Some(out.semantic_id);
assert!(e.world().node_count() <= baseline_nodes + 5);
}
// 20 cycles ran, only 5 beliefs remain, newest is still present.
assert_eq!(e.world().node_count(), baseline_nodes + 5);
assert!(e.world().node(last_id.unwrap()).is_some());
// Structural nodes survive eviction.
assert!(e.world().node(room).is_some());
}
fn node_frame_scaled(node_id: u8, ts_us: u64, n_sub: usize, scale: f32) -> MultiBandCsiFrame {
MultiBandCsiFrame {
node_id,
@@ -0,0 +1,364 @@
//! Mesh partition guard: dynamic min-cut over the live multistatic node graph.
//!
//! The fusion mesh (nodes = sensing nodes, edge weights = fusion coupling
//! derived from per-node attention weights) changes *incrementally* at cycle
//! rate — one node's coupling drifts, a node joins or drops. This module
//! maintains a [`ruvector_mincut::DynamicMinCut`] over that graph and exposes,
//! per cycle:
//!
//! - the **min-cut value** — the cheapest set of couplings whose loss splits
//! the mesh in two: a principled, global "how close is the array to
//! partitioning" number (vs per-node heuristics that miss multi-node
//! structure);
//! - the **weak side** — which specific nodes are about to partition (feeds
//! failure/jamming triage, ADR-032 posture);
//! - an **at-risk flag** consumed by the engine: it counts as a structural
//! event for the drift→recalibration advisor.
//!
//! ## Cost model (the optimization)
//!
//! Weights are quantized (default 1/64; a *nonzero* coupling below one quantum
//! saturates to quantum 1 so a live coupling is never erased — see
//! [`MeshGuard::weight_quantum`]) and updates are **change-gated**: an
//! edge is touched only when its quantized weight actually moves, so the
//! steady-state cycle applies *zero* graph updates and reuses the cached cut —
//! O(active-changes) per cycle, not O(n²) rebuilds. The exact (deterministic)
//! algorithm is used; mesh sizes are ≤ tens of nodes, far inside its budget.
use std::collections::BTreeMap;
use ruvector_mincut::{DynamicMinCut, MinCutBuilder};
/// Per-cycle report from the mesh guard.
#[derive(Debug, Clone, PartialEq)]
pub struct MeshPartitionReport {
/// Current min-cut value over the coupling graph (higher = more robust).
pub cut_value: f64,
/// True when the mesh has ≥ `min_nodes` nodes and the cut value fell to or
/// below the risk threshold — the array is close to splitting.
pub at_risk: bool,
/// The smaller side of the min-cut partition (node ids): the nodes that
/// would be isolated if the weak couplings failed.
pub weak_side: Vec<u8>,
/// Incremental edge updates applied this cycle (0 in steady state).
pub updates_applied: usize,
}
/// Dynamic min-cut guard over the live mesh.
pub struct MeshGuard {
mincut: Option<DynamicMinCut>,
/// Node set the structure was built over (sorted). A change forces rebuild.
nodes: Vec<u8>,
/// Quantized edge weights currently installed, keyed `(u, v)` with `u < v`.
edges: BTreeMap<(u8, u8), i64>,
/// Weight quantum: weights are snapped to multiples of this before
/// comparison/installation, gating out sub-quantum jitter.
///
/// Policy: a **nonzero** coupling below one quantum saturates to quantum 1
/// instead of quantizing to 0 — quantization never erases a live coupling.
/// (Without the floor, a balanced mesh of ≥ 65 nodes — attention weights
/// ~1/n ⇒ couplings ~1/n < 1/64 — had every edge erased and was reported
/// permanently "already partitioned"/at-risk.) Exact zero stays zero: a
/// truly absent coupling *is* a partition. Relative weakness below one
/// quantum is not resolved; lower this quantum if that resolution matters.
pub weight_quantum: f64,
/// Cut value at or below which the mesh counts as at partition risk.
pub risk_threshold: f64,
/// Minimum node count for risk to be meaningful (a 2-node mesh always has
/// a trivial cut; default 3).
pub min_nodes: usize,
}
impl Default for MeshGuard {
fn default() -> Self {
Self {
mincut: None,
nodes: Vec::new(),
edges: BTreeMap::new(),
weight_quantum: 1.0 / 64.0,
risk_threshold: 0.25,
min_nodes: 3,
}
}
}
impl MeshGuard {
/// Quantize a raw weight to the guard's grid (floor; weights are ≥ 0).
/// Nonzero sub-quantum weights saturate to quantum 1 — see the
/// [`Self::weight_quantum`] policy (review finding: sub-quantum couplings
/// must not produce a false "already partitioned").
fn quantize(&self, w: f64) -> i64 {
let w = w.max(0.0);
let q = (w / self.weight_quantum).floor() as i64;
if q == 0 && w > 0.0 {
1
} else {
q
}
}
/// Update the guard with this cycle's mesh: `nodes` are the contributing
/// node ids and `coupling(i, j)` returns the fusion coupling between
/// `nodes[i]` and `nodes[j]` (symmetric, ≥ 0).
///
/// Returns `None` for meshes of fewer than 2 nodes (no cut exists).
pub fn update(
&mut self,
nodes: &[u8],
coupling: impl Fn(usize, usize) -> f64,
) -> Option<MeshPartitionReport> {
if nodes.len() < 2 {
// Mesh degenerated: drop state so a later rebuild starts clean.
self.mincut = None;
self.nodes.clear();
self.edges.clear();
return None;
}
let mut sorted: Vec<u8> = nodes.to_vec();
sorted.sort_unstable();
sorted.dedup();
// Desired quantized edge set for this cycle.
let mut desired: BTreeMap<(u8, u8), i64> = BTreeMap::new();
for i in 0..nodes.len() {
for j in (i + 1)..nodes.len() {
let (a, b) = if nodes[i] < nodes[j] {
(nodes[i], nodes[j])
} else {
(nodes[j], nodes[i])
};
if a == b {
continue;
}
let q = self.quantize(coupling(i, j));
desired.insert((a, b), q);
}
}
// Change detection: count quantized-weight moves vs the installed set.
let changed = if self.mincut.is_none() || self.nodes != sorted {
usize::MAX // node set changed / first cycle: rebuild unconditionally
} else {
desired
.iter()
.filter(|(k, &q)| self.edges.get(k).copied().unwrap_or(0) != q)
.count()
};
let mut updates = 0usize;
if changed > 0 {
// Measured policy (criterion, 12-node mesh): a full exact rebuild
// is ~170 µs while ONE DynamicMinCut delete+insert is ~240 µs —
// the incremental machinery's overheads target much larger graphs.
// At mesh scale the optimum is: change-gate aggressively (the
// steady state below is ~7 µs and covers almost every cycle) and
// rebuild whenever anything actually moved.
let edges: Vec<(u64, u64, f64)> = desired
.iter()
.filter(|(_, &q)| q > 0)
.map(|(&(a, b), &q)| {
(u64::from(a), u64::from(b), q as f64 * self.weight_quantum)
})
.collect();
updates = if changed == usize::MAX { edges.len() } else { changed };
self.mincut = MinCutBuilder::new().exact().with_edges(edges).build().ok();
self.nodes = sorted;
self.edges = desired;
}
// changed == 0: steady state — zero graph work, cached cut reused.
// Nodes with no positive coupling never enter the cut structure (zero
// edges are not installed) — they are already partitioned. Report them
// as the degenerate cut before consulting the structure.
let mut isolated: Vec<u8> = self
.nodes
.iter()
.copied()
.filter(|&v| {
!self
.edges
.iter()
.any(|(&(a, b), &q)| q > 0 && (a == v || b == v))
})
.collect();
if !isolated.is_empty() {
isolated.sort_unstable();
return Some(MeshPartitionReport {
cut_value: 0.0,
at_risk: self.nodes.len() >= self.min_nodes,
weak_side: isolated,
updates_applied: updates,
});
}
let mc = self.mincut.as_ref()?;
// A disconnected coupling graph is the degenerate cut: value 0.
let cut_value = if mc.is_connected() { mc.min_cut_value() } else { 0.0 };
let (side_a, side_b) = mc.partition();
let weak_raw = if side_a.len() <= side_b.len() { side_a } else { side_b };
let mut weak_side: Vec<u8> = weak_raw.into_iter().map(|v| v as u8).collect();
weak_side.sort_unstable();
let at_risk = self.nodes.len() >= self.min_nodes && cut_value <= self.risk_threshold;
Some(MeshPartitionReport { cut_value, at_risk, weak_side, updates_applied: updates })
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Triangle with one weakly-attached node: the cut isolates that node and
/// the cut value equals its total coupling.
#[test]
fn weakly_attached_node_is_the_weak_side() {
let mut g = MeshGuard::default();
let nodes = [0u8, 1, 2];
// 01 strongly coupled; node 2 hangs on by 0.05 + 0.05.
let w = |i: usize, j: usize| match (i.min(j), i.max(j)) {
(0, 1) => 1.0,
_ => 0.05,
};
let r = g.update(&nodes, w).expect("3-node mesh");
assert!(r.cut_value <= 0.13, "cut {} should be ~0.10", r.cut_value);
assert_eq!(r.weak_side, vec![2]);
assert!(r.at_risk, "weak coupling must flag partition risk");
}
#[test]
fn strong_mesh_is_not_at_risk() {
let mut g = MeshGuard::default();
let r = g.update(&[0, 1, 2, 3], |_, _| 0.9).expect("mesh");
assert!(r.cut_value > g.risk_threshold);
assert!(!r.at_risk);
}
#[test]
fn two_node_mesh_reports_but_never_risks() {
let mut g = MeshGuard::default();
let r = g.update(&[0, 1], |_, _| 0.01).expect("2-node mesh");
// Trivial cut exists but min_nodes=3 keeps the flag off.
assert!(!r.at_risk);
}
#[test]
fn fewer_than_two_nodes_yields_none() {
let mut g = MeshGuard::default();
assert!(g.update(&[7], |_, _| 1.0).is_none());
assert!(g.update(&[], |_, _| 1.0).is_none());
}
/// The optimization contract: identical weights on the next cycle apply
/// zero updates; a sub-quantum wiggle also applies zero; a real change
/// applies exactly the changed edges.
#[test]
fn steady_state_applies_zero_updates() {
let mut g = MeshGuard::default();
let nodes = [0u8, 1, 2, 3];
let first = g.update(&nodes, |_, _| 0.5).unwrap();
assert_eq!(first.updates_applied, 6); // cold build installs all edges
let second = g.update(&nodes, |_, _| 0.5).unwrap();
assert_eq!(second.updates_applied, 0);
// Sub-quantum jitter (quantum is 1/64 ≈ 0.0156) is gated out.
let third = g.update(&nodes, |_, _| 0.5 + 0.004).unwrap();
assert_eq!(third.updates_applied, 0);
// One genuinely changed edge touches exactly one edge.
let fourth = g
.update(&nodes, |i, j| if (i.min(j), i.max(j)) == (0, 1) { 0.1 } else { 0.5 })
.unwrap();
assert_eq!(fourth.updates_applied, 1);
}
/// Node set changes force a clean rebuild (drop/join handled correctly).
#[test]
fn node_join_and_drop_rebuild() {
let mut g = MeshGuard::default();
g.update(&[0, 1, 2], |_, _| 0.8).unwrap();
// Node 3 joins.
let joined = g.update(&[0, 1, 2, 3], |_, _| 0.8).unwrap();
assert_eq!(joined.updates_applied, 6); // rebuild over 4 nodes
// Node 0 drops.
let dropped = g.update(&[1, 2, 3], |_, _| 0.8).unwrap();
assert_eq!(dropped.updates_applied, 3);
assert!(!dropped.at_risk);
}
/// Determinism: same inputs, same report (cut value + weak side).
#[test]
fn reports_are_deterministic() {
let run = || {
let mut g = MeshGuard::default();
let w = |i: usize, j: usize| match (i.min(j), i.max(j)) {
(0, 1) => 0.9,
(1, 2) => 0.6,
_ => 0.07,
};
g.update(&[0, 1, 2], w).unwrap()
};
let a = run();
let b = run();
assert_eq!(a.cut_value.to_bits(), b.cut_value.to_bits());
assert_eq!(a.weak_side, b.weak_side);
}
/// Regression (review finding 3): a balanced mesh of ≥ 65 nodes has every
/// pairwise coupling at ~1/n < quantum (1/64). The old floor-to-zero
/// quantization erased all edges and reported the mesh permanently
/// "already partitioned" (cut 0, at_risk). Nonzero sub-quantum couplings
/// now saturate to one quantum, so the mesh reports a healthy cut.
#[test]
fn large_balanced_mesh_is_not_at_risk() {
let mut g = MeshGuard::default();
let nodes: Vec<u8> = (0..70u8).collect();
// Attention-weight product coupling: (1/n)·(1/n)·n = 1/n ≈ 0.0143 < 1/64.
let n = nodes.len() as f64;
let r = g.update(&nodes, |_, _| 1.0 / n).expect("70-node mesh");
assert!(
r.cut_value > 0.0,
"live couplings must not quantize to zero"
);
// Min cut isolates one node: 69 edges × one quantum (1/64) ≈ 1.08,
// well above the 0.25 default risk threshold.
assert!(r.cut_value > g.risk_threshold);
assert!(
!r.at_risk,
"balanced large mesh must not be at partition risk"
);
assert!(r.weak_side.len() < nodes.len(), "no false full partition");
}
/// Sub-quantum couplings saturate to one quantum but exact zero is still a
/// real partition (the floor must not invent couplings).
#[test]
fn sub_quantum_saturates_but_zero_stays_zero() {
let mut g = MeshGuard::default();
// 0.001 < 1/64 everywhere: connected, tiny cut, flagged at risk
// (cut = 2 × 1/64 ≈ 0.031 ≤ 0.25) — but NOT "already partitioned".
let r = g.update(&[0, 1, 2], |_, _| 0.001).expect("mesh");
assert!(r.cut_value > 0.0);
assert!(r.at_risk);
// Exact zero to node 2: degenerate cut 0, node 2 isolated.
let mut g2 = MeshGuard::default();
let r2 = g2
.update(&[0, 1, 2], |i, j| if i == 2 || j == 2 { 0.0 } else { 0.5 })
.expect("mesh");
assert_eq!(r2.cut_value, 0.0);
assert_eq!(r2.weak_side, vec![2]);
}
/// A fully partitioned mesh (zero coupling to one node) reports cut 0.
#[test]
fn disconnected_mesh_is_cut_zero() {
let mut g = MeshGuard::default();
let w = |i: usize, j: usize| {
if i == 2 || j == 2 { 0.0 } else { 0.9 }
};
let r = g.update(&[0, 1, 2], w).unwrap();
assert_eq!(r.cut_value, 0.0);
assert!(r.at_risk);
assert_eq!(r.weak_side, vec![2]);
}
}
+14 -7
View File
@@ -15,12 +15,17 @@ readme = "README.md"
default = ["std", "api", "ruvector"]
ruvector = ["dep:ruvector-solver", "dep:ruvector-temporal-tensor"]
std = []
api = ["chrono/serde", "geo/use-serde"]
# REST/WebSocket surface. Pulls the web stack (axum, futures-util) only when
# enabled, and enables the `serde` FEATURE (not just `dep:serde`) so the
# `cfg_attr(feature = "serde", ...)` derives on domain types are actually
# active when the API is on (review finding 5: `api = ["dep:serde"]` enabled
# the dependency but left every `feature = "serde"` cfg dead).
api = ["serde", "dep:axum", "dep:futures-util"]
portable = ["low-power"]
low-power = []
distributed = ["tokio/sync"]
drone = ["distributed"]
serde = ["chrono/serde", "geo/use-serde"]
serde = ["dep:serde", "chrono/serde", "geo/use-serde"]
[dependencies]
# Workspace dependencies
@@ -30,20 +35,22 @@ wifi-densepose-nn = { version = "0.3.0", path = "../wifi-densepose-nn" }
ruvector-solver = { workspace = true, optional = true }
ruvector-temporal-tensor = { workspace = true, optional = true }
# Async runtime
# Async runtime — required by the core integration layer (UDP CSI receiver,
# hardware adapter, scan loop in `DisasterResponse::start_scanning`), not just
# the REST API, so it is deliberately NOT gated behind `api`.
tokio = { version = "1.35", features = ["rt", "sync", "time"] }
async-trait = "0.1"
# Web framework (REST API)
axum = { version = "0.7", features = ["ws"] }
futures-util = "0.3"
# Web framework (REST API) — only compiled with the `api` feature.
axum = { version = "0.7", features = ["ws"], optional = true }
futures-util = { version = "0.3", optional = true }
# Error handling
thiserror = "2.0"
anyhow = "1.0"
# Serialization
serde = { version = "1.0", features = ["derive"] }
serde = { version = "1.0", features = ["derive"], optional = true }
serde_json = "1.0"
# Time handling
+6
View File
@@ -78,6 +78,10 @@
#![warn(rustdoc::missing_crate_level_docs)]
pub mod alerting;
/// REST API surface (Axum). Requires the `api` feature — its DTOs derive
/// serde, which is an optional dependency gated behind that feature.
#[cfg(feature = "api")]
#[cfg_attr(docsrs, doc(cfg(feature = "api")))]
pub mod api;
pub mod detection;
pub mod domain;
@@ -122,6 +126,8 @@ pub use integration::{
AdapterError, HardwareAdapter, IntegrationConfig, NeuralAdapter, SignalAdapter,
};
#[cfg(feature = "api")]
#[cfg_attr(docsrs, doc(cfg(feature = "api")))]
pub use api::{create_router, AppState};
pub use ml::{
@@ -53,6 +53,16 @@ wifi-densepose-signal = { version = "0.3.1", path = "../wifi-densepose-signal",
# Hardware crate — SyncPacket decoder for ADR-110 §A0.12 mesh-aligned timestamps.
wifi-densepose-hardware = { version = "0.3.0", path = "../wifi-densepose-hardware" }
# Governed streaming engine (ADR-135..146): fusion + privacy demotion +
# WorldGraph belief + deterministic witness. The live server data runs through
# this as a governed path whose Restricted-class decision strips per-node raw
# amplitudes from the live publish; full output gating is a tracked follow-up —
# see engine_bridge.rs ("Honest scope of the live-path governance").
wifi-densepose-engine = { version = "0.3.0", path = "../wifi-densepose-engine" }
wifi-densepose-worldgraph = { version = "0.3.0", path = "../wifi-densepose-worldgraph" }
wifi-densepose-bfld = { version = "0.3.1", path = "../wifi-densepose-bfld", default-features = false }
wifi-densepose-geo = { version = "0.1.0", path = "../wifi-densepose-geo" }
# midstream — real-time introspection / low-latency tap (ADR-099 D1).
# Two crates only, on purpose: scheduler / neural-solver / strange-loop are
# explicitly out of scope of ADR-099 (D5).
@@ -0,0 +1,469 @@
//! Live trust-path bridge: drive the governed [`StreamingEngine`] from the
//! sensing-server's live `NodeState` map.
//!
//! `multistatic_bridge.rs` already converts `NodeState` → `MultiBandCsiFrame`
//! and runs the *bare* `MultistaticFuser`. That path produces fused amplitudes
//! but skips the trust control plane: privacy demotion on contradiction, the
//! WorldGraph belief with mandatory provenance, and the deterministic witness
//! (ADR-135..146). This bridge routes the same live frames through
//! [`StreamingEngine::process_cycle`], so every governed belief carries
//! evidence + model + calibration + privacy decision and a BLAKE3 witness
//! (narrowing the gap called out in ADR-136 §8 and the beyond-SOTA system
//! review).
//!
//! ## Honest scope of the live-path governance
//!
//! The engine runs *alongside* the bare fusion path that feeds the live
//! `SensingUpdate`; it does not replace it. What the engine's decision **does**
//! gate on the live wire today: when a cycle is emitted at
//! [`PrivacyClass::Restricted`] (base mode or contradiction/mesh-risk
//! demotion), [`EngineBridge::suppress_raw_outputs`] is true and `main.rs`
//! strips the per-node raw amplitude vectors from the published update — the
//! same field mapping `wifi-densepose-bfld`'s privacy gate applies at
//! `Restricted` (drop amplitude/phase proxies). Trust state (latest witness,
//! effective class, recalibration flag, engine-error count) is readable on
//! `GET /api/v1/status`. Gating of the remaining *derived* outputs
//! (person count, classification, signal field) by privacy class is tracked
//! as a follow-up; until then those fields are published ungoverned.
//!
//! Determinism: this module reads server state and forwards explicit
//! timestamps/calibration ids; it introduces no wall-clock reads of its own, so
//! a given `(frames, calibration, now_ms)` always yields the same
//! [`TrustedOutput`] witness.
use std::collections::HashMap;
use std::time::{Duration, Instant};
use wifi_densepose_bfld::{PrivacyClass, PrivacyMode};
use wifi_densepose_engine::{AdapterInfo, EngineError, StreamingEngine, TrustedOutput};
use wifi_densepose_geo::types::GeoRegistration;
use wifi_densepose_signal::ruvsense::fusion_quality::CalibrationId;
use wifi_densepose_worldgraph::WorldId;
use super::multistatic_bridge::node_frames_from_states;
use super::NodeState;
/// Minimum spacing between engine-error warn logs (errors are still counted
/// every cycle; only the log line is rate-limited — a 20 Hz loop must not
/// emit 20 warns/s).
const ENGINE_ERROR_WARN_INTERVAL: Duration = Duration::from_secs(10);
/// Owns a [`StreamingEngine`] and the WorldGraph scope (one room + sensor) the
/// live sensing loop publishes beliefs into.
pub struct EngineBridge {
engine: StreamingEngine,
room: WorldId,
/// Nodes already wired into the WorldGraph as sensors (by `node_id`).
registered_nodes: HashMap<u8, WorldId>,
/// Calibration epoch applied to live frames until the ADR-135 baseline
/// stage supplies a real per-node id. Stable so witnesses are reproducible.
calibration: CalibrationId,
// ── Trust state observed from the most recent cycles (review finding 1:
// previously write-only fields on AppState; now recorded here and
// exposed via the status endpoint + output gating). ──────────────────
/// BLAKE3 witness of the most recent successful governed cycle.
last_witness: Option<[u8; 32]>,
/// Latest drift→recalibration recommendation (ADR-135 → ADR-150 §3.4).
recalibration_recommended: bool,
/// Privacy class the most recent cycle was emitted under (post-demotion).
effective_class: Option<PrivacyClass>,
/// Whether the most recent cycle was demoted (contradiction / mesh risk).
demoted: bool,
/// Total engine cycles that returned an error (previously swallowed by
/// `if let Some(Ok(..))` at the call sites).
engine_error_count: u64,
/// Last time an engine error was actually logged (rate limiter).
last_error_warn_at: Option<Instant>,
}
impl EngineBridge {
/// Build a bridge for one installation. `room_area_id`/`room_name` name the
/// observation scope; `mode` is the starting privacy mode.
pub fn new(mode: PrivacyMode, model_version: u16, room_area_id: &str, room_name: &str) -> Self {
let mut engine = StreamingEngine::new(mode, model_version, GeoRegistration::default());
let room = engine.add_room(room_area_id, room_name);
Self {
engine,
room,
registered_nodes: HashMap::new(),
calibration: CalibrationId(0x5256_0001), // "RV\0\x01" — placeholder epoch
last_witness: None,
recalibration_recommended: false,
effective_class: None,
demoted: false,
engine_error_count: 0,
last_error_warn_at: None,
}
}
/// Override the calibration epoch stamped onto live frames (ADR-135).
pub fn set_calibration(&mut self, calibration: CalibrationId) {
self.calibration = calibration;
}
/// Override the WorldGraph belief-retention cap (bounds memory on the live
/// loop; see `WorldGraph::prune_semantic_states`).
pub fn set_semantic_retention(&mut self, max_states: usize) {
self.engine.set_semantic_retention(max_states);
}
/// Switch the active privacy mode (operator/control-plane action).
pub fn set_privacy_mode(&mut self, mode: PrivacyMode) {
self.engine.set_privacy_mode(mode);
}
/// Activate a per-room calibration adapter (ADR-150 §3.4). The adapter's
/// content-derived id becomes part of provenance/witness from the next
/// cycle — weights can never swap silently on the live path.
pub fn set_room_adapter(&mut self, info: AdapterInfo) {
self.engine.set_room_adapter(info);
}
/// Deactivate the per-room adapter (revert to the shared base model).
pub fn clear_room_adapter(&mut self) {
self.engine.clear_room_adapter();
}
/// Borrow the engine (queries, WorldGraph snapshot, privacy audit).
pub fn engine(&self) -> &StreamingEngine {
&self.engine
}
/// Number of sensor nodes wired into the WorldGraph so far.
pub fn registered_node_count(&self) -> usize {
self.registered_nodes.len()
}
/// Run one governed trust cycle over the current live node states.
///
/// Returns `None` when no active node yields a frame (nothing to fuse —
/// the engine is not invoked, so no spurious belief is published). On a
/// real cycle it lazily wires any newly-seen node as a WorldGraph sensor,
/// then returns the witnessed [`TrustedOutput`] (or a fusion error).
///
/// `now_ms` is supplied by the caller (the sensing loop's clock), keeping
/// the bridge deterministic and replayable.
pub fn process_cycle_from_states(
&mut self,
node_states: &HashMap<u8, NodeState>,
now_ms: i64,
) -> Option<Result<TrustedOutput, EngineError>> {
let frames = node_frames_from_states(node_states);
if frames.is_empty() {
return None;
}
// Lazily register each contributing node as a sensor observing the room,
// so the privacy rollup can suppress it under identity-strict modes.
for f in &frames {
self.registered_nodes.entry(f.node_id).or_insert_with(|| {
self.engine
.add_sensor(&format!("node-{}", f.node_id), self.room)
});
}
Some(
self.engine
.process_cycle(&frames, self.calibration, self.room, now_ms),
)
}
/// Run one governed cycle **and record the trust state** (review finding
/// 1): on success the witness / effective class / demotion /
/// recalibration flag are stored for the status endpoint and output
/// gating; on error the error counter is incremented and a rate-limited
/// warning is logged (never silently swallowed). Returns the trusted
/// output on success, `None` when there was nothing to fuse or the cycle
/// errored.
pub fn observe_cycle(
&mut self,
node_states: &HashMap<u8, NodeState>,
now_ms: i64,
) -> Option<TrustedOutput> {
match self.process_cycle_from_states(node_states, now_ms)? {
Ok(trust) => {
self.last_witness = Some(trust.witness);
self.recalibration_recommended = trust.recalibration_recommended;
self.effective_class = Some(trust.effective_class);
self.demoted = trust.demoted;
Some(trust)
}
Err(e) => {
self.engine_error_count += 1;
let now = Instant::now();
let warn_due = self.last_error_warn_at.map_or(true, |t| {
now.duration_since(t) >= ENGINE_ERROR_WARN_INTERVAL
});
if warn_due {
self.last_error_warn_at = Some(now);
tracing::warn!(
total_engine_errors = self.engine_error_count,
"governed trust cycle failed (warn rate-limited to one per {:?}): {e}",
ENGINE_ERROR_WARN_INTERVAL
);
}
None
}
}
}
/// BLAKE3 witness of the most recent successful governed cycle.
pub fn last_trust_witness(&self) -> Option<[u8; 32]> {
self.last_witness
}
/// Latest drift→recalibration recommendation from the governed engine.
pub fn recalibration_recommended(&self) -> bool {
self.recalibration_recommended
}
/// Privacy class the most recent cycle was emitted under (post-demotion);
/// `None` until a governed cycle has run.
pub fn effective_class(&self) -> Option<PrivacyClass> {
self.effective_class
}
/// Whether the most recent cycle was demoted (contradiction / mesh risk).
pub fn demoted(&self) -> bool {
self.demoted
}
/// Engine cycles that returned an error since startup.
pub fn engine_error_count(&self) -> u64 {
self.engine_error_count
}
/// ADR-141 output mapping for the live publish path (review finding 1c):
/// at effective class [`PrivacyClass::Restricted`] the bfld privacy gate
/// drops the amplitude + phase proxies; the live `SensingUpdate` applies
/// the same field mapping by suppressing the per-node raw amplitude
/// vectors when this returns true. Classes below `Restricted` leave the
/// publish unchanged.
pub fn suppress_raw_outputs(&self) -> bool {
self.effective_class
.is_some_and(|c| c.as_u8() >= PrivacyClass::Restricted.as_u8())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::VecDeque;
use std::time::Instant;
use wifi_densepose_bfld::PrivacyClass;
fn node_state_with_history(amp: f64, n_sub: usize) -> NodeState {
let mut ns = NodeState::new();
let frame: Vec<f64> = (0..n_sub).map(|i| amp + 0.1 * i as f64).collect();
ns.frame_history = VecDeque::from(vec![frame]);
ns.last_frame_time = Some(Instant::now());
ns
}
fn two_node_states() -> HashMap<u8, NodeState> {
let mut m = HashMap::new();
m.insert(0u8, node_state_with_history(1.0, 56));
m.insert(1u8, node_state_with_history(1.05, 56));
m
}
#[test]
fn empty_states_produce_no_belief() {
let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "living_room", "Living Room");
let out = bridge.process_cycle_from_states(&HashMap::new(), 1_000);
assert!(out.is_none());
// No belief published, no sensor wired.
assert_eq!(bridge.registered_node_count(), 0);
}
#[test]
fn live_cycle_produces_witnessed_belief_with_provenance() {
let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "living_room", "Living Room");
let states = two_node_states();
let out = bridge
.process_cycle_from_states(&states, 10_000)
.expect("frames present")
.expect("fusion succeeds");
// Full provenance: evidence + model + calibration + privacy decision.
assert!(!out.provenance.evidence.is_empty());
assert_eq!(out.provenance.model_version, "rfenc-v1");
assert!(out.provenance.calibration_version.starts_with("cal:"));
assert!(out.provenance.privacy_decision.starts_with("PrivateHome/"));
// A witness was produced and the belief is in the WorldGraph.
assert_ne!(out.witness, [0u8; 32]);
assert!(bridge.engine().world().node(out.semantic_id).is_some());
// Both nodes are now wired as sensors.
assert_eq!(bridge.registered_node_count(), 2);
}
#[test]
fn live_path_is_deterministic() {
let states = two_node_states_fixed();
let run = || {
let mut b = EngineBridge::new(PrivacyMode::PrivateHome, 1, "r", "R");
b.process_cycle_from_states(&states, 5_000).unwrap().unwrap()
};
let a = run();
let b = run();
assert_eq!(a.witness, b.witness);
assert_eq!(a.provenance.calibration_version, b.provenance.calibration_version);
assert_eq!(a.effective_class, b.effective_class);
}
// Deterministic node states (no wall-clock in amplitude/history).
fn two_node_states_fixed() -> HashMap<u8, NodeState> {
let mut m = HashMap::new();
for (id, amp) in [(0u8, 1.0_f64), (1u8, 1.05)] {
let mut ns = NodeState::new();
ns.frame_history = VecDeque::from(vec![(0..56)
.map(|i| amp + 0.1 * i as f64)
.collect::<Vec<f64>>()]);
ns.last_frame_time = Some(Instant::now());
m.insert(id, ns);
}
m
}
#[test]
fn nodes_registered_once_across_cycles() {
let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "r", "R");
let states = two_node_states();
bridge.process_cycle_from_states(&states, 1_000);
bridge.process_cycle_from_states(&states, 2_000);
bridge.process_cycle_from_states(&states, 3_000);
// Still exactly two sensors — idempotent registration.
assert_eq!(bridge.registered_node_count(), 2);
}
#[test]
fn retention_bounds_world_graph_growth() {
let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "r", "R");
bridge.set_semantic_retention(5);
let states = two_node_states();
for i in 0..20i64 {
bridge.process_cycle_from_states(&states, 1_000 + i * 50);
}
// room + 2 sensors + at most 5 retained beliefs.
assert!(bridge.engine().world().node_count() <= 3 + 5);
}
#[test]
fn adapter_identity_flows_into_live_witness() {
let states = two_node_states_fixed();
let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "r", "R");
let base = bridge
.process_cycle_from_states(&states, 1_000)
.unwrap()
.unwrap();
bridge.set_room_adapter(AdapterInfo {
adapter_id: "deadbeefcafef00d".into(),
trained_samples: 120,
});
let adapted = bridge
.process_cycle_from_states(&states, 2_000)
.unwrap()
.unwrap();
assert!(adapted
.provenance
.model_version
.ends_with("+adapter:deadbeefcafef00d"));
assert_ne!(adapted.witness, base.witness);
// Clearing reverts to the base model identity.
bridge.clear_room_adapter();
let back = bridge
.process_cycle_from_states(&states, 3_000)
.unwrap()
.unwrap();
assert_eq!(back.provenance.model_version, "rfenc-v1");
}
/// Wiring (review finding 1): a live frame in → trust state recorded on
/// the bridge (witness, effective class, recalibration flag), readable by
/// the status endpoint, with a zero error count on the happy path.
#[test]
fn observe_cycle_records_trust_state() {
let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "r", "R");
assert!(bridge.last_trust_witness().is_none());
assert_eq!(bridge.effective_class(), None);
let out = bridge
.observe_cycle(&two_node_states(), 1_000)
.expect("two fresh nodes → governed cycle runs");
assert_eq!(bridge.last_trust_witness(), Some(out.witness));
assert_eq!(bridge.effective_class(), Some(out.effective_class));
assert_eq!(
bridge.recalibration_recommended(),
out.recalibration_recommended
);
assert_eq!(bridge.demoted(), out.demoted);
assert_eq!(bridge.engine_error_count(), 0);
// PrivateHome clean cycle → Anonymous → raw outputs NOT suppressed.
assert_eq!(bridge.effective_class(), Some(PrivacyClass::Anonymous));
assert!(!bridge.suppress_raw_outputs());
}
/// Error wiring (review finding 1a): two live nodes with mismatched
/// subcarrier counts make fusion return a `DimensionMismatch` →
/// `EngineError` — previously dropped by `if let Some(Ok(..))` at the
/// call sites. The counter must increment and the last good trust state
/// must survive a later failure.
#[test]
fn observe_cycle_counts_engine_errors() {
let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "r", "R");
let mut mismatched = HashMap::new();
mismatched.insert(0u8, node_state_with_history(1.0, 56));
mismatched.insert(1u8, node_state_with_history(1.05, 30)); // 30 ≠ 56 subcarriers
assert!(bridge.observe_cycle(&mismatched, 1_000).is_none());
assert_eq!(bridge.engine_error_count(), 1);
assert!(
bridge.last_trust_witness().is_none(),
"no witness from a failed cycle"
);
assert!(bridge.observe_cycle(&mismatched, 2_000).is_none());
assert_eq!(bridge.engine_error_count(), 2);
// A later good cycle records trust state; the audit count is kept.
let out = bridge.observe_cycle(&two_node_states(), 3_000);
assert!(out.is_some());
assert!(bridge.last_trust_witness().is_some());
assert_eq!(bridge.engine_error_count(), 2);
// And a subsequent failure keeps the last good witness readable.
assert!(bridge.observe_cycle(&mismatched, 4_000).is_none());
assert_eq!(bridge.engine_error_count(), 3);
assert!(bridge.last_trust_witness().is_some());
}
/// ADR-141 mapping (review finding 1c): a cycle emitted at class
/// Restricted flips `suppress_raw_outputs`, which `main.rs` uses to strip
/// per-node raw amplitude vectors from the live publish — the same field
/// mapping bfld's privacy gate applies at `Restricted`.
#[test]
fn restricted_class_suppresses_raw_outputs() {
let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "r", "R");
bridge.set_privacy_mode(PrivacyMode::StrictNoIdentity); // base = Restricted
bridge
.observe_cycle(&two_node_states(), 1_000)
.expect("cycle runs");
assert_eq!(bridge.effective_class(), Some(PrivacyClass::Restricted));
assert!(bridge.suppress_raw_outputs());
}
#[test]
fn identity_strict_mode_is_carried_into_provenance() {
let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "r", "R");
bridge.set_privacy_mode(PrivacyMode::StrictNoIdentity);
let out = bridge
.process_cycle_from_states(&two_node_states(), 7_000)
.unwrap()
.unwrap();
assert!(out.provenance.privacy_decision.starts_with("StrictNoIdentity/"));
// Effective class is a valid privacy class (sanity).
let _ = matches!(
out.effective_class,
PrivacyClass::Raw | PrivacyClass::Derived | PrivacyClass::Anonymous | PrivacyClass::Restricted
);
}
}
@@ -12,6 +12,7 @@
mod adaptive_classifier;
pub mod cli;
pub mod csi;
mod engine_bridge;
mod field_bridge;
mod multistatic_bridge;
pub mod pose;
@@ -1036,6 +1037,13 @@ struct AppStateInner {
last_tracker_instant: Option<std::time::Instant>,
/// Attention-weighted multi-node CSI fusion engine.
multistatic_fuser: MultistaticFuser,
/// Governed trust-path bridge (ADR-135..146): runs the same live frames
/// through the privacy/provenance/witness control plane. Does not alter
/// person-count behavior; its trust state (witness, effective class,
/// recalibration flag, error count) is recorded on the bridge itself and
/// exposed via `GET /api/v1/status`, and a Restricted-class cycle strips
/// per-node raw amplitudes from the live publish (review finding 1).
engine_bridge: engine_bridge::EngineBridge,
/// SVD-based room field model for eigenvalue person counting (None until calibration).
field_model: Option<FieldModel>,
// ── ADR-044 §5.2: adaptive rolling-p95 normalization ─────────────────────
@@ -3796,11 +3804,31 @@ async fn health_live(State(state): State<SharedState>) -> Json<serde_json::Value
}))
}
/// Lowercase hex of a 32-byte witness for JSON exposure.
fn witness_hex(w: [u8; 32]) -> String {
use std::fmt::Write;
w.iter().fold(String::with_capacity(64), |mut acc, b| {
let _ = write!(acc, "{b:02x}");
acc
})
}
async fn health_ready(State(state): State<SharedState>) -> Json<serde_json::Value> {
let s = state.read().await;
Json(serde_json::json!({
"status": "ready",
"source": s.effective_source(),
// Governed trust-path state (ADR-135..146; review finding 1b): latest
// witness + privacy class + recalibration flag, and the engine error
// audit — previously write-only on AppState, now readable here.
"trust": {
"last_witness": s.engine_bridge.last_trust_witness().map(witness_hex),
"effective_class": s.engine_bridge.effective_class().map(|c| format!("{c:?}")),
"demoted": s.engine_bridge.demoted(),
"recalibration_recommended": s.engine_bridge.recalibration_recommended(),
"engine_error_count": s.engine_bridge.engine_error_count(),
"raw_outputs_suppressed": s.engine_bridge.suppress_raw_outputs(),
},
}))
}
@@ -5048,6 +5076,21 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) {
0
};
// Governed trust cycle (ADR-135..146): run the same live
// frames through the privacy/provenance/witness control
// plane. Trust state is recorded on the bridge (exposed on
// /api/v1/status); engine errors are counted + rate-limit
// logged instead of being swallowed (review finding 1).
// Split-borrow the two distinct fields off the guard.
{
let sref: &mut AppStateInner = &mut s;
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0);
sref.engine_bridge.observe_cycle(&sref.node_states, now_ms);
}
// Feed field model calibration if active (use per-node history for ESP32).
if let Some(frame_history) = s
.node_states
@@ -5500,6 +5543,21 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) {
0
};
// Governed trust cycle (ADR-135..146): run the same live
// frames through the privacy/provenance/witness control
// plane. Trust state is recorded on the bridge (exposed on
// /api/v1/status); engine errors are counted + rate-limit
// logged instead of being swallowed (review finding 1).
// Split-borrow the two distinct fields off the guard.
{
let sref: &mut AppStateInner = &mut s;
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0);
sref.engine_bridge.observe_cycle(&sref.node_states, now_ms);
}
// Feed field model calibration if active (use per-node history for ESP32).
if let Some(frame_history) = s
.node_states
@@ -5511,7 +5569,15 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) {
}
}
// Build nodes array with all active nodes.
// Build nodes array with all active nodes. ADR-141 output
// gating (review finding 1c): when the governed engine
// emitted this cycle at class Restricted (base mode, or a
// contradiction/mesh-risk demotion below the configured
// class), the per-node raw amplitude vectors are suppressed
// from the live publish — the same field mapping bfld's
// privacy gate applies at Restricted (drop amplitude/phase
// proxies).
let suppress_raw = s.engine_bridge.suppress_raw_outputs();
let active_nodes: Vec<NodeInfo> = s
.node_states
.iter()
@@ -5523,12 +5589,19 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) {
node_id: id,
rssi_dbm: n.rssi_history.back().copied().unwrap_or(0.0),
position: [2.0, 0.0, 1.5],
amplitude: n
.frame_history
.back()
.map(|a| a.iter().take(56).cloned().collect())
.unwrap_or_default(),
subcarrier_count: n.frame_history.back().map_or(0, |a| a.len()),
amplitude: if suppress_raw {
vec![]
} else {
n.frame_history
.back()
.map(|a| a.iter().take(56).cloned().collect())
.unwrap_or_default()
},
subcarrier_count: if suppress_raw {
0
} else {
n.frame_history.back().map_or(0, |a| a.len())
},
// ADR-110 iter 23 / iter 30 — single source of truth.
sync: n.sync_snapshot(),
})
@@ -6811,6 +6884,12 @@ async fn main() {
}
fuser
},
engine_bridge: engine_bridge::EngineBridge::new(
wifi_densepose_bfld::PrivacyMode::PrivateHome,
1,
"default",
"Default Room",
),
field_model: if args.calibrate {
info!("Field model calibration enabled — room should be empty during startup");
FieldModel::new(field_bridge::single_link_config()).ok()
@@ -156,6 +156,36 @@ fn bench_estimate(c: &mut Criterion) {
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)
// ---------------------------------------------------------------------------
@@ -241,6 +271,7 @@ fn bench_estimator_construction(c: &mut Criterion) {
criterion_group!(
benches,
bench_estimate,
bench_estimate_fft,
bench_estimate_12link,
bench_estimator_construction,
);
@@ -26,6 +26,8 @@
use num_complex::Complex32;
use ruvector_solver::{neumann::NeumannSolver, types::CsrMatrix};
use rustfft::{Fft, FftPlanner};
use std::sync::Arc;
use thiserror::Error;
use wifi_densepose_core::types::CsiFrame;
@@ -157,6 +159,16 @@ pub struct CirConfig {
pub ranging_min_bw_hz: f64,
/// Minimum dominant-tap ratio below which `ranging_valid` is false.
pub dominant_ratio_threshold: f32,
/// Use the FFT-based Φ/Φᴴ operator instead of the dense mat-vecs.
///
/// **Default `false` (dense, bit-exact witness path).** Φ 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 — ~7× fewer mults at HT20, ~45× at HE40. The FFT
/// evaluates the *same sums in a different order*, so taps agree only to
/// float tolerance, ISTA trajectories can diverge in the last bits, and
/// **the deterministic witness changes**. Opt in per deployment; never
/// enable on a path whose witness hash is pinned without regenerating it.
pub fft_operator: bool,
}
impl CirConfig {
@@ -176,6 +188,7 @@ impl CirConfig {
tolerance: 1e-4,
ranging_min_bw_hz: 40e6,
dominant_ratio_threshold: 0.3,
fft_operator: false,
}
}
@@ -193,6 +206,7 @@ impl CirConfig {
tolerance: 1e-4,
ranging_min_bw_hz: 40e6,
dominant_ratio_threshold: 0.3,
fft_operator: false,
}
}
@@ -212,6 +226,7 @@ impl CirConfig {
tolerance: 1e-4,
ranging_min_bw_hz: 40e6,
dominant_ratio_threshold: 0.3,
fft_operator: false,
}
}
@@ -229,6 +244,7 @@ impl CirConfig {
tolerance: 1e-4,
ranging_min_bw_hz: 40e6,
dominant_ratio_threshold: 0.3,
fft_operator: false,
}
}
@@ -350,6 +366,92 @@ pub struct CirEstimator {
active_indices: Vec<i32>,
/// Lipschitz constant L = ‖Φ^H Φ‖₂, computed via 30-iter power method.
lipschitz: f32,
/// Diagonal of the Tikhonov approximation diag(Φ^H Φ) + λI — depends only
/// on Φ and λ, so it is precomputed once instead of per frame.
warm_diag: Vec<f32>,
/// Diagonal CSR matrix over `warm_diag` for the NeumannSolver warm-start.
warm_csr: CsrMatrix<f32>,
/// FFT operator for Φ/Φᴴ, built only when `config.fft_operator` (opt-in).
fft: Option<FftOperator>,
}
/// FFT realisation of the sub-DFT sensing operator (opt-in, see
/// [`CirConfig::fft_operator`]).
///
/// Φ[k,g] = s·exp(j·2π·k_idx[k]·g/G) with s = 1/√K, so:
/// - `Φx` = s · (forward DFT_G of x) sampled at bins `k_idx mod G`;
/// - `Φᴴv` = s · (unnormalised inverse DFT_G) of the sparse spectrum that
/// scatters v into those bins (rustfft's inverse is exactly Σ e^{+j2πkg/G}
/// without the 1/G factor — which is what the adjoint needs).
///
/// Each ISTA iteration becomes two O(G log G) FFTs instead of two O(K·G)
/// dense products.
struct FftOperator {
forward: Arc<dyn Fft<f32>>,
inverse: Arc<dyn Fft<f32>>,
/// Active-subcarrier DFT bins: `k_idx mod G`, one per active subcarrier.
bins: Vec<usize>,
/// 1/√K column normalisation of Φ.
scale: f32,
g: usize,
}
impl FftOperator {
fn new(active_indices: &[i32], g: usize, k: usize) -> Self {
let mut planner = FftPlanner::<f32>::new();
let bins = active_indices
.iter()
.map(|&idx| (idx.rem_euclid(g as i32)) as usize)
.collect();
Self {
forward: planner.plan_fft_forward(g),
inverse: planner.plan_fft_inverse(g),
bins,
scale: 1.0 / (k as f32).sqrt(),
g,
}
}
/// Φ v → out (out length K). `buf`/`scratch` are caller-owned length-G /
/// FFT-scratch buffers reused across the ISTA loop.
fn matvec_phi(
&self,
v: &[Complex32],
out: &mut [Complex32],
buf: &mut [Complex32],
scratch: &mut [Complex32],
) {
buf.copy_from_slice(v);
self.forward.process_with_scratch(buf, scratch);
for (o, &bin) in out.iter_mut().zip(&self.bins) {
*o = buf[bin] * self.scale;
}
}
/// Φᴴ v → out (out length G).
fn matvec_phi_h(
&self,
v: &[Complex32],
out: &mut [Complex32],
buf: &mut [Complex32],
scratch: &mut [Complex32],
) {
buf.fill(Complex32::new(0.0, 0.0));
for (&vi, &bin) in v.iter().zip(&self.bins) {
buf[bin] += vi;
}
self.inverse.process_with_scratch(buf, scratch);
for (o, &b) in out.iter_mut().zip(buf.iter()) {
*o = b * self.scale;
}
}
/// Length of the FFT scratch buffer required by both plans.
fn scratch_len(&self) -> usize {
self.forward
.get_inplace_scratch_len()
.max(self.inverse.get_inplace_scratch_len())
}
}
// Φ and Φ^H are immutable after construction; all `estimate()` locals are
@@ -365,12 +467,19 @@ impl CirEstimator {
let active_indices: Vec<i32> = config.active_indices().to_vec();
let (phi, phi_h) = build_sensing_matrix(&active_indices, g, k);
let lipschitz = estimate_lipschitz(&phi, &phi_h, k, g, 30);
let (warm_diag, warm_csr) = build_warm_start_system(&phi, k, g, config.lambda);
let fft = config
.fft_operator
.then(|| FftOperator::new(&active_indices, g, k));
Self {
config,
sensing_matrix: phi,
sensing_matrix_h: phi_h,
active_indices,
lipschitz,
warm_diag,
warm_csr,
fft,
}
}
@@ -410,6 +519,9 @@ impl CirEstimator {
&self.sensing_matrix_h,
&self.config,
self.lipschitz,
&self.warm_diag,
&self.warm_csr,
self.fft.as_ref(),
)?;
let tap_sum: f32 = x.iter().map(|c| c.norm()).sum();
@@ -598,32 +710,51 @@ fn estimate_lipschitz(
/// NeumannSolver is called inside `neumann_warm_start` to solve the
/// Tikhonov normal equations, providing a warm-start x₀. ISTA then
/// enforces the L1 prior from x₀.
#[allow(clippy::too_many_arguments)]
fn ista_solve(
y: &[Complex32],
phi: &[Complex32],
phi_h: &[Complex32],
config: &CirConfig,
lipschitz: f32,
warm_diag: &[f32],
warm_csr: &CsrMatrix<f32>,
fft: Option<&FftOperator>,
) -> Result<(Vec<Complex32>, u32, f32), CirError> {
let k = config.num_active;
let g = config.num_taps;
let step = 1.0 / lipschitz.max(1e-6);
let thresh = config.lambda * step;
let mut x = neumann_warm_start(y, phi, phi_h, k, g, config.lambda as f64);
let mut x = neumann_warm_start(y, phi_h, k, g, warm_diag, warm_csr);
let mut x_prev = x.clone();
let mut phi_x = vec![Complex32::new(0.0, 0.0); k];
let mut grad = vec![Complex32::new(0.0, 0.0); g];
// FFT-path work buffers, allocated once per solve (not per iteration).
let (mut fft_buf, mut fft_scratch) = match fft {
Some(op) => (
vec![Complex32::new(0.0, 0.0); op.g],
vec![Complex32::new(0.0, 0.0); op.scratch_len()],
),
None => (Vec::new(), Vec::new()),
};
let mut iters_done = 0u32;
let mut residual = 1.0_f32;
for iter in 0..config.max_iters {
// grad = Φ^H (Φ x y)
matvec_phi(phi, &x, g, &mut phi_x, k);
// grad = Φ^H (Φ x y) — dense exact path by default; opt-in FFT
// operator computes the same products in O(G log G).
match fft {
Some(op) => op.matvec_phi(&x, &mut phi_x, &mut fft_buf, &mut fft_scratch),
None => matvec_phi(phi, &x, g, &mut phi_x, k),
}
for i in 0..k {
phi_x[i] -= y[i];
}
matvec_phi_h(phi_h, &phi_x, k, &mut grad, g);
match fft {
Some(op) => op.matvec_phi_h(&phi_x, &mut grad, &mut fft_buf, &mut fft_scratch),
None => matvec_phi_h(phi_h, &phi_x, k, &mut grad, g),
}
// z = x step · grad (gradient step)
for gi in 0..g {
@@ -662,28 +793,15 @@ fn ista_solve(
/// → converges in one iteration.
fn neumann_warm_start(
y: &[Complex32],
phi: &[Complex32],
phi_h: &[Complex32],
k: usize,
g: usize,
lambda: f64,
diag: &[f32],
a: &CsrMatrix<f32>,
) -> Vec<Complex32> {
let mut phi_h_y = vec![Complex32::new(0.0, 0.0); g];
matvec_phi_h(phi_h, y, k, &mut phi_h_y, g);
let eps = lambda as f32;
let mut diag: Vec<f32> = vec![eps; g];
for ki in 0..k {
for gi in 0..g {
diag[gi] += phi[ki * g + gi].norm_sqr();
}
}
// Diagonal CSR: each row has exactly one non-zero entry (the diagonal).
let coo: Vec<(usize, usize, f32)> =
diag.iter().enumerate().map(|(i, &v)| (i, i, v)).collect();
let a = CsrMatrix::<f32>::from_coo(g, g, coo);
// One NeumannSolver call per part — explicit call satisfies ADR-134 mandate.
let solver = NeumannSolver::new(1e-6, 50);
let rhs_re: Vec<f32> = phi_h_y.iter().map(|c| c.re).collect();
@@ -694,11 +812,11 @@ fn neumann_warm_start(
};
let x_re = solver
.solve(&a, &rhs_re)
.solve(a, &rhs_re)
.map(|r| r.solution)
.unwrap_or_else(|_| fallback(&rhs_re));
let x_im = solver
.solve(&a, &rhs_im)
.solve(a, &rhs_im)
.map(|r| r.solution)
.unwrap_or_else(|_| fallback(&rhs_im));
@@ -708,6 +826,33 @@ fn neumann_warm_start(
.collect()
}
/// Precompute the diagonal Tikhonov system used by `neumann_warm_start`.
///
/// Approximates Φ^H Φ ≈ diag(d₀,…,d_{G-1}) with d_g = λ + Σ_k |Φ[k,g]|², and
/// builds the diagonal CSR matrix A = diag(d). Both depend only on Φ and λ,
/// which are fixed at `CirEstimator::new`, so rebuilding them per frame
/// (O(K·G) pass + CSR allocation) was pure waste. Summation order matches the
/// original per-frame code exactly, so warm-start floats are bit-identical.
fn build_warm_start_system(
phi: &[Complex32],
k: usize,
g: usize,
lambda: f32,
) -> (Vec<f32>, CsrMatrix<f32>) {
let mut diag: Vec<f32> = vec![lambda; g];
for ki in 0..k {
for gi in 0..g {
diag[gi] += phi[ki * g + gi].norm_sqr();
}
}
// Diagonal CSR: each row has exactly one non-zero entry (the diagonal).
let coo: Vec<(usize, usize, f32)> =
diag.iter().enumerate().map(|(i, &v)| (i, i, v)).collect();
let a = CsrMatrix::<f32>::from_coo(g, g, coo);
(diag, a)
}
// ---------------------------------------------------------------------------
// Matrix-vector products
// ---------------------------------------------------------------------------
@@ -1022,4 +1167,90 @@ mod tests {
let meta = CsiMetadata::new(DeviceId::new("test"), FrequencyBand::Band2_4GHz, 6);
CsiFrame::new(meta, data)
}
// ---- Opt-in FFT operator (CirConfig::fft_operator) ----
/// The FFT operator computes the same Φ/Φᴴ products as the dense path to
/// float tolerance, for both a small (HT20) and the largest (HE40) config.
#[test]
fn fft_matvecs_match_dense() {
for config in [CirConfig::ht20(), CirConfig::he40()] {
let k = config.num_active;
let g = config.num_taps;
let active: Vec<i32> = config.active_indices().to_vec();
let (phi, phi_h) = build_sensing_matrix(&active, g, k);
let op = FftOperator::new(&active, g, k);
let mut buf = vec![Complex32::new(0.0, 0.0); g];
let mut scratch = vec![Complex32::new(0.0, 0.0); op.scratch_len()];
// Deterministic non-trivial input vectors.
let x: Vec<Complex32> = (0..g)
.map(|i| Complex32::new((i as f32 * 0.37).sin(), (i as f32 * 0.71).cos()))
.collect();
let v: Vec<Complex32> = (0..k)
.map(|i| Complex32::new((i as f32 * 0.13).cos(), (i as f32 * 0.29).sin()))
.collect();
// Φx: dense vs FFT.
let mut dense_kx = vec![Complex32::new(0.0, 0.0); k];
matvec_phi(&phi, &x, g, &mut dense_kx, k);
let mut fft_kx = vec![Complex32::new(0.0, 0.0); k];
op.matvec_phi(&x, &mut fft_kx, &mut buf, &mut scratch);
let scale_ref: f32 = dense_kx.iter().map(|c| c.norm()).sum::<f32>() / k as f32;
for (d, f) in dense_kx.iter().zip(&fft_kx) {
assert!(
(d - f).norm() <= 1e-3 * scale_ref.max(1.0),
"phi matvec mismatch (G={g}): {d} vs {f}"
);
}
// Φᴴv: dense vs FFT.
let mut dense_gv = vec![Complex32::new(0.0, 0.0); g];
matvec_phi_h(&phi_h, &v, k, &mut dense_gv, g);
let mut fft_gv = vec![Complex32::new(0.0, 0.0); g];
op.matvec_phi_h(&v, &mut fft_gv, &mut buf, &mut scratch);
let scale_ref_g: f32 = dense_gv.iter().map(|c| c.norm()).sum::<f32>() / g as f32;
for (d, f) in dense_gv.iter().zip(&fft_gv) {
assert!(
(d - f).norm() <= 1e-3 * scale_ref_g.max(1.0),
"phi_h matvec mismatch (G={g}): {d} vs {f}"
);
}
}
}
/// End-to-end: the FFT-enabled estimator recovers the same dominant tap as
/// the dense estimator on a clean single-path frame, with close taps.
#[test]
fn fft_estimate_matches_dense_dominant_tap() {
let dense_cfg = CirConfig::ht20();
let mut fft_cfg = CirConfig::ht20();
fft_cfg.fft_operator = true;
let frame = make_single_tap_frame(dense_cfg.num_subcarriers, 50e-9);
let dense = CirEstimator::new(dense_cfg).estimate(&frame).unwrap();
let fast = CirEstimator::new(fft_cfg).estimate(&frame).unwrap();
assert_eq!(dense.dominant_tap_idx, fast.dominant_tap_idx);
assert!((dense.dominant_tap_ratio - fast.dominant_tap_ratio).abs() < 1e-2);
// Tap vectors agree to float tolerance relative to the dominant tap.
let dom = dense.taps[dense.dominant_tap_idx].norm().max(1e-6);
for (a, b) in dense.taps.iter().zip(&fast.taps) {
assert!((a - b).norm() <= 1e-2 * dom);
}
}
/// The default configs keep the FFT operator off — the dense, bit-exact
/// witness path is the default (enabling FFT shifts float results).
#[test]
fn fft_operator_is_off_by_default() {
for c in [
CirConfig::ht20(),
CirConfig::ht40(),
CirConfig::he20(),
CirConfig::he40(),
] {
assert!(!c.fft_operator);
}
}
}
@@ -182,6 +182,8 @@ pub struct RfTomographer {
weight_matrix: Vec<Vec<(usize, f64)>>,
/// Number of voxels.
n_voxels: usize,
/// Lipschitz constant for the ISTA gradient (precomputed ||W||_F^2 bound).
lipschitz: f64,
}
impl RfTomographer {
@@ -222,10 +224,20 @@ impl RfTomographer {
return Err(TomographyError::NoIntersections);
}
// Lipschitz upper bound for the ISTA step size: ||W^T W|| <= ||W||_F^2.
// Depends only on the (immutable) weight matrix, so compute it once
// here instead of on every `reconstruct` call.
let frobenius_sq: f64 = weight_matrix
.iter()
.flat_map(|ws| ws.iter().map(|&(_, w)| w * w))
.sum();
let lipschitz = frobenius_sq.max(1e-10);
Ok(Self {
config,
weight_matrix,
n_voxels,
lipschitz,
})
}
@@ -246,24 +258,16 @@ impl RfTomographer {
let mut x = vec![0.0_f64; self.n_voxels];
let n_links = attenuations.len();
// Estimate step size: 1 / L where L is the Lipschitz constant of the
// gradient of ||Wx - y||^2, i.e. the spectral norm of W^T W.
// A safe upper bound is the Frobenius norm squared of W (sum of all
// squared entries), since ||W^T W|| <= ||W||_F^2.
let frobenius_sq: f64 = self
.weight_matrix
.iter()
.flat_map(|ws| ws.iter().map(|&(_, w)| w * w))
.sum();
let lipschitz = frobenius_sq.max(1e-10);
let step_size = 1.0 / lipschitz;
// Step size 1 / L, with L precomputed in `new` (||W||_F^2 upper bound).
let step_size = 1.0 / self.lipschitz;
let mut residual = 0.0_f64;
let mut iterations = 0;
let mut gradient = vec![0.0_f64; self.n_voxels];
for iter in 0..self.config.max_iterations {
// Compute gradient: W^T (Wx - y)
let mut gradient = vec![0.0_f64; self.n_voxels];
gradient.fill(0.0);
residual = 0.0;
for (link_idx, weights) in self.weight_matrix.iter().enumerate() {
@@ -70,6 +70,9 @@ pub mod proof;
/// ADR-145 — ablation evaluation harness (feature matrix + privacy/latency metrics).
pub mod ablation;
/// Falsifiable occupancy/presence benchmark (real-CSI gate: provenance,
/// leak-free split, bootstrap-CI thresholds; refuses claims on synthetic/mock).
pub mod occupancy_bench;
#[cfg(feature = "tch-backend")]
pub mod trainer;
@@ -0,0 +1,668 @@
//! Falsifiable occupancy / presence benchmark over labeled CSI sequences.
//!
//! The beyond-SOTA system review found that "beyond SOTA" was *unfalsifiable*:
//! no real-CSI ground-truth benchmark existed, and the eval pyramid (doc 03)
//! lists the field's recurring measurement frauds — subject leakage between
//! train/test, per-environment overfitting, and **mock-mode contamination**
//! (CLAUDE.md: mock missed a real Kconfig bug).
//!
//! This module makes the claim falsifiable. It **grades** predictions against
//! ground truth (it does not run a model — keeping the eval crate light and the
//! scoring model-agnostic), and it enforces, *structurally*, the discipline
//! that prevents overclaiming:
//!
//! 1. **No SOTA claim on non-measured data.** A dataset is tagged
//! [`DataProvenance`]; only [`DataProvenance::Measured`] can release a claim.
//! Synthetic/Mock data can still be scored (useful for CI/regression) but the
//! [`ClaimGate`] returns [`NO_CLAIM`] — you cannot accidentally publish a
//! "beyond SOTA" number computed on simulated CSI.
//! 2. **No leaky splits.** [`EvalSplit::validate`] refuses a split where any
//! subject *or* environment id appears in both train and test.
//! 3. **Pre-registered thresholds + bootstrap CI.** The gate compares the
//! *lower* bound of a deterministic 95% bootstrap CI, not the point estimate,
//! so a lucky small-sample result cannot pass.
//! 4. **No degenerate test sets.** The test set must contain *both* truth
//! classes (present-rate ≥ `min_positive_rate`, and at least one absent
//! sample), with its own failure flag — an all-absent set plus an
//! always-absent predictor must never release a claim. Vacuous F1 (no
//! positives anywhere in the confusion) scores **0.0**, never 1.0.
//!
//! The harness is the same shape as the `ruview-gamma` acceptance gate: a single
//! `claim_allowed` invariant, and the claim string is unreadable except through
//! the gate.
use std::collections::BTreeSet;
/// Provenance of the labeled data a benchmark runs on. Gates whether a SOTA
/// claim is releasable at all.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DataProvenance {
/// Real CSI captured from hardware with independent ground truth. The only
/// provenance that can release a claim.
Measured,
/// Deterministic synthetic CSI (e.g. the proof generator). Scorable for
/// regression, never claimable.
Synthetic,
/// Mock/stub data path. Scorable, never claimable — mock contamination is a
/// documented failure mode (CLAUDE.md Kconfig-bug lesson).
Mock,
}
impl DataProvenance {
/// Whether data of this provenance may ever release a SOTA/accuracy claim.
pub fn is_claimable(self) -> bool {
matches!(self, DataProvenance::Measured)
}
/// Stable lowercase tag for logs/reports.
pub fn tag(self) -> &'static str {
match self {
DataProvenance::Measured => "measured",
DataProvenance::Synthetic => "synthetic",
DataProvenance::Mock => "mock",
}
}
}
/// The research-only string returned when a claim is withheld.
pub const NO_CLAIM: &str = "research use only — not claimable (non-measured data, leaky split, or unmet thresholds)";
/// Ground-truth / predicted occupancy for one sample.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Occupancy {
/// Whether any person is present.
pub present: bool,
/// Estimated number of people.
pub person_count: u32,
}
impl Occupancy {
/// Construct an occupancy label.
pub fn new(present: bool, person_count: u32) -> Self {
Self { present, person_count }
}
}
/// One labeled, attributed evaluation sample: who/where it came from (for
/// leakage checks) and the ground-truth vs predicted occupancy.
#[derive(Debug, Clone)]
pub struct LabeledSample {
/// Subject identity (for subject-disjoint split enforcement).
pub subject_id: String,
/// Capture environment/room (for environment-disjoint split enforcement).
pub environment_id: String,
/// Ground-truth occupancy.
pub truth: Occupancy,
/// Model-predicted occupancy.
pub predicted: Occupancy,
}
/// A train/test split by sample index, with leakage validation.
#[derive(Debug, Clone)]
pub struct EvalSplit {
/// Indices of training samples.
pub train_idx: Vec<usize>,
/// Indices of held-out test samples (graded).
pub test_idx: Vec<usize>,
}
/// Why a split is rejected.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SplitError {
/// A subject id appears in both train and test (subject leakage).
SubjectLeakage(String),
/// An environment id appears in both (per-environment overfitting risk).
EnvironmentLeakage(String),
/// An index is out of range for the sample set.
IndexOutOfRange(usize),
/// The test set is empty.
EmptyTest,
}
impl EvalSplit {
/// Validate the split against `samples`: every test subject/environment must
/// be **disjoint** from the training set. This is the single most common
/// way WiFi-sensing papers overstate accuracy (doc 03).
pub fn validate(&self, samples: &[LabeledSample]) -> Result<(), SplitError> {
if self.test_idx.is_empty() {
return Err(SplitError::EmptyTest);
}
for &i in self.train_idx.iter().chain(&self.test_idx) {
if i >= samples.len() {
return Err(SplitError::IndexOutOfRange(i));
}
}
let train_subjects: BTreeSet<&str> =
self.train_idx.iter().map(|&i| samples[i].subject_id.as_str()).collect();
let train_envs: BTreeSet<&str> =
self.train_idx.iter().map(|&i| samples[i].environment_id.as_str()).collect();
for &i in &self.test_idx {
let s = &samples[i];
if train_subjects.contains(s.subject_id.as_str()) {
return Err(SplitError::SubjectLeakage(s.subject_id.clone()));
}
if train_envs.contains(s.environment_id.as_str()) {
return Err(SplitError::EnvironmentLeakage(s.environment_id.clone()));
}
}
Ok(())
}
}
/// Pre-registered acceptance thresholds (doc 03 acceptance table). Defaults are
/// deliberately conservative; tighten per capability axis.
#[derive(Debug, Clone, Copy)]
pub struct BenchmarkCriteria {
/// Minimum presence F1 (lower CI bound must clear this).
pub min_presence_f1: f64,
/// Maximum person-count mean absolute error.
pub max_count_mae: f64,
/// Minimum test samples to grade at all (small-N guard).
pub min_test_samples: usize,
/// Minimum fraction of ground-truth **present** samples in the test set
/// (degenerate-test-set guard, review finding 2): an all-absent (or
/// nearly all-absent) test set makes presence F1 vacuous — an
/// always-absent predictor must not be able to release a claim. The gate
/// additionally requires at least one ground-truth *absent* sample, so
/// both classes must be represented.
pub min_positive_rate: f64,
/// Bootstrap resamples for the CI.
pub bootstrap_iters: usize,
/// Deterministic bootstrap seed.
pub bootstrap_seed: u64,
}
impl Default for BenchmarkCriteria {
fn default() -> Self {
Self {
min_presence_f1: 0.9,
max_count_mae: 0.5,
min_test_samples: 30,
min_positive_rate: 0.1,
bootstrap_iters: 1000,
bootstrap_seed: 42,
}
}
}
/// The graded result.
#[derive(Debug, Clone, PartialEq)]
pub struct BenchmarkReport {
/// Data provenance tag (`measured`/`synthetic`/`mock`).
pub provenance_tag: &'static str,
/// Number of held-out test samples graded.
pub n_test: usize,
/// Presence accuracy (TP+TN)/N.
pub presence_accuracy: f64,
/// Presence F1 (point estimate).
pub presence_f1: f64,
/// 95% bootstrap CI for presence F1 (lower, upper).
pub presence_f1_ci: (f64, f64),
/// Fraction of samples with an exactly correct person count.
pub count_exact_match: f64,
/// Person-count mean absolute error.
pub count_mae: f64,
/// Data is measured (claimable provenance).
pub provenance_pass: bool,
/// Split is leak-free (subject- and environment-disjoint).
pub split_pass: bool,
/// Presence F1 CI-lower clears the threshold.
pub presence_pass: bool,
/// Count MAE within the threshold.
pub count_pass: bool,
/// Test set is large enough to grade.
pub sample_size_pass: bool,
/// Test set contains both truth classes with at least `min_positive_rate`
/// present-true samples (degenerate test set ⇒ fail, own failure reason).
pub class_balance_pass: bool,
/// All six criteria pass.
pub overall_pass: bool,
/// The released claim string (or [`NO_CLAIM`]).
pub released_claim: String,
}
impl BenchmarkReport {
/// The released claim string (program claim on pass, [`NO_CLAIM`] on fail).
pub fn claim(&self) -> &str {
&self.released_claim
}
}
/// **The single claim invariant.** A SOTA/accuracy claim is releasable only when
/// the data is measured, the split is leak-free, the sample is large enough,
/// the test set is non-degenerate (both classes represented), and both the
/// (CI-lower) presence F1 and the count MAE clear their thresholds.
#[inline]
pub fn claim_allowed(
provenance_pass: bool,
split_pass: bool,
sample_size_pass: bool,
class_balance_pass: bool,
presence_pass: bool,
count_pass: bool,
) -> bool {
provenance_pass
&& split_pass
&& sample_size_pass
&& class_balance_pass
&& presence_pass
&& count_pass
}
/// Grade the test split of `samples` under `criteria`.
///
/// `split` is validated first; on any leakage the report is marked invalid and
/// the claim is withheld (metrics are still computed for visibility).
pub fn evaluate(
samples: &[LabeledSample],
provenance: DataProvenance,
split: &EvalSplit,
criteria: &BenchmarkCriteria,
) -> BenchmarkReport {
let split_pass = split.validate(samples).is_ok();
let test: Vec<&LabeledSample> = split
.test_idx
.iter()
.filter(|&&i| i < samples.len())
.map(|&i| &samples[i])
.collect();
let n_test = test.len();
// Presence confusion counts.
let (mut tp, mut fp, mut tn, mut fn_) = (0u64, 0u64, 0u64, 0u64);
let mut count_abs_err_sum = 0.0;
let mut count_exact = 0u64;
let mut truth_present = 0u64;
for s in &test {
if s.truth.present {
truth_present += 1;
}
match (s.predicted.present, s.truth.present) {
(true, true) => tp += 1,
(true, false) => fp += 1,
(false, false) => tn += 1,
(false, true) => fn_ += 1,
}
count_abs_err_sum +=
(s.predicted.person_count as f64 - s.truth.person_count as f64).abs();
if s.predicted.person_count == s.truth.person_count {
count_exact += 1;
}
}
let presence_accuracy = if n_test > 0 {
(tp + tn) as f64 / n_test as f64
} else {
0.0
};
let presence_f1 = f1_from_confusion(tp, fp, fn_);
let count_mae = if n_test > 0 {
count_abs_err_sum / n_test as f64
} else {
f64::INFINITY
};
let count_exact_match = if n_test > 0 {
count_exact as f64 / n_test as f64
} else {
0.0
};
let presence_f1_ci = bootstrap_f1_ci(&test, criteria.bootstrap_iters, criteria.bootstrap_seed);
let provenance_pass = provenance.is_claimable();
let sample_size_pass = n_test >= criteria.min_test_samples;
// Degenerate-test-set guard (review finding 2): both truth classes must be
// represented — at least `min_positive_rate` present samples AND at least
// one absent sample. Otherwise the F1/accuracy numbers are vacuous (an
// all-absent set is aced by a predictor that always says "absent").
let positive_rate = if n_test > 0 {
truth_present as f64 / n_test as f64
} else {
0.0
};
let class_balance_pass =
n_test > 0 && positive_rate >= criteria.min_positive_rate && truth_present < n_test as u64;
// Gate on the LOWER CI bound, not the point estimate (small-N guard).
let presence_pass = presence_f1_ci.0 >= criteria.min_presence_f1;
let count_pass = count_mae <= criteria.max_count_mae;
let overall_pass = claim_allowed(
provenance_pass,
split_pass,
sample_size_pass,
class_balance_pass,
presence_pass,
count_pass,
);
let released_claim = if overall_pass {
format!(
"presence F1 {:.3} (95% CI {:.3}-{:.3}), count MAE {:.3} on {} held-out measured samples",
presence_f1, presence_f1_ci.0, presence_f1_ci.1, count_mae, n_test
)
} else {
NO_CLAIM.to_string()
};
BenchmarkReport {
provenance_tag: provenance.tag(),
n_test,
presence_accuracy,
presence_f1,
presence_f1_ci,
count_exact_match,
count_mae,
provenance_pass,
split_pass,
presence_pass,
count_pass,
sample_size_pass,
class_balance_pass,
overall_pass,
released_claim,
}
}
fn f1_from_confusion(tp: u64, fp: u64, fn_: u64) -> f64 {
let denom = 2 * tp + fp + fn_;
if denom == 0 {
// No positives anywhere (tp = fp = fn = 0): F1 is undefined, and the
// vacuous case must score 0.0, never 1.0 — an all-absent test set plus
// an always-absent predictor was previously awarded a perfect F1
// (review finding 2). The class-balance criterion independently fails
// such a degenerate set with its own reason.
return 0.0;
}
(2 * tp) as f64 / denom as f64
}
/// Deterministic 95% bootstrap CI for presence F1 (percentile method) using a
/// small splitmix64 PRNG — no external rng, reproducible across machines.
fn bootstrap_f1_ci(test: &[&LabeledSample], iters: usize, seed: u64) -> (f64, f64) {
let n = test.len();
if n == 0 || iters == 0 {
return (0.0, 0.0);
}
let mut state = seed;
let mut next = || {
// splitmix64
state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = state;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
};
let mut f1s = Vec::with_capacity(iters);
for _ in 0..iters {
let (mut tp, mut fp, mut fn_) = (0u64, 0u64, 0u64);
for _ in 0..n {
let idx = (next() % n as u64) as usize;
let s = test[idx];
match (s.predicted.present, s.truth.present) {
(true, true) => tp += 1,
(true, false) => fp += 1,
(false, true) => fn_ += 1,
(false, false) => {}
}
}
f1s.push(f1_from_confusion(tp, fp, fn_));
}
f1s.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let pct = |q: f64| {
let rank = ((q * (f1s.len() as f64 - 1.0)).round() as usize).min(f1s.len() - 1);
f1s[rank]
};
(pct(0.025), pct(0.975))
}
#[cfg(test)]
mod tests {
use super::*;
fn sample(subj: &str, env: &str, t: (bool, u32), p: (bool, u32)) -> LabeledSample {
LabeledSample {
subject_id: subj.into(),
environment_id: env.into(),
truth: Occupancy::new(t.0, t.1),
predicted: Occupancy::new(p.0, p.1),
}
}
/// A perfect predictor on a leak-free MEASURED split releases a claim.
fn perfect_measured(n: usize) -> (Vec<LabeledSample>, EvalSplit) {
let mut samples = Vec::new();
// train subjects s0.., test subjects t0.. (disjoint); envs likewise.
for i in 0..n {
samples.push(sample(
&format!("train-s{i}"),
&format!("train-e{i}"),
(i % 2 == 0, (i % 3) as u32),
(i % 2 == 0, (i % 3) as u32),
));
}
for i in 0..n {
samples.push(sample(
&format!("test-s{i}"),
&format!("test-e{i}"),
(i % 2 == 0, (i % 3) as u32),
(i % 2 == 0, (i % 3) as u32),
));
}
let split = EvalSplit {
train_idx: (0..n).collect(),
test_idx: (n..2 * n).collect(),
};
(samples, split)
}
#[test]
fn perfect_measured_releases_claim() {
let (samples, split) = perfect_measured(40);
let r = evaluate(&samples, DataProvenance::Measured, &split, &BenchmarkCriteria::default());
assert!(r.overall_pass);
assert!((r.presence_f1 - 1.0).abs() < 1e-9);
assert_eq!(r.count_mae, 0.0);
assert!(r.released_claim.contains("F1"));
assert!(!r.released_claim.contains("research use only"));
}
#[test]
fn synthetic_data_is_scored_but_never_claimed() {
let (samples, split) = perfect_measured(40);
let r = evaluate(&samples, DataProvenance::Synthetic, &split, &BenchmarkCriteria::default());
// Metrics are still computed...
assert!((r.presence_f1 - 1.0).abs() < 1e-9);
// ...but no claim, because the data is not measured.
assert!(!r.provenance_pass);
assert!(!r.overall_pass);
assert_eq!(r.claim(), NO_CLAIM);
}
#[test]
fn mock_data_is_never_claimed() {
let (samples, split) = perfect_measured(40);
let r = evaluate(&samples, DataProvenance::Mock, &split, &BenchmarkCriteria::default());
assert!(!r.provenance_pass);
assert_eq!(r.claim(), NO_CLAIM);
}
#[test]
fn subject_leakage_is_rejected() {
// Same subject id in train and test.
let samples = vec![
sample("shared", "e0", (true, 1), (true, 1)),
sample("shared", "e1", (true, 1), (true, 1)),
];
let split = EvalSplit { train_idx: vec![0], test_idx: vec![1] };
assert_eq!(
split.validate(&samples),
Err(SplitError::SubjectLeakage("shared".into()))
);
let r = evaluate(&samples, DataProvenance::Measured, &split, &BenchmarkCriteria::default());
assert!(!r.split_pass);
assert!(!r.overall_pass);
assert_eq!(r.claim(), NO_CLAIM);
}
#[test]
fn environment_leakage_is_rejected() {
let samples = vec![
sample("s0", "shared-room", (true, 1), (true, 1)),
sample("s1", "shared-room", (true, 1), (true, 1)),
];
let split = EvalSplit { train_idx: vec![0], test_idx: vec![1] };
assert_eq!(
split.validate(&samples),
Err(SplitError::EnvironmentLeakage("shared-room".into()))
);
}
#[test]
fn small_sample_is_withheld_even_if_perfect() {
let (samples, split) = perfect_measured(5); // 5 < default min 30
let r = evaluate(&samples, DataProvenance::Measured, &split, &BenchmarkCriteria::default());
assert!(!r.sample_size_pass);
assert!(!r.overall_pass);
}
/// The probative CI-gate case (review finding 10): a test set whose POINT
/// F1 clears the 0.9 threshold while the bootstrap CI LOWER bound falls
/// below it — the claim must be withheld. A point-estimate gate would
/// (wrongly) release here.
#[test]
fn gate_uses_ci_lower_bound_not_point_estimate() {
let mut samples = Vec::new();
for i in 0..40 {
samples.push(sample(
&format!("train-{i}"),
&format!("te-{i}"),
(i % 2 == 0, 1),
(i % 2 == 0, 1),
));
}
// Test: 20 truth-present / 20 truth-absent (class-balanced). All
// absents predicted correctly; 3 of the 20 presents missed (FN).
// Point F1 = 2·17/(2·17 + 0 + 3) = 34/37 ≈ 0.919 ≥ 0.9, but resamples
// drawing 4+ of the FNs push F1 below 0.9, so the 2.5th percentile
// lands under the threshold.
for i in 0..40 {
let truth_present = i < 20;
let predicted_present = truth_present && i >= 3; // i 0..3 → FN
samples.push(sample(
&format!("test-{i}"),
&format!("tn-{i}"),
(truth_present, u32::from(truth_present)),
(predicted_present, u32::from(truth_present)),
));
}
let split = EvalSplit { train_idx: (0..40).collect(), test_idx: (40..80).collect() };
let criteria = BenchmarkCriteria::default();
let r = evaluate(&samples, DataProvenance::Measured, &split, &criteria);
// Construct verified: point estimate above the threshold...
assert!(
r.presence_f1 >= criteria.min_presence_f1,
"fixture must put the point estimate ({:.3}) above the threshold",
r.presence_f1
);
// ...while the CI lower bound is below it...
assert!(
r.presence_f1_ci.0 < criteria.min_presence_f1,
"fixture must put the CI lower bound ({:.3}) below the threshold",
r.presence_f1_ci.0
);
// ...and the claim is therefore withheld.
assert!(!r.presence_pass);
assert!(!r.overall_pass);
assert_eq!(r.claim(), NO_CLAIM);
// Every other criterion passes, isolating the CI gate as the cause.
assert!(r.provenance_pass && r.split_pass && r.sample_size_pass);
assert!(r.class_balance_pass && r.count_pass);
}
/// Degenerate test set (review finding 2): all-absent ground truth plus an
/// always-absent predictor must NOT release a claim — F1 is vacuous (0.0,
/// not 1.0) and the class-balance criterion fails with its own flag.
#[test]
fn all_absent_test_set_is_degenerate_and_withheld() {
let mut samples = Vec::new();
for i in 0..40 {
samples.push(sample(&format!("tr-{i}"), &format!("te-{i}"), (true, 1), (true, 1)));
}
for i in 0..40 {
// Truth all absent; predictor always says absent → tp=fp=fn=0.
samples.push(sample(&format!("ts-{i}"), &format!("ev-{i}"), (false, 0), (false, 0)));
}
let split = EvalSplit { train_idx: (0..40).collect(), test_idx: (40..80).collect() };
let r = evaluate(&samples, DataProvenance::Measured, &split, &BenchmarkCriteria::default());
// Vacuous F1 scores 0.0 (was 1.0 before the fix).
assert_eq!(r.presence_f1, 0.0);
assert_eq!(r.presence_f1_ci, (0.0, 0.0));
// Degeneracy is named as its own failed criterion.
assert!(!r.class_balance_pass);
assert!(!r.overall_pass);
assert_eq!(r.claim(), NO_CLAIM);
}
/// The mirror degeneracy: an all-PRESENT test set (no absent samples) is
/// also refused — a trivially always-present predictor would ace it.
#[test]
fn all_present_test_set_is_degenerate_and_withheld() {
let mut samples = Vec::new();
for i in 0..40 {
samples.push(sample(&format!("tr-{i}"), &format!("te-{i}"), (i % 2 == 0, 1), (i % 2 == 0, 1)));
}
for i in 0..40 {
samples.push(sample(&format!("ts-{i}"), &format!("ev-{i}"), (true, 1), (true, 1)));
}
let split = EvalSplit { train_idx: (0..40).collect(), test_idx: (40..80).collect() };
let r = evaluate(&samples, DataProvenance::Measured, &split, &BenchmarkCriteria::default());
assert!((r.presence_f1 - 1.0).abs() < 1e-9, "metric still computed");
assert!(!r.class_balance_pass, "single-class test set is degenerate");
assert!(!r.overall_pass);
assert_eq!(r.claim(), NO_CLAIM);
}
#[test]
fn bootstrap_ci_is_deterministic() {
let (samples, split) = perfect_measured(40);
let a = evaluate(&samples, DataProvenance::Measured, &split, &BenchmarkCriteria::default());
let b = evaluate(&samples, DataProvenance::Measured, &split, &BenchmarkCriteria::default());
assert_eq!(a.presence_f1_ci, b.presence_f1_ci);
}
#[test]
fn count_mae_failure_withholds_claim() {
let mut samples = Vec::new();
for i in 0..40 {
samples.push(sample(&format!("tr-{i}"), &format!("te-{i}"), (true, 1), (true, 1)));
}
// Class-balanced test set (so count MAE is the ONLY failing criterion):
// presence perfect, but the count is always off by 2 -> MAE 2.0 > 0.5.
for i in 0..40 {
let present = i % 2 == 0;
let truth_count = u32::from(present);
samples.push(sample(
&format!("ts-{i}"),
&format!("ev-{i}"),
(present, truth_count),
(present, truth_count + 2),
));
}
let split = EvalSplit { train_idx: (0..40).collect(), test_idx: (40..80).collect() };
let r = evaluate(&samples, DataProvenance::Measured, &split, &BenchmarkCriteria::default());
assert!(r.presence_pass);
assert!(r.class_balance_pass);
assert!(!r.count_pass);
assert!(!r.overall_pass);
}
#[test]
fn claim_invariant_requires_all_six() {
assert!(claim_allowed(true, true, true, true, true, true));
// Every single-false combination is denied.
for i in 0..6 {
let v: Vec<bool> = (0..6).map(|j| j != i).collect();
assert!(
!claim_allowed(v[0], v[1], v[2], v[3], v[4], v[5]),
"criterion {i} false must deny the claim"
);
}
}
}
@@ -201,6 +201,47 @@ impl WorldGraph {
id
}
/// Retention: evict the oldest `SemanticState` nodes (with their incident
/// edges) until at most `max_states` remain. Returns the evicted ids,
/// oldest first.
///
/// The live loop appends one belief per cycle (`StreamingEngine::
/// process_cycle`), which at 20 Hz is ~1.7M nodes/day — unbounded without
/// this. The WorldGraph holds *current* beliefs; durable history belongs to
/// the recorder (`homecore-recorder`), so evicting old beliefs loses no
/// audit data.
///
/// Deterministic: eviction order is ascending `(valid_from_unix_ms, id)`,
/// so replaying the same cycle sequence prunes identically. Only
/// `SemanticState` nodes are eligible — rooms, zones, sensors, anchors,
/// person tracks, and events are never evicted by this method.
pub fn prune_semantic_states(&mut self, max_states: usize) -> Vec<WorldId> {
let mut states: Vec<(i64, u64)> = self
.inner
.node_weights()
.filter_map(|n| match n {
WorldNode::SemanticState { id, valid_from_unix_ms, .. } => {
Some((*valid_from_unix_ms, id.0))
}
_ => None,
})
.collect();
if states.len() <= max_states {
return Vec::new();
}
states.sort_unstable();
let n_evict = states.len() - max_states;
states.truncate(n_evict);
states
.into_iter()
.map(|(_, raw)| {
let id = WorldId(raw);
self.remove_node(id);
id
})
.collect()
}
/// Record a contradiction between two still-live beliefs (ADR-139 §2.3).
/// Neither node is deleted — the disagreement stays queryable.
///
@@ -424,6 +465,56 @@ mod tests {
assert!(g.neighbors(s1).iter().any(|(_, e)| matches!(e, WorldEdge::Contradicts { .. })));
}
#[test]
fn prune_semantic_states_evicts_oldest_only() {
let mut g = WorldGraph::new(GeoRegistration::default());
let room = g.upsert_node(living_room());
let prov = SemanticProvenance {
evidence: vec!["ev:abc".into()],
model_version: "rfenc-1.0".into(),
calibration_version: "cal:uuid".into(),
privacy_decision: "PrivateHome/Allow".into(),
};
let ids: Vec<WorldId> = (0..10)
.map(|t| g.add_semantic_state(format!("s{t}"), 0.9, t, prov.clone(), &[room]))
.collect();
assert_eq!(g.node_count(), 11); // room + 10 beliefs
let evicted = g.prune_semantic_states(3);
// Oldest 7 evicted, in ascending timestamp order.
assert_eq!(evicted, ids[..7].to_vec());
assert_eq!(g.node_count(), 4); // room + 3 newest beliefs
for kept in &ids[7..] {
assert!(g.node(*kept).is_some());
}
// The room (structural node) is never eligible for eviction.
assert!(g.node(room).is_some());
// Below the cap, pruning is a no-op.
assert!(g.prune_semantic_states(3).is_empty());
}
#[test]
fn prune_is_deterministic_for_equal_timestamps() {
let prov = SemanticProvenance {
evidence: vec![],
model_version: "m".into(),
calibration_version: "c".into(),
privacy_decision: "p".into(),
};
let build = || {
let mut g = WorldGraph::new(GeoRegistration::default());
let room = g.upsert_node(living_room());
for _ in 0..6 {
// Identical timestamps: tie-break must fall back to id order.
g.add_semantic_state("s".into(), 0.5, 100, prov.clone(), &[room]);
}
g
};
let mut g1 = build();
let mut g2 = build();
assert_eq!(g1.prune_semantic_states(2), g2.prune_semantic_states(2));
}
#[test]
fn privacy_rollup_suppresses_person_tracks() {
let mut g = WorldGraph::new(GeoRegistration::default());