From c3b16b70d03aa879045bfe148805d75df89f85a3 Mon Sep 17 00:00:00 2001 From: ruv Date: Thu, 11 Jun 2026 15:26:18 -0400 Subject: [PATCH] =?UTF-8?q?fix:=20public-PR=20review=20findings=20?= =?UTF-8?q?=E2=80=94=20privacy-path=20honesty,=20gate=20holes,=20mesh-guar?= =?UTF-8?q?d=20cliff?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- CHANGELOG.md | 11 +- v2/crates/wifi-densepose-core/src/types.rs | 89 +++++++- v2/crates/wifi-densepose-engine/src/lib.rs | 73 ++++--- .../wifi-densepose-engine/src/mesh_guard.rs | 68 +++++- v2/crates/wifi-densepose-mat/Cargo.toml | 17 +- .../wifi-densepose-sensing-server/Cargo.toml | 7 +- .../src/engine_bridge.rs | 204 +++++++++++++++++- .../wifi-densepose-sensing-server/src/main.rs | 90 +++++--- .../src/occupancy_bench.rs | 182 +++++++++++++--- 9 files changed, 626 insertions(+), 115 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6afee156..7f58414f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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:`) — 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:`) — 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). diff --git a/v2/crates/wifi-densepose-core/src/types.rs b/v2/crates/wifi-densepose-core/src/types.rs index f00bcf5d..9e557124 100644 --- a/v2/crates/wifi-densepose-core/src/types.rs +++ b/v2/crates/wifi-densepose-core/src/types.rs @@ -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 { 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 { 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] diff --git a/v2/crates/wifi-densepose-engine/src/lib.rs b/v2/crates/wifi-densepose-engine/src/lib.rs index 0b4a4b83..c9f965d9 100644 --- a/v2/crates/wifi-densepose-engine/src/lib.rs +++ b/v2/crates/wifi-densepose-engine/src/lib.rs @@ -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 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 diff --git a/v2/crates/wifi-densepose-engine/src/mesh_guard.rs b/v2/crates/wifi-densepose-engine/src/mesh_guard.rs index bd8beeda..61d1555c 100644 --- a/v2/crates/wifi-densepose-engine/src/mesh_guard.rs +++ b/v2/crates/wifi-densepose-engine/src/mesh_guard.rs @@ -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 = (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() { diff --git a/v2/crates/wifi-densepose-mat/Cargo.toml b/v2/crates/wifi-densepose-mat/Cargo.toml index d4ede0aa..052c9dd8 100644 --- a/v2/crates/wifi-densepose-mat/Cargo.toml +++ b/v2/crates/wifi-densepose-mat/Cargo.toml @@ -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" diff --git a/v2/crates/wifi-densepose-sensing-server/Cargo.toml b/v2/crates/wifi-densepose-sensing-server/Cargo.toml index 42dfc7f8..2ca98f3e 100644 --- a/v2/crates/wifi-densepose-sensing-server/Cargo.toml +++ b/v2/crates/wifi-densepose-sensing-server/Cargo.toml @@ -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 } diff --git a/v2/crates/wifi-densepose-sensing-server/src/engine_bridge.rs b/v2/crates/wifi-densepose-sensing-server/src/engine_bridge.rs index 0a174d5a..cc4c45c0 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/engine_bridge.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/engine_bridge.rs @@ -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, + /// 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, } 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, + now_ms: i64, + ) -> Option { + 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 { + 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"); diff --git a/v2/crates/wifi-densepose-sensing-server/src/main.rs b/v2/crates/wifi-densepose-sensing-server/src/main.rs index be543804..07e1e1a9 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/main.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/main.rs @@ -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, // ── ADR-044 §5.2: adaptive rolling-p95 normalization ───────────────────── @@ -3745,11 +3742,31 @@ async fn health_live(State(state): State) -> Json 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) -> Json { 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 = 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() diff --git a/v2/crates/wifi-densepose-train/src/occupancy_bench.rs b/v2/crates/wifi-densepose-train/src/occupancy_bench.rs index 13e37a2b..be7ae1f5 100644 --- a/v2/crates/wifi-densepose-train/src/occupancy_bench.rs +++ b/v2/crates/wifi-densepose-train/src/occupancy_bench.rs @@ -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 = (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" + ); } } }