mirror of
https://github.com/ruvnet/RuView
synced 2026-08-09 20:21:43 +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:
+5
-5
@@ -8,12 +8,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Changed
|
### 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
|
### 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).
|
||||||
- **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.
|
- **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 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.*
|
- **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.*
|
||||||
- **RuView beyond-SOTA research series** (`docs/research/ruview-beyond-sota/`, 6 docs) — research-swarm output defining the beyond-SOTA bar and the path to it: system capability audit (role→crate maturity matrix, gap analysis, risk register), web-verified 2026 SOTA landscape per capability axis (incl. ratified IEEE 802.11bf-2025), 8-pillar target architecture on the ADR-136 contract spine (no rewrite), 6-layer benchmark/validation methodology (all 15 criterion bench targets inventoried; ADR-149 statistical protocol), and a determinism-safe optimization roadmap. Includes session validation evidence: 2,797 workspace tests / 0 failed, Python proof PASS (bit-exact), paired pre/post criterion runs.
|
- **RuView beyond-SOTA research series** (`docs/research/ruview-beyond-sota/`, 6 docs) — research-swarm output defining the beyond-SOTA bar and the path to it: system capability audit (role→crate maturity matrix, gap analysis, risk register), web-verified 2026 SOTA landscape per capability axis (incl. ratified IEEE 802.11bf-2025), 8-pillar target architecture on the ADR-136 contract spine (no rewrite), 6-layer benchmark/validation methodology (all 15 criterion bench targets inventoried; ADR-149 statistical protocol), and a determinism-safe optimization roadmap. Includes session validation evidence: 2,797 workspace tests / 0 failed, Python proof PASS (bit-exact), paired pre/post criterion runs.
|
||||||
|
|
||||||
### Performance
|
### Performance
|
||||||
@@ -21,8 +21,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.
|
- **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
|
### 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.
|
- **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 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.
|
- **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
|
### 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).
|
- **`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
|
/// (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
|
/// 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.
|
/// ‖ 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> {
|
fn to_canonical_bytes(&self) -> Vec<u8> {
|
||||||
let m = &self.metadata;
|
let m = &self.metadata;
|
||||||
// 16 (id) + ~48 (meta) + 8 (shape) + 16 * n_samples
|
// 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.noise_floor_dbm.to_le_bytes());
|
||||||
b.extend_from_slice(&m.sequence_number.to_le_bytes());
|
b.extend_from_slice(&m.sequence_number.to_le_bytes());
|
||||||
match m.calibration_id {
|
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]),
|
None => b.extend_from_slice(&[0u8; 16]),
|
||||||
}
|
}
|
||||||
b.extend_from_slice(&m.model_id.to_le_bytes());
|
b.extend_from_slice(&m.model_id.to_le_bytes());
|
||||||
@@ -653,6 +669,15 @@ pub enum CanonicalDecodeError {
|
|||||||
/// Trailing bytes after the declared payload.
|
/// Trailing bytes after the declared payload.
|
||||||
#[error("{0} trailing bytes after payload")]
|
#[error("{0} trailing bytes after payload")]
|
||||||
TrailingBytes(usize),
|
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.
|
/// Byte cursor for the canonical layout.
|
||||||
@@ -709,8 +734,10 @@ impl CsiFrame {
|
|||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
/// [`CanonicalDecodeError`] on truncation, bad discriminants, non-UTF-8
|
/// [`CanonicalDecodeError`] on truncation, bad discriminants, non-UTF-8
|
||||||
/// device id, shape/payload disagreement, or trailing bytes — every
|
/// device id, nonzero reserved bytes, shape/payload disagreement, or
|
||||||
/// malformed input fails closed.
|
/// 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> {
|
pub fn from_canonical_bytes(bytes: &[u8]) -> Result<Self, CanonicalDecodeError> {
|
||||||
let mut c = Cursor { b: bytes, at: 0 };
|
let mut c = Cursor { b: bytes, at: 0 };
|
||||||
|
|
||||||
@@ -740,7 +767,13 @@ impl CsiFrame {
|
|||||||
let spacing_mm = match c.u8()? {
|
let spacing_mm = match c.u8()? {
|
||||||
1 => Some(c.f32()?),
|
1 => Some(c.f32()?),
|
||||||
0 => {
|
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
|
None
|
||||||
}
|
}
|
||||||
v => {
|
v => {
|
||||||
@@ -1568,6 +1601,54 @@ mod tests {
|
|||||||
assert_eq!(replayed.metadata.calibration_id, None);
|
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.
|
/// AC3 — `serde(default)` forward-read of pre-ADR-136 metadata JSON.
|
||||||
#[cfg(feature = "serde")]
|
#[cfg(feature = "serde")]
|
||||||
#[test]
|
#[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)
|
/// 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
|
/// but never flags risk (min_nodes=3); a 3-node mesh whose cut value
|
||||||
/// down-weights one node is flagged with that node as the weak side, and
|
/// *deterministically* falls at or below the configured risk threshold
|
||||||
/// the structural event feeds the recalibration advisor immediately.
|
/// (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]
|
#[test]
|
||||||
fn mesh_partition_risk_feeds_recalibration() {
|
fn mesh_partition_risk_feeds_recalibration() {
|
||||||
let (mut e, room) = engine();
|
let (mut e, room) = engine();
|
||||||
@@ -816,56 +827,62 @@ mod tests {
|
|||||||
assert!(!mesh.at_risk);
|
assert!(!mesh.at_risk);
|
||||||
assert!(!out.recalibration_recommended);
|
assert!(!out.recalibration_recommended);
|
||||||
|
|
||||||
// 3-node mesh, one node with wildly different amplitude scale: the
|
// 3-node mesh with the operator risk threshold set to the provable
|
||||||
// fuser down-weights it -> weak coupling -> partition risk -> the
|
// cut upper bound: the crossing is deterministic regardless of the
|
||||||
// advisor recommends recalibration on the structural event.
|
// fuser's exact weighting.
|
||||||
|
e.mesh_guard_mut().risk_threshold = max_coupling_mass(3);
|
||||||
let frames = [
|
let frames = [
|
||||||
node_frame(0, 10_000_000, 56),
|
node_frame(0, 10_000_000, 56),
|
||||||
node_frame(1, 10_000_001, 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 out3 = e.process_cycle(&frames, cal, room, 2).unwrap();
|
||||||
let m3 = out3.mesh.expect("3-node mesh reports");
|
let m3 = out3.mesh.expect("3-node mesh reports");
|
||||||
if m3.at_risk {
|
assert!(m3.at_risk, "cut ≤ threshold must flag partition risk");
|
||||||
assert_eq!(m3.weak_side, vec![2]);
|
assert!(
|
||||||
assert!(out3.recalibration_recommended);
|
out3.recalibration_recommended,
|
||||||
}
|
"mesh risk is a structural event — the advisor must fire immediately, no streak"
|
||||||
// Whatever the fuser decided, the report is internally consistent.
|
);
|
||||||
assert!(m3.cut_value >= 0.0);
|
assert!(m3.cut_value.is_finite() && m3.cut_value >= 0.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mesh partition risk demotes the privacy class and shifts the witness —
|
/// Mesh partition risk demotes the privacy class and shifts the witness —
|
||||||
/// a fragmenting array makes the fused belief less trustworthy, so it is
|
/// a fragmenting array makes the fused belief less trustworthy, so it is
|
||||||
/// emitted at a more restricted class, and that demotion is auditable.
|
/// 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
|
/// Both cycles use the *same 3-node topology and frames*; the engines
|
||||||
/// fuser's exact weighting.
|
/// differ only in the forced mesh risk, so the witness delta is
|
||||||
|
/// attributable to the risk demotion alone (review finding 4).
|
||||||
#[test]
|
#[test]
|
||||||
fn mesh_risk_demotes_privacy_and_shifts_witness() {
|
fn mesh_risk_demotes_privacy_and_shifts_witness() {
|
||||||
let cal = CalibrationId(8);
|
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 = [
|
let frames3 = [
|
||||||
node_frame(0, 1000, 56),
|
node_frame(0, 1000, 56),
|
||||||
node_frame(1, 1001, 56),
|
node_frame(1, 1001, 56),
|
||||||
node_frame(2, 1002, 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();
|
let risky = e2.process_cycle(&frames3, cal, r2, 5_000).unwrap();
|
||||||
assert!(risky.mesh.as_ref().unwrap().at_risk);
|
assert!(risky.mesh.as_ref().unwrap().at_risk);
|
||||||
assert!(risky.demoted, "mesh risk must demote");
|
assert!(risky.demoted, "mesh risk must demote");
|
||||||
// PrivateHome base Anonymous(2) → demoted to Restricted(3).
|
// PrivateHome base Anonymous(2) → demoted to Restricted(3).
|
||||||
assert_eq!(risky.effective_class, PrivacyClass::Restricted);
|
assert_eq!(risky.effective_class, PrivacyClass::Restricted);
|
||||||
assert!(risky.provenance.privacy_decision.contains("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
|
/// WorldGraph belief retention: the live loop appends one SemanticState per
|
||||||
|
|||||||
@@ -17,7 +17,9 @@
|
|||||||
//!
|
//!
|
||||||
//! ## Cost model (the optimization)
|
//! ## 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
|
//! edge is touched only when its quantized weight actually moves, so the
|
||||||
//! steady-state cycle applies *zero* graph updates and reuses the cached cut —
|
//! steady-state cycle applies *zero* graph updates and reuses the cached cut —
|
||||||
//! O(active-changes) per cycle, not O(n²) rebuilds. The exact (deterministic)
|
//! O(active-changes) per cycle, not O(n²) rebuilds. The exact (deterministic)
|
||||||
@@ -51,6 +53,14 @@ pub struct MeshGuard {
|
|||||||
edges: BTreeMap<(u8, u8), i64>,
|
edges: BTreeMap<(u8, u8), i64>,
|
||||||
/// Weight quantum: weights are snapped to multiples of this before
|
/// Weight quantum: weights are snapped to multiples of this before
|
||||||
/// comparison/installation, gating out sub-quantum jitter.
|
/// 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,
|
pub weight_quantum: f64,
|
||||||
/// Cut value at or below which the mesh counts as at partition risk.
|
/// Cut value at or below which the mesh counts as at partition risk.
|
||||||
pub risk_threshold: f64,
|
pub risk_threshold: f64,
|
||||||
@@ -74,8 +84,17 @@ impl Default for MeshGuard {
|
|||||||
|
|
||||||
impl MeshGuard {
|
impl MeshGuard {
|
||||||
/// Quantize a raw weight to the guard's grid (floor; weights are ≥ 0).
|
/// 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 {
|
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
|
/// 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);
|
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.
|
/// A fully partitioned mesh (zero coupling to one node) reports cut 0.
|
||||||
#[test]
|
#[test]
|
||||||
fn disconnected_mesh_is_cut_zero() {
|
fn disconnected_mesh_is_cut_zero() {
|
||||||
|
|||||||
@@ -15,12 +15,17 @@ readme = "README.md"
|
|||||||
default = ["std", "api", "ruvector"]
|
default = ["std", "api", "ruvector"]
|
||||||
ruvector = ["dep:ruvector-solver", "dep:ruvector-temporal-tensor"]
|
ruvector = ["dep:ruvector-solver", "dep:ruvector-temporal-tensor"]
|
||||||
std = []
|
std = []
|
||||||
api = ["chrono/serde", "geo/use-serde"]
|
# REST/WebSocket surface. Pulls the web stack (axum, futures-util) only when
|
||||||
|
# enabled, and enables the `serde` FEATURE (not just `dep:serde`) so the
|
||||||
|
# `cfg_attr(feature = "serde", ...)` derives on domain types are actually
|
||||||
|
# active when the API is on (review finding 5: `api = ["dep:serde"]` enabled
|
||||||
|
# the dependency but left every `feature = "serde"` cfg dead).
|
||||||
|
api = ["serde", "dep:axum", "dep:futures-util"]
|
||||||
portable = ["low-power"]
|
portable = ["low-power"]
|
||||||
low-power = []
|
low-power = []
|
||||||
distributed = ["tokio/sync"]
|
distributed = ["tokio/sync"]
|
||||||
drone = ["distributed"]
|
drone = ["distributed"]
|
||||||
serde = ["chrono/serde", "geo/use-serde"]
|
serde = ["dep:serde", "chrono/serde", "geo/use-serde"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
# Workspace dependencies
|
# Workspace dependencies
|
||||||
@@ -30,20 +35,22 @@ wifi-densepose-nn = { version = "0.3.0", path = "../wifi-densepose-nn" }
|
|||||||
ruvector-solver = { workspace = true, optional = true }
|
ruvector-solver = { workspace = true, optional = true }
|
||||||
ruvector-temporal-tensor = { 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"] }
|
tokio = { version = "1.35", features = ["rt", "sync", "time"] }
|
||||||
async-trait = "0.1"
|
async-trait = "0.1"
|
||||||
|
|
||||||
# Web framework (REST API)
|
# Web framework (REST API) — only compiled with the `api` feature.
|
||||||
axum = { version = "0.7", features = ["ws"] }
|
axum = { version = "0.7", features = ["ws"], optional = true }
|
||||||
futures-util = "0.3"
|
futures-util = { version = "0.3", optional = true }
|
||||||
|
|
||||||
# Error handling
|
# Error handling
|
||||||
thiserror = "2.0"
|
thiserror = "2.0"
|
||||||
anyhow = "1.0"
|
anyhow = "1.0"
|
||||||
|
|
||||||
# Serialization
|
# Serialization
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"], optional = true }
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
|
|
||||||
# Time handling
|
# Time handling
|
||||||
|
|||||||
@@ -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" }
|
wifi-densepose-hardware = { version = "0.3.0", path = "../wifi-densepose-hardware" }
|
||||||
|
|
||||||
# Governed streaming engine (ADR-135..146): fusion + privacy demotion +
|
# Governed streaming engine (ADR-135..146): fusion + privacy demotion +
|
||||||
# WorldGraph belief + deterministic witness. Wiring the live server data through
|
# WorldGraph belief + deterministic witness. The live server data runs through
|
||||||
# this is what makes the trust/privacy control plane non-bypassable (the live
|
# this as a governed path whose Restricted-class decision strips per-node raw
|
||||||
# 20 Hz path) — see engine_bridge.rs.
|
# 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-engine = { version = "0.3.0", path = "../wifi-densepose-engine" }
|
||||||
wifi-densepose-worldgraph = { version = "0.3.0", path = "../wifi-densepose-worldgraph" }
|
wifi-densepose-worldgraph = { version = "0.3.0", path = "../wifi-densepose-worldgraph" }
|
||||||
wifi-densepose-bfld = { version = "0.3.1", path = "../wifi-densepose-bfld", default-features = false }
|
wifi-densepose-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
|
//! but skips the trust control plane: privacy demotion on contradiction, the
|
||||||
//! WorldGraph belief with mandatory provenance, and the deterministic witness
|
//! WorldGraph belief with mandatory provenance, and the deterministic witness
|
||||||
//! (ADR-135..146). This bridge routes the same live frames through
|
//! (ADR-135..146). This bridge routes the same live frames through
|
||||||
//! [`StreamingEngine::process_cycle`], so every published belief carries
|
//! [`StreamingEngine::process_cycle`], so every governed belief carries
|
||||||
//! evidence + model + calibration + privacy decision and a BLAKE3 witness —
|
//! evidence + model + calibration + privacy decision and a BLAKE3 witness
|
||||||
//! making the privacy control plane non-bypassable on the live 20 Hz path
|
//! (narrowing the gap called out in ADR-136 §8 and the beyond-SOTA system
|
||||||
//! (the gap called out in ADR-136 §8 and the beyond-SOTA system review).
|
//! 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
|
//! Determinism: this module reads server state and forwards explicit
|
||||||
//! timestamps/calibration ids; it introduces no wall-clock reads of its own, so
|
//! timestamps/calibration ids; it introduces no wall-clock reads of its own, so
|
||||||
@@ -17,8 +32,9 @@
|
|||||||
//! [`TrustedOutput`] witness.
|
//! [`TrustedOutput`] witness.
|
||||||
|
|
||||||
use std::collections::HashMap;
|
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_engine::{AdapterInfo, EngineError, StreamingEngine, TrustedOutput};
|
||||||
use wifi_densepose_geo::types::GeoRegistration;
|
use wifi_densepose_geo::types::GeoRegistration;
|
||||||
use wifi_densepose_signal::ruvsense::fusion_quality::CalibrationId;
|
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::multistatic_bridge::node_frames_from_states;
|
||||||
use super::NodeState;
|
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
|
/// Owns a [`StreamingEngine`] and the WorldGraph scope (one room + sensor) the
|
||||||
/// live sensing loop publishes beliefs into.
|
/// live sensing loop publishes beliefs into.
|
||||||
pub struct EngineBridge {
|
pub struct EngineBridge {
|
||||||
@@ -37,6 +58,22 @@ pub struct EngineBridge {
|
|||||||
/// Calibration epoch applied to live frames until the ADR-135 baseline
|
/// Calibration epoch applied to live frames until the ADR-135 baseline
|
||||||
/// stage supplies a real per-node id. Stable so witnesses are reproducible.
|
/// stage supplies a real per-node id. Stable so witnesses are reproducible.
|
||||||
calibration: CalibrationId,
|
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 {
|
impl EngineBridge {
|
||||||
@@ -50,6 +87,12 @@ impl EngineBridge {
|
|||||||
room,
|
room,
|
||||||
registered_nodes: HashMap::new(),
|
registered_nodes: HashMap::new(),
|
||||||
calibration: CalibrationId(0x5256_0001), // "RV\0\x01" — placeholder epoch
|
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),
|
.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)]
|
#[cfg(test)]
|
||||||
@@ -257,6 +376,81 @@ mod tests {
|
|||||||
assert_eq!(back.provenance.model_version, "rfenc-v1");
|
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]
|
#[test]
|
||||||
fn identity_strict_mode_is_carried_into_provenance() {
|
fn identity_strict_mode_is_carried_into_provenance() {
|
||||||
let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "r", "R");
|
let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "r", "R");
|
||||||
|
|||||||
@@ -1038,15 +1038,12 @@ struct AppStateInner {
|
|||||||
/// Attention-weighted multi-node CSI fusion engine.
|
/// Attention-weighted multi-node CSI fusion engine.
|
||||||
multistatic_fuser: MultistaticFuser,
|
multistatic_fuser: MultistaticFuser,
|
||||||
/// Governed trust-path bridge (ADR-135..146): runs the same live frames
|
/// Governed trust-path bridge (ADR-135..146): runs the same live frames
|
||||||
/// through the privacy/provenance/witness control plane. Additive — does not
|
/// through the privacy/provenance/witness control plane. Does not alter
|
||||||
/// affect person-count behavior; produces the auditable belief + witness.
|
/// 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,
|
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).
|
/// SVD-based room field model for eigenvalue person counting (None until calibration).
|
||||||
field_model: Option<FieldModel>,
|
field_model: Option<FieldModel>,
|
||||||
// ── ADR-044 §5.2: adaptive rolling-p95 normalization ─────────────────────
|
// ── ADR-044 §5.2: adaptive rolling-p95 normalization ─────────────────────
|
||||||
@@ -3807,11 +3804,31 @@ async fn health_live(State(state): State<SharedState>) -> Json<serde_json::Value
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Lowercase hex of a 32-byte witness for JSON exposure.
|
||||||
|
fn witness_hex(w: [u8; 32]) -> String {
|
||||||
|
use std::fmt::Write;
|
||||||
|
w.iter().fold(String::with_capacity(64), |mut acc, b| {
|
||||||
|
let _ = write!(acc, "{b:02x}");
|
||||||
|
acc
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
async fn health_ready(State(state): State<SharedState>) -> Json<serde_json::Value> {
|
async fn health_ready(State(state): State<SharedState>) -> Json<serde_json::Value> {
|
||||||
let s = state.read().await;
|
let s = state.read().await;
|
||||||
Json(serde_json::json!({
|
Json(serde_json::json!({
|
||||||
"status": "ready",
|
"status": "ready",
|
||||||
"source": s.effective_source(),
|
"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(),
|
||||||
|
},
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5061,20 +5078,17 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) {
|
|||||||
|
|
||||||
// Governed trust cycle (ADR-135..146): run the same live
|
// Governed trust cycle (ADR-135..146): run the same live
|
||||||
// frames through the privacy/provenance/witness control
|
// 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 sref: &mut AppStateInner = &mut s;
|
||||||
let now_ms = std::time::SystemTime::now()
|
let now_ms = std::time::SystemTime::now()
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
.map(|d| d.as_millis() as i64)
|
.map(|d| d.as_millis() as i64)
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
if let Some(Ok(trust)) = sref
|
sref.engine_bridge.observe_cycle(&sref.node_states, now_ms);
|
||||||
.engine_bridge
|
|
||||||
.process_cycle_from_states(&sref.node_states, now_ms)
|
|
||||||
{
|
|
||||||
sref.last_trust_witness = Some(trust.witness);
|
|
||||||
sref.recalibration_recommended = trust.recalibration_recommended;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Feed field model calibration if active (use per-node history for ESP32).
|
// Feed field model calibration if active (use per-node history for ESP32).
|
||||||
@@ -5531,20 +5545,17 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) {
|
|||||||
|
|
||||||
// Governed trust cycle (ADR-135..146): run the same live
|
// Governed trust cycle (ADR-135..146): run the same live
|
||||||
// frames through the privacy/provenance/witness control
|
// 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 sref: &mut AppStateInner = &mut s;
|
||||||
let now_ms = std::time::SystemTime::now()
|
let now_ms = std::time::SystemTime::now()
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
.map(|d| d.as_millis() as i64)
|
.map(|d| d.as_millis() as i64)
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
if let Some(Ok(trust)) = sref
|
sref.engine_bridge.observe_cycle(&sref.node_states, now_ms);
|
||||||
.engine_bridge
|
|
||||||
.process_cycle_from_states(&sref.node_states, now_ms)
|
|
||||||
{
|
|
||||||
sref.last_trust_witness = Some(trust.witness);
|
|
||||||
sref.recalibration_recommended = trust.recalibration_recommended;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Feed field model calibration if active (use per-node history for ESP32).
|
// Feed field model calibration if active (use per-node history for ESP32).
|
||||||
@@ -5558,7 +5569,15 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build nodes array with all active nodes.
|
// Build nodes array with all active nodes. ADR-141 output
|
||||||
|
// gating (review finding 1c): when the governed engine
|
||||||
|
// emitted this cycle at class Restricted (base mode, or a
|
||||||
|
// contradiction/mesh-risk demotion below the configured
|
||||||
|
// class), the per-node raw amplitude vectors are suppressed
|
||||||
|
// from the live publish — the same field mapping bfld's
|
||||||
|
// privacy gate applies at Restricted (drop amplitude/phase
|
||||||
|
// proxies).
|
||||||
|
let suppress_raw = s.engine_bridge.suppress_raw_outputs();
|
||||||
let active_nodes: Vec<NodeInfo> = s
|
let active_nodes: Vec<NodeInfo> = s
|
||||||
.node_states
|
.node_states
|
||||||
.iter()
|
.iter()
|
||||||
@@ -5570,12 +5589,19 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) {
|
|||||||
node_id: id,
|
node_id: id,
|
||||||
rssi_dbm: n.rssi_history.back().copied().unwrap_or(0.0),
|
rssi_dbm: n.rssi_history.back().copied().unwrap_or(0.0),
|
||||||
position: [2.0, 0.0, 1.5],
|
position: [2.0, 0.0, 1.5],
|
||||||
amplitude: n
|
amplitude: if suppress_raw {
|
||||||
.frame_history
|
vec![]
|
||||||
.back()
|
} else {
|
||||||
.map(|a| a.iter().take(56).cloned().collect())
|
n.frame_history
|
||||||
.unwrap_or_default(),
|
.back()
|
||||||
subcarrier_count: n.frame_history.back().map_or(0, |a| a.len()),
|
.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.
|
// ADR-110 iter 23 / iter 30 — single source of truth.
|
||||||
sync: n.sync_snapshot(),
|
sync: n.sync_snapshot(),
|
||||||
})
|
})
|
||||||
@@ -6864,8 +6890,6 @@ async fn main() {
|
|||||||
"default",
|
"default",
|
||||||
"Default Room",
|
"Default Room",
|
||||||
),
|
),
|
||||||
last_trust_witness: None,
|
|
||||||
recalibration_recommended: false,
|
|
||||||
field_model: if args.calibrate {
|
field_model: if args.calibrate {
|
||||||
info!("Field model calibration enabled — room should be empty during startup");
|
info!("Field model calibration enabled — room should be empty during startup");
|
||||||
FieldModel::new(field_bridge::single_link_config()).ok()
|
FieldModel::new(field_bridge::single_link_config()).ok()
|
||||||
|
|||||||
@@ -21,6 +21,11 @@
|
|||||||
//! 3. **Pre-registered thresholds + bootstrap CI.** The gate compares the
|
//! 3. **Pre-registered thresholds + bootstrap CI.** The gate compares the
|
||||||
//! *lower* bound of a deterministic 95% bootstrap CI, not the point estimate,
|
//! *lower* bound of a deterministic 95% bootstrap CI, not the point estimate,
|
||||||
//! so a lucky small-sample result cannot pass.
|
//! 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
|
//! 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
|
//! `claim_allowed` invariant, and the claim string is unreadable except through
|
||||||
@@ -154,6 +159,13 @@ pub struct BenchmarkCriteria {
|
|||||||
pub max_count_mae: f64,
|
pub max_count_mae: f64,
|
||||||
/// Minimum test samples to grade at all (small-N guard).
|
/// Minimum test samples to grade at all (small-N guard).
|
||||||
pub min_test_samples: usize,
|
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.
|
/// Bootstrap resamples for the CI.
|
||||||
pub bootstrap_iters: usize,
|
pub bootstrap_iters: usize,
|
||||||
/// Deterministic bootstrap seed.
|
/// Deterministic bootstrap seed.
|
||||||
@@ -166,6 +178,7 @@ impl Default for BenchmarkCriteria {
|
|||||||
min_presence_f1: 0.9,
|
min_presence_f1: 0.9,
|
||||||
max_count_mae: 0.5,
|
max_count_mae: 0.5,
|
||||||
min_test_samples: 30,
|
min_test_samples: 30,
|
||||||
|
min_positive_rate: 0.1,
|
||||||
bootstrap_iters: 1000,
|
bootstrap_iters: 1000,
|
||||||
bootstrap_seed: 42,
|
bootstrap_seed: 42,
|
||||||
}
|
}
|
||||||
@@ -199,7 +212,10 @@ pub struct BenchmarkReport {
|
|||||||
pub count_pass: bool,
|
pub count_pass: bool,
|
||||||
/// Test set is large enough to grade.
|
/// Test set is large enough to grade.
|
||||||
pub sample_size_pass: bool,
|
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,
|
pub overall_pass: bool,
|
||||||
/// The released claim string (or [`NO_CLAIM`]).
|
/// The released claim string (or [`NO_CLAIM`]).
|
||||||
pub released_claim: String,
|
pub released_claim: String,
|
||||||
@@ -213,17 +229,24 @@ impl BenchmarkReport {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// **The single claim invariant.** A SOTA/accuracy claim is releasable only when
|
/// **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
|
/// the data is measured, the split is leak-free, the sample is large enough,
|
||||||
/// both the (CI-lower) presence F1 and the count MAE clear their thresholds.
|
/// the test set is non-degenerate (both classes represented), and both the
|
||||||
|
/// (CI-lower) presence F1 and the count MAE clear their thresholds.
|
||||||
#[inline]
|
#[inline]
|
||||||
pub fn claim_allowed(
|
pub fn claim_allowed(
|
||||||
provenance_pass: bool,
|
provenance_pass: bool,
|
||||||
split_pass: bool,
|
split_pass: bool,
|
||||||
sample_size_pass: bool,
|
sample_size_pass: bool,
|
||||||
|
class_balance_pass: bool,
|
||||||
presence_pass: bool,
|
presence_pass: bool,
|
||||||
count_pass: bool,
|
count_pass: bool,
|
||||||
) -> 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`.
|
/// 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 tp, mut fp, mut tn, mut fn_) = (0u64, 0u64, 0u64, 0u64);
|
||||||
let mut count_abs_err_sum = 0.0;
|
let mut count_abs_err_sum = 0.0;
|
||||||
let mut count_exact = 0u64;
|
let mut count_exact = 0u64;
|
||||||
|
let mut truth_present = 0u64;
|
||||||
for s in &test {
|
for s in &test {
|
||||||
|
if s.truth.present {
|
||||||
|
truth_present += 1;
|
||||||
|
}
|
||||||
match (s.predicted.present, s.truth.present) {
|
match (s.predicted.present, s.truth.present) {
|
||||||
(true, true) => tp += 1,
|
(true, true) => tp += 1,
|
||||||
(true, false) => fp += 1,
|
(true, false) => fp += 1,
|
||||||
@@ -282,6 +309,17 @@ pub fn evaluate(
|
|||||||
|
|
||||||
let provenance_pass = provenance.is_claimable();
|
let provenance_pass = provenance.is_claimable();
|
||||||
let sample_size_pass = n_test >= criteria.min_test_samples;
|
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).
|
// 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 presence_pass = presence_f1_ci.0 >= criteria.min_presence_f1;
|
||||||
let count_pass = count_mae <= criteria.max_count_mae;
|
let count_pass = count_mae <= criteria.max_count_mae;
|
||||||
@@ -289,6 +327,7 @@ pub fn evaluate(
|
|||||||
provenance_pass,
|
provenance_pass,
|
||||||
split_pass,
|
split_pass,
|
||||||
sample_size_pass,
|
sample_size_pass,
|
||||||
|
class_balance_pass,
|
||||||
presence_pass,
|
presence_pass,
|
||||||
count_pass,
|
count_pass,
|
||||||
);
|
);
|
||||||
@@ -315,6 +354,7 @@ pub fn evaluate(
|
|||||||
presence_pass,
|
presence_pass,
|
||||||
count_pass,
|
count_pass,
|
||||||
sample_size_pass,
|
sample_size_pass,
|
||||||
|
class_balance_pass,
|
||||||
overall_pass,
|
overall_pass,
|
||||||
released_claim,
|
released_claim,
|
||||||
}
|
}
|
||||||
@@ -323,9 +363,12 @@ pub fn evaluate(
|
|||||||
fn f1_from_confusion(tp: u64, fp: u64, fn_: u64) -> f64 {
|
fn f1_from_confusion(tp: u64, fp: u64, fn_: u64) -> f64 {
|
||||||
let denom = 2 * tp + fp + fn_;
|
let denom = 2 * tp + fp + fn_;
|
||||||
if denom == 0 {
|
if denom == 0 {
|
||||||
// No positives anywhere: define F1 = 1.0 only if there were also no
|
// No positives anywhere (tp = fp = fn = 0): F1 is undefined, and the
|
||||||
// predicted/actual positives at all (vacuous), else 0.0.
|
// vacuous case must score 0.0, never 1.0 — an all-absent test set plus
|
||||||
return if fp == 0 && fn_ == 0 { 1.0 } else { 0.0 };
|
// 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
|
(2 * tp) as f64 / denom as f64
|
||||||
}
|
}
|
||||||
@@ -479,29 +522,101 @@ mod tests {
|
|||||||
assert!(!r.overall_pass);
|
assert!(!r.overall_pass);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The probative CI-gate case (review finding 10): a test set whose POINT
|
||||||
|
/// F1 clears the 0.9 threshold while the bootstrap CI LOWER bound falls
|
||||||
|
/// below it — the claim must be withheld. A point-estimate gate would
|
||||||
|
/// (wrongly) release here.
|
||||||
#[test]
|
#[test]
|
||||||
fn gate_uses_ci_lower_bound_not_point_estimate() {
|
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();
|
let mut samples = Vec::new();
|
||||||
for i in 0..40 {
|
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 {
|
for i in 0..40 {
|
||||||
// ~15% false negatives in test
|
let truth_present = i < 20;
|
||||||
let correct = i % 7 != 0;
|
let predicted_present = truth_present && i >= 3; // i 0..3 → FN
|
||||||
samples.push(sample(
|
samples.push(sample(
|
||||||
&format!("test-{i}"),
|
&format!("test-{i}"),
|
||||||
&format!("tn-{i}"),
|
&format!("tn-{i}"),
|
||||||
(true, 1),
|
(truth_present, u32::from(truth_present)),
|
||||||
(correct, 1),
|
(predicted_present, u32::from(truth_present)),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let split = EvalSplit { train_idx: (0..40).collect(), test_idx: (40..80).collect() };
|
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());
|
let r = evaluate(&samples, DataProvenance::Measured, &split, &BenchmarkCriteria::default());
|
||||||
// CI lower bound is below the point estimate.
|
// Vacuous F1 scores 0.0 (was 1.0 before the fix).
|
||||||
assert!(r.presence_f1_ci.0 <= r.presence_f1);
|
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]
|
#[test]
|
||||||
@@ -518,29 +633,36 @@ mod tests {
|
|||||||
for i in 0..40 {
|
for i in 0..40 {
|
||||||
samples.push(sample(&format!("tr-{i}"), &format!("te-{i}"), (true, 1), (true, 1)));
|
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 {
|
for i in 0..40 {
|
||||||
// presence perfect, but count is always off by 2 -> MAE 2.0 > 0.5
|
let present = i % 2 == 0;
|
||||||
samples.push(sample(&format!("ts-{i}"), &format!("ev-{i}"), (true, 1), (true, 3)));
|
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 split = EvalSplit { train_idx: (0..40).collect(), test_idx: (40..80).collect() };
|
||||||
let r = evaluate(&samples, DataProvenance::Measured, &split, &BenchmarkCriteria::default());
|
let r = evaluate(&samples, DataProvenance::Measured, &split, &BenchmarkCriteria::default());
|
||||||
assert!(r.presence_pass);
|
assert!(r.presence_pass);
|
||||||
|
assert!(r.class_balance_pass);
|
||||||
assert!(!r.count_pass);
|
assert!(!r.count_pass);
|
||||||
assert!(!r.overall_pass);
|
assert!(!r.overall_pass);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn claim_invariant_requires_all_five() {
|
fn claim_invariant_requires_all_six() {
|
||||||
assert!(claim_allowed(true, true, true, true, true));
|
assert!(claim_allowed(true, true, true, true, true, true));
|
||||||
let one_false = [
|
// Every single-false combination is denied.
|
||||||
(false, true, true, true, true),
|
for i in 0..6 {
|
||||||
(true, false, true, true, true),
|
let v: Vec<bool> = (0..6).map(|j| j != i).collect();
|
||||||
(true, true, false, true, true),
|
assert!(
|
||||||
(true, true, true, false, true),
|
!claim_allowed(v[0], v[1], v[2], v[3], v[4], v[5]),
|
||||||
(true, true, true, true, false),
|
"criterion {i} false must deny the claim"
|
||||||
];
|
);
|
||||||
for (a, b, c, d, e) in one_false {
|
|
||||||
assert!(!claim_allowed(a, b, c, d, e));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user