diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f73d091..11d384b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ 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. + ### 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). - **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. diff --git a/v2/crates/wifi-densepose-engine/src/lib.rs b/v2/crates/wifi-densepose-engine/src/lib.rs index 4631a370..0b4a4b83 100644 --- a/v2/crates/wifi-densepose-engine/src/lib.rs +++ b/v2/crates/wifi-densepose-engine/src/lib.rs @@ -246,6 +246,12 @@ impl StreamingEngine { self.recal = advisor; } + /// Mutable access to the mesh partition guard (risk threshold, quantum, + /// min-node count). Operators tune the partition-risk sensitivity here. + pub fn mesh_guard_mut(&mut self) -> &mut MeshGuard { + &mut self.mesh + } + /// Default cap on live `SemanticState` beliefs in the WorldGraph /// (~6 minutes of full-rate history at 20 Hz; older beliefs are evicted — /// durable history belongs to the recorder). @@ -439,13 +445,32 @@ impl StreamingEngine { // 4. Evolution change-point (ADR-142) over per-node mean amplitude. let change_point = self.track_evolution(node_frames, now_ms, room); - // 5. Privacy control plane (ADR-141): demote on a fusion-level OR an - // array-level contradiction (monotonic — information only removed). + // 5. Mesh partition guard (ADR-032): dynamic min-cut over the coupling + // graph. Coupling between nodes i and j is the product of their + // fusion attention weights scaled by the node count, so a node the + // fuser down-weights is exactly a node weakly coupled in the graph. + // (Change-gated incremental updates: steady state touches 0 edges.) + let node_ids: Vec = node_frames.iter().map(|f| f.node_id).collect(); + let weights = &quality.per_node_weights; + let n = weights.len() as f64; + let mesh = self.mesh.update(&node_ids, |i, j| { + let wi = weights.get(i).copied().unwrap_or(0.0) as f64; + let wj = weights.get(j).copied().unwrap_or(0.0) as f64; + wi * wj * n + }); + let mesh_at_risk = mesh.as_ref().is_some_and(|m| m.at_risk); + + // 6. Privacy control plane (ADR-141): demote on a fusion-level OR an + // array-level contradiction OR a mesh close to partitioning. The + // last is a security/reliability signal (ADR-032): a fragmenting + // array makes the fused belief less trustworthy, so we emit at a + // more restricted class. Monotonic — information is only ever + // removed — and the demotion is part of the witness. let base_class = self.privacy.active_class(); - let demoted = quality.forces_privacy_demotion() || array_contradiction; + let demoted = quality.forces_privacy_demotion() || array_contradiction || mesh_at_risk; let effective_class = if demoted { demote_one(base_class) } else { base_class }; - // 6. Semantic state with mandatory provenance (ADR-139/140). The + // 7. Semantic state with mandatory provenance (ADR-139/140). The // calibration version comes from the *agreed* epoch (None on mismatch). // When a per-room adapter is active (ADR-150 §3.4) its content-derived // id is part of model_version — and therefore of the witness — so the @@ -480,24 +505,11 @@ impl StreamingEngine { // eviction; the just-added belief is always newest and survives. self.world.prune_semantic_states(self.semantic_retention); - // 7. Deterministic witness over the trust decision (ADR-137 §2.7). + // 8. Deterministic witness over the trust decision (ADR-137 §2.7). + // `effective_class` already reflects any mesh-risk demotion, so a + // fragmenting array shifts the witness — partition risk is auditable. let witness = witness_of(&provenance, effective_class); - // 8. Mesh partition guard: dynamic min-cut over the coupling graph. - // Coupling between nodes i and j is the product of their fusion - // attention weights scaled by the node count, so a node the fuser - // down-weights is exactly a node weakly coupled in the graph. - // (Change-gated incremental updates: steady state touches 0 edges.) - let node_ids: Vec = node_frames.iter().map(|f| f.node_id).collect(); - let weights = &quality.per_node_weights; - let n = weights.len() as f64; - let mesh = self.mesh.update(&node_ids, |i, j| { - let wi = weights.get(i).copied().unwrap_or(0.0) as f64; - let wj = weights.get(j).copied().unwrap_or(0.0) as f64; - wi * wj * n - }); - let mesh_at_risk = mesh.as_ref().is_some_and(|m| m.at_risk); - // 9. Drift→recalibration advisor (ADR-135 → ADR-150 §3.4): sustained // low coherence, an environment change-point, or a mesh close to // partitioning recommends refit. @@ -822,6 +834,40 @@ mod tests { assert!(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. + #[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), + ]; + 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); + } + /// WorldGraph belief retention: the live loop appends one SemanticState per /// cycle; past the cap the oldest beliefs are evicted so graph memory is /// bounded, while structural nodes and the newest belief always survive.