mirror of
https://github.com/ruvnet/RuView
synced 2026-07-09 15:13:18 +00:00
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>
This commit is contained in:
+6
-5
@@ -8,12 +8,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
- **Mesh partition risk now demotes the privacy class and is witnessed (ADR-032).** The dynamic min-cut guard's `at_risk` signal was advisory-only (it 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`; new `mesh_guard_mut()` exposes risk-threshold tuning. Test proves a forced-risk 3-node cycle demotes PrivateHome Anonymous→Restricted and shifts the witness vs a clean baseline.
|
||||
- **Mesh partition risk now demotes the privacy class and is witnessed (ADR-032).** The dynamic min-cut guard's `at_risk` signal was advisory-only (it 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`; new `mesh_guard_mut()` exposes risk-threshold tuning. Test proves a forced-risk 3-node cycle demotes PrivateHome Anonymous→Restricted and shifts the witness vs a clean *same-topology* baseline (the only delta between the two cycles is the forced risk).
|
||||
|
||||
### Added
|
||||
- **Dynamic min-cut mesh partition guard in the streaming engine (`mesh_guard`).** Maintains a `ruvector-mincut` exact min-cut over the live mesh coupling graph (nodes = sensing nodes, coupling = product of fusion attention weights), surfacing per cycle: the global **cut value** (how close the array is to splitting — a structural measure per-node heuristics miss), the **weak side** (which specific nodes would partition: failure/jamming triage feeding ADR-032 posture), and an **at-risk flag** that counts as a structural event for the drift→recalibration advisor. Surfaced as `TrustedOutput::mesh`. **Measured cost policy** (criterion, 12-node mesh): weights are quantized (1/64) and updates change-gated, so the steady-state cycle does zero graph work (~7.3 µs, ~23× cheaper than building); on any real change a full exact rebuild (~171 µs) is used because one `DynamicMinCut` delete+insert measured ~240 µs — the incremental machinery's overhead targets much larger graphs, so rebuild-on-change is the measured optimum at mesh scale (one-edge case −28% after the policy switch). Degenerate cases fail toward risk: a node with zero coupling is reported as already partitioned (cut 0). 9 mesh-guard tests + an engine-level wiring test; full `process_cycle` with the guard: ~33 µs for 4 nodes (50 ms budget).
|
||||
- **Dynamic min-cut mesh partition guard in the streaming engine (`mesh_guard`).** Maintains a `ruvector-mincut` exact min-cut over the live mesh coupling graph (nodes = sensing nodes, coupling = product of fusion attention weights), surfacing per cycle: the global **cut value** (how close the array is to splitting — a structural measure per-node heuristics miss), the **weak side** (which specific nodes would partition: failure/jamming triage feeding ADR-032 posture), and an **at-risk flag** that counts as a structural event for the drift→recalibration advisor. Surfaced as `TrustedOutput::mesh`. **Measured cost policy** (criterion, 12-node mesh): weights are quantized (1/64; a *nonzero* coupling below one quantum saturates to quantum 1 so quantization never erases a live coupling — without the floor, balanced meshes of ≥ 65 nodes had every ~1/n coupling erased and sat permanently "at risk") and updates change-gated, so the steady-state cycle does zero graph work (~7.3 µs, ~23× cheaper than building); on any real change a full exact rebuild (~171 µs) is used because one `DynamicMinCut` delete+insert measured ~240 µs — the incremental machinery's overhead targets much larger graphs, so rebuild-on-change is the measured optimum at mesh scale (one-edge case −28% after the policy switch). Degenerate cases fail toward risk: a node with zero coupling is reported as already partitioned (cut 0). 9 mesh-guard tests + an engine-level wiring test; full `process_cycle` with the guard: ~33 µs for 4 nodes (50 ms budget).
|
||||
- **`ruview-gamma-clinic` crate (ADR-251) — clinical dashboard + persistent hash-chained RuVector store.** Read-only research/clinical instrumentation over the ADR-250 platform, closing two operational gaps (no durable cohort memory; no clinician surface). **Store** (`store.rs`): append-only JSONL holding profiles, witnessed session summaries, and acceptance verdicts, each line hash-chained `entry_hash = SHA-256(prev ‖ raw record bytes)` so any retroactive edit/deletion/reorder breaks the chain — the store *fails closed* (refuses to open tampered data) and rebuilds the RuVector kNN/clustering layer on open so warm-start survives restarts. (Hashes the exact on-disk bytes via `RawValue`, since serde_json's default float parse is lossy and re-serialization isn't byte-stable.) **Dashboard** (`server.rs` + embedded dependency-free `dashboard.html`): Axum surface with `GET` routes for participants, per-participant frequency-response map + session trend (safety-stop markers), cohort clusters, per-program acceptance verdicts, and a live chain-integrity badge — **strictly read-only** (a test asserts no route accepts POST). Claim discipline inherited: acceptance payloads carry `AcceptanceReport::released_claim` (the gate's output, `NO_CLAIM` on failure), never a raw program claim. `gamma-clinic` binary; `ingest_governor` bridges the live ADR-250 loop into the store (pseudonymous, dedup by witness hash). 20 tests (13 store/lib + 7 server) + 1 doctest; pseudonymity asserted (the `person_id` never reaches disk).
|
||||
- **Opt-in FFT operator for the CIR ISTA solver (8–14× measured).** Φ 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. 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 and requires regenerating any pinned witness). `FftOperator` (rustfft, planned once at construction, scratch reused across the ISTA loop) dispatches inside `ista_solve`; warm-start/Lipschitz stay dense at construction. Measured (criterion, same run): ht20 2.22 ms → 265 µs (**8.4×**), ht40 10.26 ms → 717 µs (**14.3×**); the real HE40 grid (K=484, G=1452) scales further. 3 new tests: FFT↔dense matvec equivalence to float tolerance (ht20 + he40 grids), end-to-end dominant-tap agreement on a single-path frame, and all default configs keep FFT off. New `cir_estimate_fft` bench group. Closes the trust-chain gap where an ~11 KB per-room LoRA adapter (ADR-150 §3.4) could silently change inference without the witness noticing. `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). New `RecalibrationAdvisor` recommends re-running the ADR-135 baseline / refitting the 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` and stored on the sensing-server `AppState` alongside the witness. Bridge plumbing: `EngineBridge::{set_room_adapter, clear_room_adapter}` + live-path test that the adapter id flows into the live witness. Engine 15 tests, bridge 7 tests. *Scope note: 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/`), and a trained RF-encoder checkpoint still does not exist in-tree.*
|
||||
- **Opt-in FFT operator for the CIR ISTA solver (8–14× measured).** Φ 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. 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 and requires regenerating any pinned witness). `FftOperator` (rustfft, planned once at construction, scratch reused across the ISTA loop) dispatches inside `ista_solve`; warm-start/Lipschitz stay dense at construction. Measured (criterion, same run): ht20 2.22 ms → 265 µs (**8.4×**), ht40 10.26 ms → 717 µs (**14.3×**); the real HE40 grid (K=484, G=1452) scales further. 3 new tests: FFT↔dense matvec equivalence to float tolerance (ht20 + he40 grids), end-to-end dominant-tap agreement on a single-path frame, and all default configs keep FFT off. New `cir_estimate_fft` bench group.
|
||||
- **Per-room adapter provenance + drift→recalibration advisor in the streaming engine.** Closes the trust-chain gap where an ~11 KB per-room LoRA adapter (ADR-150 §3.4) could silently change inference without the witness noticing. `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). New `RecalibrationAdvisor` recommends re-running the ADR-135 baseline / refitting the 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` and recorded on the sensing-server's `EngineBridge` alongside the witness. Bridge plumbing: `EngineBridge::{set_room_adapter, clear_room_adapter}` + live-path test that the adapter id flows into the live witness. *Scope note: 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/`), and a trained RF-encoder checkpoint still does not exist in-tree.*
|
||||
- **`esp32-gamma-stim` firmware — ESP32 gamma stimulation actuator (ADR-250 §21 M2 device harness).** The hardware side of `ruview-gamma`: an ESP32 driving an LED + audio flicker at a commanded 36–44 Hz envelope with a hardware emergency stop. Split into a **pure, host-tested safety core** (`main/stim_core.{h,c}` — envelope validation mirroring `SafetyEnvelope::conservative()`, a latched START/STOP/e-stop state machine, exact integer timing math in millihertz so the ±0.1 Hz HIL target is exact, and a line-protocol parser; **15 host tests pass under gcc, no ESP-IDF needed**) and a thin **ESP-IDF binding** (`main/main.c` — GPTimer ISR, LEDC PWM for LED+audio, sync-out GPIO for logic-analyzer capture, e-stop GPIO ISR that kills outputs in microseconds, USB-CDC console). Defense in depth: the device re-enforces the safety envelope independently of the Rust host, so a buggy/compromised host still cannot command an out-of-envelope output. Emits a canonical integer `SESSION {...}` record per run for witness-hash reproduction. Maps 1:1 to the five `hil::verify_hil` targets. Kconfig pin config, 4 MB single-app, radio-off deterministic actuator profile.
|
||||
- **`ruview-gamma` claim-gate invariant + hardware-in-the-loop contract.** Centralized the claim release rule into a single `acceptance::claim_allowed(entrainment, safety, adherence, repeatability)` (strict AND of all four) used by every path, with a test proving every 3-of-4 subset is denied — no path can weaken the gate. New `hil` module: `verify_hil` grades a captured actuator bench measurement against fixed targets (LED frequency ±0.1 Hz, audio-visual sync < 5 ms, stop-signal→actuator-off < 100 ms, session-hash reproducibility 100%, EEG entrainment lift ≥ 20% over fixed 40 Hz) — the next acceptance milestone for a real LED+speaker (e.g. ESP32) actuator; all failure modes fail closed (missing stop measurement, no replay, any hash mismatch). README gains the benchmark table and the "governed personalization engine that refuses to overpromise" positioning. 9 new tests; crate now 97 + 1 doctest; pinned witness preserved.
|
||||
- **`ruview-gamma` generalized to an adaptive sensory neuromodulation platform (ADR-250 §23).** 40 Hz is now one prior in one program, not the product. New `program` module: `NeuroProgram` catalog of 7 use cases (Alzheimer's research, post-stroke cognition, sleep optimization, attention/working-memory, mood/arousal, home wellness, drug+device trial infrastructure), each with its own `SafetyEnvelope`, starting prior, `ObjectiveWeights`, physiological-state gating (sleep permits `Asleep` + near-dark brightness cap; attention requires wakefulness), `EvidenceLevel`, and a single non-disease claim. New `acceptance` module makes the acceptance sentence executable: `AcceptanceHarness` grades a program over ≥3 repeats on entrainment gain, safety-stop rate, adherence, and optimal-frequency repeatability, exposing a `ClaimGate` that returns the program's claim **only if all four criteria pass** — the marketing claim is otherwise unreadable (`NO_CLAIM`). Governor wiring: `enroll_program` (per-program envelope/objective; `enroll` stays the bare Alzheimer's-defaults path so the pinned witness `13cb164c…` is preserved), `program()`, `prior()`, `state_eligible()`. 13 new module tests + 2 platform integration tests (per-program envelope enforced end-to-end — a stimulus valid for Alzheimer's is refused by the sleep program; acceptance gates every catalog program's claim); crate now 88 tests + 1 doctest. Bench: full 3-repeat program grading ~425 µs.
|
||||
@@ -27,8 +28,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- **RF tomography solver hoisting** — ISTA gradient buffer no longer allocated inside the 100-iteration loop, and the Frobenius Lipschitz bound moved from per-`reconstruct` to construction (`ruvsense/tomography.rs`). Bit-identical results.
|
||||
|
||||
### Added
|
||||
- **Falsifiable occupancy benchmark (`wifi-densepose-train::occupancy_bench`).** Makes the presence/person-count "beyond SOTA" claim falsifiable in code instead of aspirational (the unfalsifiability gap from the beyond-SOTA system review). Grades predictions vs ground truth and gates a SOTA claim behind one `claim_allowed` invariant requiring all of: `DataProvenance::Measured` (synthetic/mock is scorable but **never claimable** — anti-mock-contamination per the CLAUDE.md Kconfig-bug lesson), a leak-free `EvalSplit` (refuses any split where a subject *or* environment id appears in both train and test — subject leakage / per-environment overfitting), `n_test ≥ min`, presence-F1 **bootstrap-CI lower bound** (deterministic seeded splitmix64) clearing the threshold, and 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. 10 tests cover each refusal path.
|
||||
- **Live trust path: sensing-server now routes real frames through the governed `StreamingEngine`.** Previously the live server ran only the *bare* `MultistaticFuser` (fused amplitudes, no trust control plane), while the privacy/provenance/witness engine (ADR-135..146) ran only on synthetic in-test frames — the gap called out in ADR-136 §8 and the beyond-SOTA system review (the privacy control plane was bypassable). New `engine_bridge` module drives `StreamingEngine::process_cycle` from the server's live `NodeState` map (reusing the existing `NodeState → MultiBandCsiFrame` conversion), lazily wiring each node as a WorldGraph sensor and bounding belief growth via the retention cap. Wired additively into both live ESP32/WiFi fusion sites in `main.rs` (split-borrow off the write guard; does not alter person-count behavior) and stores the latest BLAKE3 witness 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 provenance, determinism, idempotent node registration, retention bound, privacy-mode propagation); sensing-server suite 430+128 green.
|
||||
- **Falsifiable occupancy benchmark (`wifi-densepose-train::occupancy_bench`).** Makes the presence/person-count "beyond SOTA" claim falsifiable in code instead of aspirational (the unfalsifiability gap from the beyond-SOTA system review). Grades predictions vs ground truth and gates a SOTA claim behind one `claim_allowed` invariant requiring all of: `DataProvenance::Measured` (synthetic/mock is scorable but **never claimable** — anti-mock-contamination per the CLAUDE.md Kconfig-bug lesson), a leak-free `EvalSplit` (refuses any split where a subject *or* environment id appears in both train and test — subject leakage / per-environment overfitting), `n_test ≥ min`, a **non-degenerate test set** (both truth classes represented: present-rate ≥ `min_positive_rate` and ≥ 1 absent sample — an all-absent set plus an always-absent predictor cannot release a claim; vacuous F1 scores 0.0, never 1.0), presence-F1 **bootstrap-CI lower bound** (deterministic seeded splitmix64) clearing the threshold, and 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. 12 tests cover each refusal path, including the point-above/CI-below case (claim withheld on the CI lower bound even when the point estimate clears the threshold).
|
||||
- **Live trust path: sensing-server routes real frames through the governed `StreamingEngine` (parallel governed path with partial output gating).** Previously the live server ran only the *bare* `MultistaticFuser` (fused amplitudes, no trust control plane), while the privacy/provenance/witness engine (ADR-135..146) ran only on synthetic in-test frames — the gap called out in ADR-136 §8 and the beyond-SOTA system review. New `engine_bridge` module drives `StreamingEngine::process_cycle` from the server's live `NodeState` map (reusing the existing `NodeState → MultiBandCsiFrame` conversion), lazily wiring each node as a WorldGraph sensor and bounding belief growth via the retention cap; every *governed belief* carries evidence + model + calibration + privacy decision and a deterministic witness. **Honest scope:** the engine runs alongside (not instead of) the bare fusion path that feeds the live `SensingUpdate`. What its decision gates on the wire today: a cycle emitted at class `Restricted` (base mode or contradiction/mesh-risk demotion) suppresses the per-node raw amplitude vectors from the live publish — the same field mapping `wifi-densepose-bfld`'s privacy gate applies at `Restricted`; gating the remaining derived outputs (person count, classification, signal field) is tracked as a follow-up. Trust state is no longer write-only: the latest witness, effective privacy class, demotion flag, recalibration recommendation, and an engine-error counter are readable on `GET /api/v1/status`, and engine errors are counted + rate-limit logged instead of silently swallowed (`EngineBridge::observe_cycle`). Adds `wifi-densepose-engine/-worldgraph/-bfld/-geo` deps. Bridge tests cover witnessed belief with provenance, determinism, idempotent node registration, retention bound, privacy-mode propagation, trust-state recording, the error-counter path, and Restricted-class raw-output suppression.
|
||||
|
||||
### Fixed
|
||||
- **`wifi-densepose-mat` standalone `--no-default-features` build (101 errors → 0).** `pub mod api` was unconditional while its only dependency, serde, is optional behind the `api` feature — so any build without default features failed with unresolved serde imports (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 feature combos compile: bare `--no-default-features`, `--no-default-features --features api`, and full default (177 tests pass). `StreamingEngine::process_cycle` appended one `SemanticState` belief per cycle with no eviction — ~1.7M nodes/day at 20 Hz (identified in `docs/research/ruview-beyond-sota/04-optimization-roadmap.md`). Added `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) never eligible — and wired it into the engine after each belief append (`StreamingEngine::DEFAULT_SEMANTIC_RETENTION` = 7,200 ≈ 6 min at 20 Hz; tunable via `set_semantic_retention`). The WorldGraph holds *current* beliefs; durable history is the recorder's job, so no audit data is lost. 3 new tests (bounded growth end-to-end, oldest-only eviction, deterministic tie-break).
|
||||
|
||||
@@ -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());
|
||||
@@ -653,6 +669,15 @@ pub enum CanonicalDecodeError {
|
||||
/// 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.
|
||||
@@ -709,8 +734,10 @@ impl CsiFrame {
|
||||
///
|
||||
/// # Errors
|
||||
/// [`CanonicalDecodeError`] on truncation, bad discriminants, non-UTF-8
|
||||
/// device id, shape/payload disagreement, or trailing bytes — every
|
||||
/// malformed input fails closed.
|
||||
/// 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 };
|
||||
|
||||
@@ -740,7 +767,13 @@ impl CsiFrame {
|
||||
let spacing_mm = match c.u8()? {
|
||||
1 => Some(c.f32()?),
|
||||
0 => {
|
||||
c.take(4)?; // reserved zero bytes
|
||||
// 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 => {
|
||||
@@ -1568,6 +1601,54 @@ mod tests {
|
||||
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]
|
||||
|
||||
@@ -799,10 +799,21 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 ≤ (n−1)/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 where fusion
|
||||
/// down-weights one node is flagged with that node as the weak side, and
|
||||
/// the structural event feeds the recalibration advisor immediately.
|
||||
/// 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();
|
||||
@@ -816,56 +827,62 @@ mod tests {
|
||||
assert!(!mesh.at_risk);
|
||||
assert!(!out.recalibration_recommended);
|
||||
|
||||
// 3-node mesh, one node with wildly different amplitude scale: the
|
||||
// fuser down-weights it -> weak coupling -> partition risk -> the
|
||||
// advisor recommends recalibration on the structural event.
|
||||
// 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_scaled(2, 10_000_002, 56, 60.0),
|
||||
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");
|
||||
if m3.at_risk {
|
||||
assert_eq!(m3.weak_side, vec![2]);
|
||||
assert!(out3.recalibration_recommended);
|
||||
}
|
||||
// Whatever the fuser decided, the report is internally consistent.
|
||||
assert!(m3.cut_value >= 0.0);
|
||||
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.
|
||||
/// Synthetic injection (via a unit hook) so the test does not depend on the
|
||||
/// fuser's exact weighting.
|
||||
/// 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 frames = [node_frame(0, 1000, 56), node_frame(1, 1001, 56)];
|
||||
|
||||
// Baseline: a clean 2-node cycle is not demoted (PrivateHome → Anonymous).
|
||||
let (mut e1, r1) = engine();
|
||||
let base = e1.process_cycle(&frames, cal, r1, 5_000).unwrap();
|
||||
assert!(!base.demoted);
|
||||
assert_eq!(base.effective_class, PrivacyClass::Anonymous);
|
||||
|
||||
// Force the mesh guard to report risk by setting an impossible risk
|
||||
// threshold (any finite cut is ≤ it) on a ≥3-node mesh.
|
||||
let (mut e2, r2) = engine();
|
||||
e2.mesh_guard_mut().risk_threshold = f64::INFINITY;
|
||||
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);
|
||||
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
|
||||
|
||||
@@ -17,7 +17,9 @@
|
||||
//!
|
||||
//! ## Cost model (the optimization)
|
||||
//!
|
||||
//! Weights are quantized (default 1/64) and updates are **change-gated**: an
|
||||
//! 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)
|
||||
@@ -51,6 +53,14 @@ pub struct MeshGuard {
|
||||
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,
|
||||
@@ -74,8 +84,17 @@ impl Default for MeshGuard {
|
||||
|
||||
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 {
|
||||
(w.max(0.0) / self.weight_quantum).floor() as 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
|
||||
@@ -285,6 +304,51 @@ mod tests {
|
||||
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() {
|
||||
|
||||
@@ -15,7 +15,12 @@ readme = "README.md"
|
||||
default = ["std", "api", "ruvector"]
|
||||
ruvector = ["dep:ruvector-solver", "dep:ruvector-temporal-tensor"]
|
||||
std = []
|
||||
api = ["dep:serde", "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"]
|
||||
@@ -30,13 +35,15 @@ 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"
|
||||
|
||||
@@ -54,9 +54,10 @@ wifi-densepose-signal = { version = "0.3.1", path = "../wifi-densepose-signal",
|
||||
wifi-densepose-hardware = { version = "0.3.0", path = "../wifi-densepose-hardware" }
|
||||
|
||||
# Governed streaming engine (ADR-135..146): fusion + privacy demotion +
|
||||
# WorldGraph belief + deterministic witness. Wiring the live server data through
|
||||
# this is what makes the trust/privacy control plane non-bypassable (the live
|
||||
# 20 Hz path) — see engine_bridge.rs.
|
||||
# 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 }
|
||||
|
||||
@@ -6,10 +6,25 @@
|
||||
//! 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 published belief carries
|
||||
//! evidence + model + calibration + privacy decision and a BLAKE3 witness —
|
||||
//! making the privacy control plane non-bypassable on the live 20 Hz path
|
||||
//! (the gap called out in ADR-136 §8 and the beyond-SOTA system review).
|
||||
//! [`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
|
||||
@@ -17,8 +32,9 @@
|
||||
//! [`TrustedOutput`] witness.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use wifi_densepose_bfld::PrivacyMode;
|
||||
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;
|
||||
@@ -27,6 +43,11 @@ 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 {
|
||||
@@ -37,6 +58,22 @@ pub struct EngineBridge {
|
||||
/// 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 {
|
||||
@@ -50,6 +87,12 @@ impl EngineBridge {
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,6 +165,82 @@ impl EngineBridge {
|
||||
.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)]
|
||||
@@ -257,6 +376,81 @@ mod tests {
|
||||
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");
|
||||
|
||||
@@ -990,15 +990,12 @@ struct AppStateInner {
|
||||
/// 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. Additive — does not
|
||||
/// affect person-count behavior; produces the auditable belief + witness.
|
||||
/// 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,
|
||||
/// Witness of the most recent governed trust cycle (BLAKE3), for audit/UI.
|
||||
pub(crate) last_trust_witness: Option<[u8; 32]>,
|
||||
/// Latest drift→recalibration recommendation from the governed engine
|
||||
/// (ADR-135 → ADR-150 §3.4): sustained low coherence or a change-point
|
||||
/// suggests re-running the empty-room baseline / refitting the room adapter.
|
||||
pub(crate) recalibration_recommended: bool,
|
||||
/// SVD-based room field model for eigenvalue person counting (None until calibration).
|
||||
field_model: Option<FieldModel>,
|
||||
// ── ADR-044 §5.2: adaptive rolling-p95 normalization ─────────────────────
|
||||
@@ -3745,11 +3742,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(),
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -4999,20 +5016,17 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) {
|
||||
|
||||
// Governed trust cycle (ADR-135..146): run the same live
|
||||
// frames through the privacy/provenance/witness control
|
||||
// plane. Split-borrow the two distinct fields off the guard.
|
||||
// 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);
|
||||
if let Some(Ok(trust)) = sref
|
||||
.engine_bridge
|
||||
.process_cycle_from_states(&sref.node_states, now_ms)
|
||||
{
|
||||
sref.last_trust_witness = Some(trust.witness);
|
||||
sref.recalibration_recommended = trust.recalibration_recommended;
|
||||
}
|
||||
sref.engine_bridge.observe_cycle(&sref.node_states, now_ms);
|
||||
}
|
||||
|
||||
// Feed field model calibration if active (use per-node history for ESP32).
|
||||
@@ -5441,20 +5455,17 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) {
|
||||
|
||||
// Governed trust cycle (ADR-135..146): run the same live
|
||||
// frames through the privacy/provenance/witness control
|
||||
// plane. Split-borrow the two distinct fields off the guard.
|
||||
// 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);
|
||||
if let Some(Ok(trust)) = sref
|
||||
.engine_bridge
|
||||
.process_cycle_from_states(&sref.node_states, now_ms)
|
||||
{
|
||||
sref.last_trust_witness = Some(trust.witness);
|
||||
sref.recalibration_recommended = trust.recalibration_recommended;
|
||||
}
|
||||
sref.engine_bridge.observe_cycle(&sref.node_states, now_ms);
|
||||
}
|
||||
|
||||
// Feed field model calibration if active (use per-node history for ESP32).
|
||||
@@ -5468,7 +5479,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()
|
||||
@@ -5480,12 +5499,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(),
|
||||
})
|
||||
@@ -6774,8 +6800,6 @@ async fn main() {
|
||||
"default",
|
||||
"Default Room",
|
||||
),
|
||||
last_trust_witness: None,
|
||||
recalibration_recommended: false,
|
||||
field_model: if args.calibrate {
|
||||
info!("Field model calibration enabled — room should be empty during startup");
|
||||
FieldModel::new(field_bridge::single_link_config()).ok()
|
||||
|
||||
@@ -21,6 +21,11 @@
|
||||
//! 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
|
||||
@@ -154,6 +159,13 @@ pub struct BenchmarkCriteria {
|
||||
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.
|
||||
@@ -166,6 +178,7 @@ impl Default for BenchmarkCriteria {
|
||||
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,
|
||||
}
|
||||
@@ -199,7 +212,10 @@ pub struct BenchmarkReport {
|
||||
pub count_pass: bool,
|
||||
/// Test set is large enough to grade.
|
||||
pub sample_size_pass: bool,
|
||||
/// All five criteria pass.
|
||||
/// 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,
|
||||
@@ -213,17 +229,24 @@ impl BenchmarkReport {
|
||||
}
|
||||
|
||||
/// **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, and
|
||||
/// both the (CI-lower) presence F1 and the count MAE clear their thresholds.
|
||||
/// 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 && presence_pass && count_pass
|
||||
provenance_pass
|
||||
&& split_pass
|
||||
&& sample_size_pass
|
||||
&& class_balance_pass
|
||||
&& presence_pass
|
||||
&& count_pass
|
||||
}
|
||||
|
||||
/// Grade the test split of `samples` under `criteria`.
|
||||
@@ -249,7 +272,11 @@ pub fn evaluate(
|
||||
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,
|
||||
@@ -282,6 +309,17 @@ pub fn evaluate(
|
||||
|
||||
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;
|
||||
@@ -289,6 +327,7 @@ pub fn evaluate(
|
||||
provenance_pass,
|
||||
split_pass,
|
||||
sample_size_pass,
|
||||
class_balance_pass,
|
||||
presence_pass,
|
||||
count_pass,
|
||||
);
|
||||
@@ -315,6 +354,7 @@ pub fn evaluate(
|
||||
presence_pass,
|
||||
count_pass,
|
||||
sample_size_pass,
|
||||
class_balance_pass,
|
||||
overall_pass,
|
||||
released_claim,
|
||||
}
|
||||
@@ -323,9 +363,12 @@ pub fn evaluate(
|
||||
fn f1_from_confusion(tp: u64, fp: u64, fn_: u64) -> f64 {
|
||||
let denom = 2 * tp + fp + fn_;
|
||||
if denom == 0 {
|
||||
// No positives anywhere: define F1 = 1.0 only if there were also no
|
||||
// predicted/actual positives at all (vacuous), else 0.0.
|
||||
return if fp == 0 && fn_ == 0 { 1.0 } else { 0.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
|
||||
}
|
||||
@@ -479,29 +522,101 @@ mod tests {
|
||||
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() {
|
||||
// A predictor that is right most of the time but with enough errors that
|
||||
// the bootstrap LOWER bound dips below the 0.9 threshold even if the
|
||||
// point F1 is near it.
|
||||
let mut samples = Vec::new();
|
||||
for i in 0..40 {
|
||||
samples.push(sample(&format!("train-{i}"), &format!("te-{i}"), (true, 1), (true, 1)));
|
||||
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 {
|
||||
// ~15% false negatives in test
|
||||
let correct = i % 7 != 0;
|
||||
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}"),
|
||||
(true, 1),
|
||||
(correct, 1),
|
||||
(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());
|
||||
// CI lower bound is below the point estimate.
|
||||
assert!(r.presence_f1_ci.0 <= r.presence_f1);
|
||||
// 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]
|
||||
@@ -518,29 +633,36 @@ mod tests {
|
||||
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 {
|
||||
// presence perfect, but count is always off by 2 -> MAE 2.0 > 0.5
|
||||
samples.push(sample(&format!("ts-{i}"), &format!("ev-{i}"), (true, 1), (true, 3)));
|
||||
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_five() {
|
||||
assert!(claim_allowed(true, true, true, true, true));
|
||||
let one_false = [
|
||||
(false, true, true, true, true),
|
||||
(true, false, true, true, true),
|
||||
(true, true, false, true, true),
|
||||
(true, true, true, false, true),
|
||||
(true, true, true, true, false),
|
||||
];
|
||||
for (a, b, c, d, e) in one_false {
|
||||
assert!(!claim_allowed(a, b, c, d, e));
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user