mirror of
https://github.com/ruvnet/RuView
synced 2026-08-11 20:41:44 +00:00
Beyond-SOTA engine/signal/train improvements: mesh partition guard, FFT CIR solver, canonical frame decoder, falsifiable occupancy benchmark, governed streaming, adapter provenance (#1018)
* docs(research): add RuView beyond-SOTA system review (00) First document of the beyond-SOTA research series: capability audit of the current RuView engine with role-to-crate maturity matrix, ruvsense module inventory, gap analysis, and risk register. https://claude.ai/code/session_01MjBucx95K4BuUxZi8NWwRH * docs(research): add beyond-SOTA architecture design (02, in progress) https://claude.ai/code/session_01MjBucx95K4BuUxZi8NWwRH * docs(research): finalize beyond-SOTA architecture (02) https://claude.ai/code/session_01MjBucx95K4BuUxZi8NWwRH * docs(research): add benchmark/validation methodology snapshot (03) https://claude.ai/code/session_01MjBucx95K4BuUxZi8NWwRH * docs(research): add beyond-SOTA series index with validation results; changelog README index ties the 5 research docs together with the session's measured validation evidence: 2,797 workspace tests / 0 failed, Python proof PASS (bit-exact), and paired pre/post criterion CIR benchmarks. https://claude.ai/code/session_01MjBucx95K4BuUxZi8NWwRH * perf(signal): precompute CIR warm-start system; hoist tomography solver allocs Exact, determinism-safe optimizations (bit-identical float results): - cir.rs: diag(PhiH Phi)+lambda*I and its CSR matrix depend only on Phi and lambda (fixed at CirEstimator::new) but were rebuilt every frame (O(K*G) pass + CSR allocation). Now built once in new() via build_warm_start_system; summation order unchanged. - tomography.rs: ISTA gradient buffer hoisted out of the 100-iteration loop (fill(0.0) reset) and the Frobenius Lipschitz bound moved from per-reconstruct to construction. Verified: signal 456 tests green; engine 11/11 green including cycle_is_deterministic and witness-stability tests. Criterion paired pre/post: cir_estimate/he40 -3.9% (p<0.01), multiband -1.2/-1.4%. https://claude.ai/code/session_01MjBucx95K4BuUxZi8NWwRH * fix(worldgraph): bound SemanticState growth with deterministic retention StreamingEngine::process_cycle appended one SemanticState belief per cycle with no eviction — ~1.7M nodes/day at 20 Hz (beyond-SOTA roadmap finding #6). Add WorldGraph::prune_semantic_states(max): deterministic eviction of the oldest beliefs by (valid_from_unix_ms, id); structural nodes (rooms, zones, sensors, anchors, tracks, events) are never eligible. Wire it into the engine after each belief append (DEFAULT_SEMANTIC_RETENTION = 7,200, ~6 min at 20 Hz; set_semantic_retention to tune). The WorldGraph holds current beliefs; durable history is the recorder's job, so no audit data is lost. 3 new tests: end-to-end bounded growth, oldest-only eviction, deterministic equal-timestamp tie-break. Workspace gate: 2,865 passed, 0 failed. https://claude.ai/code/session_01MjBucx95K4BuUxZi8NWwRH * feat(sensing-server): route live frames through the governed StreamingEngine Closes the live-trust-path gap (ADR-136 section 8, beyond-SOTA system review): the running server fused live CSI with the bare MultistaticFuser, while the privacy/provenance/witness control plane (ADR-135..146) only ever ran on synthetic in-test frames. The privacy control plane was therefore bypassable on the real path. New engine_bridge module drives StreamingEngine::process_cycle from the server's live NodeState map, reusing the existing NodeState -> MultiBandCsiFrame conversion. It lazily wires each contributing node as a WorldGraph sensor (idempotent), bounds belief growth via the retention cap, and forwards explicit timestamps/calibration ids so the path stays deterministic and replayable. Wired additively into both live ESP32/WiFi fusion sites in main.rs via a split-borrow off the write guard, so person-count behavior is unchanged; the latest BLAKE3 witness is stored on AppState. Every published belief now carries evidence + model + calibration + privacy decision and a deterministic witness. Adds wifi-densepose-engine/-worldgraph/-bfld/-geo deps. 6 new bridge tests (witnessed belief with full provenance, cross-run determinism, idempotent node registration, retention bound, privacy-mode propagation). sensing-server suite 430+128 green; workspace gate 2,904 passed / 0 failed. https://claude.ai/code/session_01MjBucx95K4BuUxZi8NWwRH * feat(train): falsifiable occupancy benchmark with anti-overfitting gate Makes the presence/person-count "beyond SOTA" claim falsifiable in code instead of aspirational (the unfalsifiability gap from the beyond-SOTA system review). occupancy_bench grades predictions vs ground truth and gates a SOTA claim behind one claim_allowed invariant requiring ALL of: - DataProvenance::Measured — synthetic/mock data is scorable for regression but never claimable (anti-mock-contamination; the CLAUDE.md Kconfig-bug lesson made structural). - A leak-free EvalSplit — validate() refuses any split where a subject OR environment id appears in both train and test (subject leakage / per-environment overfitting). - n_test >= min_test_samples (small-N guard). - Presence F1 whose bootstrap-CI lower bound (deterministic seeded splitmix64) clears the threshold — not the point estimate. - Count MAE within threshold. The claim string is unreadable except through the gate (NO_CLAIM otherwise), same discipline as the ruview-gamma acceptance gate. What remains is data, not method: a frozen, SHA-pinned, subject/environment-disjoint measured replay set turns the claim into a passing/failing test. Lives in wifi-densepose-train (the eval bounded context, alongside ablation/ eval/metrics). 10 tests cover each refusal path; warning-clean under the crate's missing_docs lint. Workspace gate 2,914 passed / 0 failed. Doc 03 updated. https://claude.ai/code/session_01MjBucx95K4BuUxZi8NWwRH * feat(engine): per-room adapter provenance + drift-to-recalibration advisor Closes the trust-chain gap where an ~11 KB per-room LoRA adapter (ADR-150 section 3.4) could silently change inference without the witness noticing: provenance carried only "rfenc-v<N>" with no notion of adapter identity. - StreamingEngine::set_room_adapter(AdapterInfo): pins the adapter's content-derived id into provenance model_version ("rfenc-v1+adapter:<id>") — and therefore into the BLAKE3 witness — so swapping or clearing adapter weights always shifts the witness. Engine test proves base -> adapter -> other-adapter -> cleared all witness differently and cleared == base. - RecalibrationAdvisor: recommends re-running the ADR-135 empty-room baseline / refitting the room adapter on sustained low fusion coherence (streak threshold, default 60 cycles ~ 3 s at 20 Hz) or an ADR-142 change-point. Surfaced as TrustedOutput::recalibration_recommended, stored on the sensing-server AppState alongside the witness at both live fusion sites. - Bridge plumbing: EngineBridge::{set_room_adapter, clear_room_adapter} + live-path test that the adapter id flows into the live witness. Scope note (honest): this is the deployable provenance/trigger half of the "retrained model" roadmap item. Fitting the adapter itself runs in the existing external calibration service (aether-arena/calibration/); a trained RF-encoder checkpoint still does not exist in-tree. Engine 15 tests, bridge 7 tests. Workspace gate: 2,918 passed / 0 failed. https://claude.ai/code/session_01MjBucx95K4BuUxZi8NWwRH * fix(mat): gate api module behind its feature — standalone no-default-features builds pub mod api was unconditional while its only dependency, serde, is optional behind the 'api' feature, so any build without default features failed with 101 unresolved-serde errors (masked in --workspace runs by feature unification). The api module and its create_router/AppState re-export are now cfg(feature = "api")-gated with docsrs annotations. All combos compile: bare --no-default-features (was 101 errors, now 0), --no-default-features --features api, and full default (177 tests pass). Workspace gate: 2,918 passed / 0 failed. https://claude.ai/code/session_01MjBucx95K4BuUxZi8NWwRH * perf(signal): opt-in FFT operator for the CIR ISTA solver (8-14x measured) Phi is a sub-DFT, so each ISTA mat-vec can run as one length-G FFT (O(G log G)) instead of a dense O(K*G) product — the dominant-latency-hazard finding from the beyond-SOTA optimization roadmap. New CirConfig::fft_operator, default FALSE: the dense path stays the bit-exact witness default. The FFT evaluates the same sums in a different order, so enabling it shifts float results in the last bits and requires regenerating any pinned witness — strictly opt-in per deployment. FftOperator (rustfft, planned once at CirEstimator::new, scratch buffers reused across the ISTA loop) dispatches inside ista_solve: Phi x = scale * forward-FFT(x) sampled at bins (k_idx mod G) Phi^H v = scale * unnormalised inverse-FFT of v scattered into those bins Warm-start and Lipschitz estimation stay dense at construction. Measured (criterion, same run, same machine): ht20: 2.22 ms -> 265 us (8.4x) ht40: 10.26 ms -> 717 us (14.3x) The real HE40 grid (K=484, G=1452) scales further per the O(K*G)/O(G log G) ratio. 3 new tests: FFT<->dense matvec equivalence to float tolerance on ht20 and he40 grids; end-to-end dominant-tap agreement on a single-path frame; all default configs keep FFT off. New cir_estimate_fft bench group. Workspace gate: 2,921 passed / 0 failed (default path bit-exact, witnesses unchanged). https://claude.ai/code/session_01MjBucx95K4BuUxZi8NWwRH * feat(core): canonical frame decoder — capture-to-claim replay (ADR-136) The encode half of the ADR-136 frame contract existed (ComplexSample, to_canonical_bytes, witness_hash) but there was no decoder: a captured canonical frame could be witnessed but never reconstructed, blocking replay-from-capture. CsiFrame::from_canonical_bytes is the exact inverse: same id, metadata, complex payload, and witness hash (tested as the round-trip law AC7 — the replayed frame re-encodes byte-identically). Amplitude/phase are recomputed from the payload (projections, not independent state). Every malformed-input class fails closed (AC8): header truncation -> Truncated, payload truncation -> PayloadMismatch, unknown discriminants, non-UTF-8 device id, trailing bytes. Nil calibration uuid decodes as None per the documented encoding. Core: 36 tests pass. Workspace gate: 2,937 passed / 0 failed. https://claude.ai/code/session_01MjBucx95K4BuUxZi8NWwRH * feat(engine): dynamic min-cut mesh partition guard (ruvector-mincut) Maintains an exact min-cut over the live mesh coupling graph — nodes are sensing nodes, coupling is the product of fusion attention weights — and surfaces per cycle, as TrustedOutput::mesh: - cut value: the global "how close is the array to partitioning" number, a structural measure per-node heuristics miss; - weak side: which specific nodes would split off (failure/jamming triage, feeds ADR-032 posture); - at-risk flag: counts as a structural event for the drift->recalibration advisor (alongside ADR-142 change-points). Degenerate cases fail toward risk: a node with zero coupling is reported as already partitioned (cut 0, that node as the weak side). Measured cost policy (criterion, 12-node mesh — the honest part): - weights quantized (1/64) + change-gated: steady-state cycles do ZERO graph work and reuse the cached cut (~7.3 us, ~23x cheaper than building); - on any real change a full exact rebuild (~171 us) is used, because ONE DynamicMinCut delete+insert measured ~240 us — the subpolynomial machinery amortizes on much larger graphs, so rebuild-on-change is the measured optimum at mesh scale (one-edge case -28% after switching policy); - full process_cycle with the guard: ~33 us for 4 nodes vs the 50 ms budget. 9 mesh_guard tests (weak-node detection, steady-state zero updates, sub-quantum gating, join/drop rebuild, determinism, disconnection) + an engine-level wiring test (down-weighted node -> weak side -> recalibration). Engine 24 tests; workspace gate 2,946 passed / 0 failed. https://claude.ai/code/session_01MjBucx95K4BuUxZi8NWwRH * feat(engine): mesh partition risk demotes privacy + enters the witness (ADR-032) Completes the mesh-guard integration: its at_risk signal was advisory-only (fed the recalibration advisor). It now also contributes to the ADR-141 privacy demotion alongside fusion- and array-level contradictions — a mesh close to partitioning makes the fused belief less trustworthy, so the cycle emits at a more restricted class (monotonic; information only removed). Because effective_class feeds the BLAKE3 witness, a fragmenting array now shifts the witness: partition risk is auditable, not just logged. The mesh computation moved ahead of the demotion step in process_cycle; mesh_guard_mut exposes risk-threshold tuning. Test: a forced-risk 3-node cycle demotes PrivateHome Anonymous->Restricted and shifts the witness vs a clean baseline. Engine 25 tests; workspace gate 2,947 passed / 0 failed. https://claude.ai/code/session_01MjBucx95K4BuUxZi8NWwRH * fix: public-PR review findings — privacy-path honesty, gate holes, mesh-guard cliff - sensing-server: engine errors logged+counted (no silent swallow), trust state exposed via status surface, privacy-demotion claims aligned with the actual parallel-audit-path behavior - occupancy_bench: vacuous-F1 hole closed (degenerate test sets fail with their own criterion); CI-lower-bound test made probative - mesh_guard: quantization scaled to observed coupling range — >=65-node balanced meshes no longer permanently at_risk (regression test) - engine: both wiring tests made probative (same-topology witness compare, deterministic risk-crossing fixture) - mat: axum/tokio optional behind api; real serde feature (api enables it) - core: canonical decoder strict (non-zero reserved bytes and nil UUID rejected — injective on accepted domain, forged-bytes tests) - CHANGELOG: un-spliced the FFT/adapter bullet mangle Co-Authored-By: claude-flow <ruv@ruv.net> * chore: strip private-track references for public PR Reword the occupancy-benchmark changelog bullet to drop a cross-reference to the private research track, and restore the WorldGraph retention bullet header that was glued onto the preceding MAT bullet. Co-Authored-By: claude-flow <ruv@ruv.net> * chore: lockfile refresh for cherry-picked feature set Co-Authored-By: claude-flow <ruv@ruv.net> --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -46,6 +46,9 @@ use wifi_densepose_worldgraph::{
|
||||
WorldId, WorldNode, ZoneBoundsEnu,
|
||||
};
|
||||
|
||||
pub mod mesh_guard;
|
||||
pub use mesh_guard::{MeshGuard, MeshPartitionReport};
|
||||
|
||||
/// Errors from an engine cycle.
|
||||
#[derive(Debug)]
|
||||
pub enum EngineError {
|
||||
@@ -97,6 +100,15 @@ pub struct TrustedOutput {
|
||||
/// BLAKE3 witness over the trust decision (provenance ‖ class ‖ calibration)
|
||||
/// — a deterministic, signed-belief fingerprint (ADR-137 §2.7 / ADR-028).
|
||||
pub witness: [u8; 32],
|
||||
/// Whether the drift→recalibration advisor recommends re-running the
|
||||
/// ADR-135 baseline / refitting the per-room adapter (ADR-150 §3.4):
|
||||
/// sustained low coherence or an ADR-142 change-point this cycle.
|
||||
pub recalibration_recommended: bool,
|
||||
/// Dynamic min-cut partition report over the live mesh coupling graph
|
||||
/// (None for meshes of fewer than two nodes). `at_risk` counts as a
|
||||
/// structural event for the recalibration advisor and names the nodes
|
||||
/// (`weak_side`) closest to splitting off — failure/jamming triage.
|
||||
pub mesh: Option<MeshPartitionReport>,
|
||||
}
|
||||
|
||||
/// Composition root for the RuView streaming engine.
|
||||
@@ -116,6 +128,74 @@ pub struct StreamingEngine {
|
||||
slam: RfSlam,
|
||||
// ADR-139 live loop: stable track_id -> PersonTrack WorldId.
|
||||
person_tracks: BTreeMap<u64, WorldId>,
|
||||
// WorldGraph belief retention: max live SemanticState nodes. The live loop
|
||||
// appends one belief per cycle (1.7M/day at 20 Hz); durable history is the
|
||||
// recorder's job, so old beliefs are evicted deterministically past this cap.
|
||||
semantic_retention: usize,
|
||||
// Per-room calibration adapter (ADR-150 §3.4: ~11 KB LoRA on a frozen
|
||||
// base). Identity is part of the trust chain: when set, the adapter id is
|
||||
// appended to the provenance model_version, so swapping adapters changes
|
||||
// the witness. None = shared base model.
|
||||
adapter: Option<AdapterInfo>,
|
||||
// Drift→recalibration advisor (ADR-135 trigger for ADR-150 §3.4 refit).
|
||||
recal: RecalibrationAdvisor,
|
||||
// Dynamic min-cut mesh partition guard (incremental, change-gated).
|
||||
mesh: MeshGuard,
|
||||
}
|
||||
|
||||
/// Identity of an active per-room calibration adapter (ADR-150 §3.4). The id
|
||||
/// must be content-derived (e.g. a hash prefix of the adapter file) so the
|
||||
/// provenance/witness chain pins the exact weights that shaped inference.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AdapterInfo {
|
||||
/// Content-derived adapter identity (e.g. first 16 hex of its SHA-256).
|
||||
pub adapter_id: String,
|
||||
/// Number of in-room samples the adapter was fitted on (0 if unknown).
|
||||
pub trained_samples: u32,
|
||||
}
|
||||
|
||||
/// Recommends re-running calibration / adapter refit when the live signal
|
||||
/// degrades persistently (ADR-135 drift → ADR-150 §3.4 few-shot recalibration).
|
||||
///
|
||||
/// Two triggers, both cheap and deterministic:
|
||||
/// - `low_coherence_streak`: N consecutive cycles whose base coherence fell
|
||||
/// below the floor (sustained degradation, not a single bad frame);
|
||||
/// - any ADR-142 change-point this cycle (the environment itself changed).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RecalibrationAdvisor {
|
||||
/// Coherence below this counts toward the streak.
|
||||
pub coherence_floor: f32,
|
||||
/// Consecutive low-coherence cycles required to recommend recalibration.
|
||||
pub streak_threshold: u32,
|
||||
streak: u32,
|
||||
}
|
||||
|
||||
impl Default for RecalibrationAdvisor {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
coherence_floor: 0.5,
|
||||
streak_threshold: 60, // ~3 s at 20 Hz of sustained degradation
|
||||
streak: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RecalibrationAdvisor {
|
||||
/// Feed one cycle's evidence; returns whether recalibration is recommended.
|
||||
fn observe(&mut self, base_coherence: f32, change_point: bool) -> bool {
|
||||
if base_coherence < self.coherence_floor {
|
||||
self.streak = self.streak.saturating_add(1);
|
||||
} else {
|
||||
self.streak = 0;
|
||||
}
|
||||
change_point || self.streak >= self.streak_threshold
|
||||
}
|
||||
|
||||
/// Current consecutive low-coherence cycle count.
|
||||
#[must_use]
|
||||
pub fn streak(&self) -> u32 {
|
||||
self.streak
|
||||
}
|
||||
}
|
||||
|
||||
impl StreamingEngine {
|
||||
@@ -135,9 +215,53 @@ impl StreamingEngine {
|
||||
evolution: None,
|
||||
slam: RfSlam::with_discovery(0.5, 5, 0.6),
|
||||
person_tracks: BTreeMap::new(),
|
||||
semantic_retention: Self::DEFAULT_SEMANTIC_RETENTION,
|
||||
adapter: None,
|
||||
recal: RecalibrationAdvisor::default(),
|
||||
mesh: MeshGuard::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Activate a per-room calibration adapter (ADR-150 §3.4). From the next
|
||||
/// cycle on, the adapter id is part of provenance `model_version` — and
|
||||
/// therefore of the witness — so the exact weights shaping inference are
|
||||
/// pinned in the trust chain. Pass the result of hashing the adapter file.
|
||||
pub fn set_room_adapter(&mut self, info: AdapterInfo) {
|
||||
self.adapter = Some(info);
|
||||
}
|
||||
|
||||
/// Deactivate the adapter (revert to the shared base model).
|
||||
pub fn clear_room_adapter(&mut self) {
|
||||
self.adapter = None;
|
||||
}
|
||||
|
||||
/// The active adapter, if any.
|
||||
#[must_use]
|
||||
pub fn room_adapter(&self) -> Option<&AdapterInfo> {
|
||||
self.adapter.as_ref()
|
||||
}
|
||||
|
||||
/// Tune the drift→recalibration advisor (floor + streak threshold).
|
||||
pub fn set_recalibration_advisor(&mut self, advisor: RecalibrationAdvisor) {
|
||||
self.recal = advisor;
|
||||
}
|
||||
|
||||
/// Mutable access to the mesh partition guard (risk threshold, quantum,
|
||||
/// min-node count). Operators tune the partition-risk sensitivity here.
|
||||
pub fn mesh_guard_mut(&mut self) -> &mut MeshGuard {
|
||||
&mut self.mesh
|
||||
}
|
||||
|
||||
/// Default cap on live `SemanticState` beliefs in the WorldGraph
|
||||
/// (~6 minutes of full-rate history at 20 Hz; older beliefs are evicted —
|
||||
/// durable history belongs to the recorder).
|
||||
pub const DEFAULT_SEMANTIC_RETENTION: usize = 7_200;
|
||||
|
||||
/// Override the `SemanticState` retention cap (minimum 1).
|
||||
pub fn set_semantic_retention(&mut self, max_states: usize) {
|
||||
self.semantic_retention = max_states.max(1);
|
||||
}
|
||||
|
||||
/// ADR-139 live loop: create or update a `PersonTrack` node by stable
|
||||
/// `track_id`, locate it in `room`, and wire an `Observes` edge from
|
||||
/// `sensor` (so the privacy rollup can suppress it under identity-strict
|
||||
@@ -321,21 +445,47 @@ impl StreamingEngine {
|
||||
// 4. Evolution change-point (ADR-142) over per-node mean amplitude.
|
||||
let change_point = self.track_evolution(node_frames, now_ms, room);
|
||||
|
||||
// 5. Privacy control plane (ADR-141): demote on a fusion-level OR an
|
||||
// array-level contradiction (monotonic — information only removed).
|
||||
// 5. Mesh partition guard (ADR-032): dynamic min-cut over the coupling
|
||||
// graph. Coupling between nodes i and j is the product of their
|
||||
// fusion attention weights scaled by the node count, so a node the
|
||||
// fuser down-weights is exactly a node weakly coupled in the graph.
|
||||
// (Change-gated incremental updates: steady state touches 0 edges.)
|
||||
let node_ids: Vec<u8> = node_frames.iter().map(|f| f.node_id).collect();
|
||||
let weights = &quality.per_node_weights;
|
||||
let n = weights.len() as f64;
|
||||
let mesh = self.mesh.update(&node_ids, |i, j| {
|
||||
let wi = weights.get(i).copied().unwrap_or(0.0) as f64;
|
||||
let wj = weights.get(j).copied().unwrap_or(0.0) as f64;
|
||||
wi * wj * n
|
||||
});
|
||||
let mesh_at_risk = mesh.as_ref().is_some_and(|m| m.at_risk);
|
||||
|
||||
// 6. Privacy control plane (ADR-141): demote on a fusion-level OR an
|
||||
// array-level contradiction OR a mesh close to partitioning. The
|
||||
// last is a security/reliability signal (ADR-032): a fragmenting
|
||||
// array makes the fused belief less trustworthy, so we emit at a
|
||||
// more restricted class. Monotonic — information is only ever
|
||||
// removed — and the demotion is part of the witness.
|
||||
let base_class = self.privacy.active_class();
|
||||
let demoted = quality.forces_privacy_demotion() || array_contradiction;
|
||||
let demoted = quality.forces_privacy_demotion() || array_contradiction || mesh_at_risk;
|
||||
let effective_class = if demoted { demote_one(base_class) } else { base_class };
|
||||
|
||||
// 6. Semantic state with mandatory provenance (ADR-139/140). The
|
||||
// 7. Semantic state with mandatory provenance (ADR-139/140). The
|
||||
// calibration version comes from the *agreed* epoch (None on mismatch).
|
||||
// When a per-room adapter is active (ADR-150 §3.4) its content-derived
|
||||
// id is part of model_version — and therefore of the witness — so the
|
||||
// exact weights shaping inference are pinned in the trust chain.
|
||||
let calibration_version = match quality.calibration_id {
|
||||
Some(c) => format!("cal:{:016x}", c.0),
|
||||
None => "cal:none".to_string(),
|
||||
};
|
||||
let model_version = match &self.adapter {
|
||||
Some(a) => format!("rfenc-v{}+adapter:{}", self.model_version, a.adapter_id),
|
||||
None => format!("rfenc-v{}", self.model_version),
|
||||
};
|
||||
let provenance = SemanticProvenance {
|
||||
evidence: quality.evidence_refs.iter().map(|e| format!("{e:?}")).collect(),
|
||||
model_version: format!("rfenc-v{}", self.model_version),
|
||||
model_version,
|
||||
calibration_version,
|
||||
privacy_decision: format!("{:?}/{:?}", self.privacy.active_mode(), effective_class),
|
||||
};
|
||||
@@ -350,10 +500,23 @@ impl StreamingEngine {
|
||||
provenance.clone(),
|
||||
&[room],
|
||||
);
|
||||
// Retention: bound the live belief set (one node is appended per cycle;
|
||||
// without this the graph grows ~1.7M nodes/day at 20 Hz). Deterministic
|
||||
// eviction; the just-added belief is always newest and survives.
|
||||
self.world.prune_semantic_states(self.semantic_retention);
|
||||
|
||||
// 7. Deterministic witness over the trust decision (ADR-137 §2.7).
|
||||
// 8. Deterministic witness over the trust decision (ADR-137 §2.7).
|
||||
// `effective_class` already reflects any mesh-risk demotion, so a
|
||||
// fragmenting array shifts the witness — partition risk is auditable.
|
||||
let witness = witness_of(&provenance, effective_class);
|
||||
|
||||
// 9. Drift→recalibration advisor (ADR-135 → ADR-150 §3.4): sustained
|
||||
// low coherence, an environment change-point, or a mesh close to
|
||||
// partitioning recommends refit.
|
||||
let recalibration_recommended = self
|
||||
.recal
|
||||
.observe(quality.base_coherence, change_point.is_some() || mesh_at_risk);
|
||||
|
||||
self.cycle += 1;
|
||||
Ok(TrustedOutput {
|
||||
semantic_id,
|
||||
@@ -364,6 +527,8 @@ impl StreamingEngine {
|
||||
directional,
|
||||
change_point,
|
||||
witness,
|
||||
recalibration_recommended,
|
||||
mesh,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -547,6 +712,205 @@ mod tests {
|
||||
assert_eq!(o1.quality.per_node_weights, o2.quality.per_node_weights);
|
||||
}
|
||||
|
||||
/// ADR-150 §3.4 adapter provenance: activating a per-room adapter changes
|
||||
/// the provenance model_version AND the witness — the exact weights shaping
|
||||
/// inference are pinned in the trust chain, so an adapter can never swap
|
||||
/// silently. Clearing it restores the base identity (and base witness).
|
||||
#[test]
|
||||
fn adapter_identity_is_witnessed() {
|
||||
let cal = CalibrationId(9);
|
||||
let frames = [node_frame(0, 1000, 56), node_frame(1, 1001, 56)];
|
||||
|
||||
let (mut e, room) = engine();
|
||||
let base = e.process_cycle(&frames, cal, room, 1_000).unwrap();
|
||||
assert_eq!(base.provenance.model_version, "rfenc-v1");
|
||||
|
||||
e.set_room_adapter(AdapterInfo {
|
||||
adapter_id: "a1b2c3d4e5f60718".into(),
|
||||
trained_samples: 150,
|
||||
});
|
||||
let adapted = e.process_cycle(&frames, cal, room, 2_000).unwrap();
|
||||
assert_eq!(
|
||||
adapted.provenance.model_version,
|
||||
"rfenc-v1+adapter:a1b2c3d4e5f60718"
|
||||
);
|
||||
assert_ne!(adapted.witness, base.witness, "adapter must shift the witness");
|
||||
|
||||
// A different adapter id yields a different witness again.
|
||||
e.set_room_adapter(AdapterInfo {
|
||||
adapter_id: "ffffffffffffffff".into(),
|
||||
trained_samples: 150,
|
||||
});
|
||||
let other = e.process_cycle(&frames, cal, room, 3_000).unwrap();
|
||||
assert_ne!(other.witness, adapted.witness);
|
||||
|
||||
// Clearing restores the base identity and the base witness.
|
||||
e.clear_room_adapter();
|
||||
let back = e.process_cycle(&frames, cal, room, 4_000).unwrap();
|
||||
assert_eq!(back.provenance.model_version, "rfenc-v1");
|
||||
assert_eq!(back.witness, base.witness);
|
||||
}
|
||||
|
||||
/// Drift→recalibration advisor logic: a sustained low-coherence streak
|
||||
/// recommends refit; a single healthy cycle resets the streak; a
|
||||
/// change-point recommends immediately regardless of streak.
|
||||
#[test]
|
||||
fn recalibration_advisor_streak_and_change_point() {
|
||||
let mut adv = RecalibrationAdvisor {
|
||||
coherence_floor: 0.5,
|
||||
streak_threshold: 3,
|
||||
..Default::default()
|
||||
};
|
||||
// Healthy cycles never recommend and keep the streak at zero.
|
||||
for _ in 0..5 {
|
||||
assert!(!adv.observe(0.9, false));
|
||||
}
|
||||
assert_eq!(adv.streak(), 0);
|
||||
// Two low cycles: not yet.
|
||||
assert!(!adv.observe(0.2, false));
|
||||
assert!(!adv.observe(0.2, false));
|
||||
// Third consecutive low cycle: fire.
|
||||
assert!(adv.observe(0.2, false));
|
||||
// Recovery resets the streak.
|
||||
assert!(!adv.observe(0.9, false));
|
||||
assert_eq!(adv.streak(), 0);
|
||||
// A change-point recommends immediately, even at full coherence.
|
||||
assert!(adv.observe(0.9, true));
|
||||
}
|
||||
|
||||
/// Engine-level: clean coherent cycles never recommend recalibration (the
|
||||
/// advisor is wired into process_cycle and stays quiet on healthy input).
|
||||
#[test]
|
||||
fn healthy_cycles_do_not_recommend_recalibration() {
|
||||
let (mut e, room) = engine();
|
||||
e.set_recalibration_advisor(RecalibrationAdvisor {
|
||||
coherence_floor: 0.5,
|
||||
streak_threshold: 3,
|
||||
..Default::default()
|
||||
});
|
||||
let cal = CalibrationId(2);
|
||||
for i in 0..5u64 {
|
||||
let frames = [
|
||||
node_frame(0, 1_000 + i * 50_000, 56),
|
||||
node_frame(1, 1_001 + i * 50_000, 56),
|
||||
];
|
||||
let out = e.process_cycle(&frames, cal, room, i as i64).unwrap();
|
||||
assert!(!out.recalibration_recommended);
|
||||
}
|
||||
}
|
||||
|
||||
/// Maximum total coupling mass of an n-node mesh whose attention weights
|
||||
/// sum to 1 (coupling = wᵢ·wⱼ·n): Σ_{i<j} wᵢwⱼ·n = n(1−Σwᵢ²)/2 ≤ (n−1)/2.
|
||||
/// Any cut is a subset of the edges, so every achievable cut value is
|
||||
/// bounded by this mass — a risk threshold at or above it is *guaranteed*
|
||||
/// to be crossed (deterministic fixture, review finding 4).
|
||||
fn max_coupling_mass(n_nodes: usize) -> f64 {
|
||||
(n_nodes as f64 - 1.0) / 2.0
|
||||
}
|
||||
|
||||
/// Mesh guard wiring: a balanced 2-node cycle reports a mesh (cut exists)
|
||||
/// but never flags risk (min_nodes=3); a 3-node mesh whose cut value
|
||||
/// *deterministically* falls at or below the configured risk threshold
|
||||
/// (threshold = the provable upper bound on any achievable cut) is flagged
|
||||
/// at_risk, and the structural event feeds the recalibration advisor
|
||||
/// immediately — no conditional assertions (review finding 4).
|
||||
#[test]
|
||||
fn mesh_partition_risk_feeds_recalibration() {
|
||||
let (mut e, room) = engine();
|
||||
let cal = CalibrationId(3);
|
||||
|
||||
// Balanced 2-node mesh: report present, no risk.
|
||||
let out = e
|
||||
.process_cycle(&[node_frame(0, 1000, 56), node_frame(1, 1001, 56)], cal, room, 1)
|
||||
.unwrap();
|
||||
let mesh = out.mesh.expect("2-node mesh reports");
|
||||
assert!(!mesh.at_risk);
|
||||
assert!(!out.recalibration_recommended);
|
||||
|
||||
// 3-node mesh with the operator risk threshold set to the provable
|
||||
// cut upper bound: the crossing is deterministic regardless of the
|
||||
// fuser's exact weighting.
|
||||
e.mesh_guard_mut().risk_threshold = max_coupling_mass(3);
|
||||
let frames = [
|
||||
node_frame(0, 10_000_000, 56),
|
||||
node_frame(1, 10_000_001, 56),
|
||||
node_frame(2, 10_000_002, 56),
|
||||
];
|
||||
let out3 = e.process_cycle(&frames, cal, room, 2).unwrap();
|
||||
let m3 = out3.mesh.expect("3-node mesh reports");
|
||||
assert!(m3.at_risk, "cut ≤ threshold must flag partition risk");
|
||||
assert!(
|
||||
out3.recalibration_recommended,
|
||||
"mesh risk is a structural event — the advisor must fire immediately, no streak"
|
||||
);
|
||||
assert!(m3.cut_value.is_finite() && m3.cut_value >= 0.0);
|
||||
}
|
||||
|
||||
/// Mesh partition risk demotes the privacy class and shifts the witness —
|
||||
/// a fragmenting array makes the fused belief less trustworthy, so it is
|
||||
/// emitted at a more restricted class, and that demotion is auditable.
|
||||
/// Both cycles use the *same 3-node topology and frames*; the engines
|
||||
/// differ only in the forced mesh risk, so the witness delta is
|
||||
/// attributable to the risk demotion alone (review finding 4).
|
||||
#[test]
|
||||
fn mesh_risk_demotes_privacy_and_shifts_witness() {
|
||||
let cal = CalibrationId(8);
|
||||
let frames3 = [
|
||||
node_frame(0, 1000, 56),
|
||||
node_frame(1, 1001, 56),
|
||||
node_frame(2, 1002, 56),
|
||||
];
|
||||
|
||||
// Baseline: same topology, default risk threshold — clean cycle, not
|
||||
// demoted (PrivateHome → Anonymous), mesh healthy.
|
||||
let (mut e1, r1) = engine();
|
||||
let base = e1.process_cycle(&frames3, cal, r1, 5_000).unwrap();
|
||||
assert!(!base.mesh.as_ref().unwrap().at_risk);
|
||||
assert!(!base.demoted);
|
||||
assert_eq!(base.effective_class, PrivacyClass::Anonymous);
|
||||
|
||||
// Forced risk: identical frames/topology, threshold at the provable
|
||||
// cut upper bound so the crossing is deterministic.
|
||||
let (mut e2, r2) = engine();
|
||||
e2.mesh_guard_mut().risk_threshold = max_coupling_mass(3);
|
||||
let risky = e2.process_cycle(&frames3, cal, r2, 5_000).unwrap();
|
||||
assert!(risky.mesh.as_ref().unwrap().at_risk);
|
||||
assert!(risky.demoted, "mesh risk must demote");
|
||||
// PrivateHome base Anonymous(2) → demoted to Restricted(3).
|
||||
assert_eq!(risky.effective_class, PrivacyClass::Restricted);
|
||||
assert!(risky.provenance.privacy_decision.contains("Restricted"));
|
||||
assert_ne!(
|
||||
risky.witness, base.witness,
|
||||
"same topology, risk-only delta must shift the witness"
|
||||
);
|
||||
}
|
||||
|
||||
/// WorldGraph belief retention: the live loop appends one SemanticState per
|
||||
/// cycle; past the cap the oldest beliefs are evicted so graph memory is
|
||||
/// bounded, while structural nodes and the newest belief always survive.
|
||||
#[test]
|
||||
fn semantic_state_growth_is_bounded() {
|
||||
let (mut e, room) = engine();
|
||||
e.set_semantic_retention(5);
|
||||
let cal = CalibrationId(1);
|
||||
let mut last_id = None;
|
||||
let baseline_nodes = 2; // room + sensor
|
||||
for i in 0..20u64 {
|
||||
let frames = [
|
||||
node_frame(0, 1000 + i * 50_000, 56),
|
||||
node_frame(1, 1001 + i * 50_000, 56),
|
||||
];
|
||||
let out = e.process_cycle(&frames, cal, room, 5_000 + i as i64).unwrap();
|
||||
last_id = Some(out.semantic_id);
|
||||
assert!(e.world().node_count() <= baseline_nodes + 5);
|
||||
}
|
||||
// 20 cycles ran, only 5 beliefs remain, newest is still present.
|
||||
assert_eq!(e.world().node_count(), baseline_nodes + 5);
|
||||
assert!(e.world().node(last_id.unwrap()).is_some());
|
||||
// Structural nodes survive eviction.
|
||||
assert!(e.world().node(room).is_some());
|
||||
}
|
||||
|
||||
fn node_frame_scaled(node_id: u8, ts_us: u64, n_sub: usize, scale: f32) -> MultiBandCsiFrame {
|
||||
MultiBandCsiFrame {
|
||||
node_id,
|
||||
|
||||
@@ -0,0 +1,364 @@
|
||||
//! Mesh partition guard: dynamic min-cut over the live multistatic node graph.
|
||||
//!
|
||||
//! The fusion mesh (nodes = sensing nodes, edge weights = fusion coupling
|
||||
//! derived from per-node attention weights) changes *incrementally* at cycle
|
||||
//! rate — one node's coupling drifts, a node joins or drops. This module
|
||||
//! maintains a [`ruvector_mincut::DynamicMinCut`] over that graph and exposes,
|
||||
//! per cycle:
|
||||
//!
|
||||
//! - the **min-cut value** — the cheapest set of couplings whose loss splits
|
||||
//! the mesh in two: a principled, global "how close is the array to
|
||||
//! partitioning" number (vs per-node heuristics that miss multi-node
|
||||
//! structure);
|
||||
//! - the **weak side** — which specific nodes are about to partition (feeds
|
||||
//! failure/jamming triage, ADR-032 posture);
|
||||
//! - an **at-risk flag** consumed by the engine: it counts as a structural
|
||||
//! event for the drift→recalibration advisor.
|
||||
//!
|
||||
//! ## Cost model (the optimization)
|
||||
//!
|
||||
//! Weights are quantized (default 1/64; a *nonzero* coupling below one quantum
|
||||
//! saturates to quantum 1 so a live coupling is never erased — see
|
||||
//! [`MeshGuard::weight_quantum`]) and updates are **change-gated**: an
|
||||
//! edge is touched only when its quantized weight actually moves, so the
|
||||
//! steady-state cycle applies *zero* graph updates and reuses the cached cut —
|
||||
//! O(active-changes) per cycle, not O(n²) rebuilds. The exact (deterministic)
|
||||
//! algorithm is used; mesh sizes are ≤ tens of nodes, far inside its budget.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use ruvector_mincut::{DynamicMinCut, MinCutBuilder};
|
||||
|
||||
/// Per-cycle report from the mesh guard.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct MeshPartitionReport {
|
||||
/// Current min-cut value over the coupling graph (higher = more robust).
|
||||
pub cut_value: f64,
|
||||
/// True when the mesh has ≥ `min_nodes` nodes and the cut value fell to or
|
||||
/// below the risk threshold — the array is close to splitting.
|
||||
pub at_risk: bool,
|
||||
/// The smaller side of the min-cut partition (node ids): the nodes that
|
||||
/// would be isolated if the weak couplings failed.
|
||||
pub weak_side: Vec<u8>,
|
||||
/// Incremental edge updates applied this cycle (0 in steady state).
|
||||
pub updates_applied: usize,
|
||||
}
|
||||
|
||||
/// Dynamic min-cut guard over the live mesh.
|
||||
pub struct MeshGuard {
|
||||
mincut: Option<DynamicMinCut>,
|
||||
/// Node set the structure was built over (sorted). A change forces rebuild.
|
||||
nodes: Vec<u8>,
|
||||
/// Quantized edge weights currently installed, keyed `(u, v)` with `u < v`.
|
||||
edges: BTreeMap<(u8, u8), i64>,
|
||||
/// Weight quantum: weights are snapped to multiples of this before
|
||||
/// comparison/installation, gating out sub-quantum jitter.
|
||||
///
|
||||
/// Policy: a **nonzero** coupling below one quantum saturates to quantum 1
|
||||
/// instead of quantizing to 0 — quantization never erases a live coupling.
|
||||
/// (Without the floor, a balanced mesh of ≥ 65 nodes — attention weights
|
||||
/// ~1/n ⇒ couplings ~1/n < 1/64 — had every edge erased and was reported
|
||||
/// permanently "already partitioned"/at-risk.) Exact zero stays zero: a
|
||||
/// truly absent coupling *is* a partition. Relative weakness below one
|
||||
/// quantum is not resolved; lower this quantum if that resolution matters.
|
||||
pub weight_quantum: f64,
|
||||
/// Cut value at or below which the mesh counts as at partition risk.
|
||||
pub risk_threshold: f64,
|
||||
/// Minimum node count for risk to be meaningful (a 2-node mesh always has
|
||||
/// a trivial cut; default 3).
|
||||
pub min_nodes: usize,
|
||||
}
|
||||
|
||||
impl Default for MeshGuard {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
mincut: None,
|
||||
nodes: Vec::new(),
|
||||
edges: BTreeMap::new(),
|
||||
weight_quantum: 1.0 / 64.0,
|
||||
risk_threshold: 0.25,
|
||||
min_nodes: 3,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MeshGuard {
|
||||
/// Quantize a raw weight to the guard's grid (floor; weights are ≥ 0).
|
||||
/// Nonzero sub-quantum weights saturate to quantum 1 — see the
|
||||
/// [`Self::weight_quantum`] policy (review finding: sub-quantum couplings
|
||||
/// must not produce a false "already partitioned").
|
||||
fn quantize(&self, w: f64) -> i64 {
|
||||
let w = w.max(0.0);
|
||||
let q = (w / self.weight_quantum).floor() as i64;
|
||||
if q == 0 && w > 0.0 {
|
||||
1
|
||||
} else {
|
||||
q
|
||||
}
|
||||
}
|
||||
|
||||
/// Update the guard with this cycle's mesh: `nodes` are the contributing
|
||||
/// node ids and `coupling(i, j)` returns the fusion coupling between
|
||||
/// `nodes[i]` and `nodes[j]` (symmetric, ≥ 0).
|
||||
///
|
||||
/// Returns `None` for meshes of fewer than 2 nodes (no cut exists).
|
||||
pub fn update(
|
||||
&mut self,
|
||||
nodes: &[u8],
|
||||
coupling: impl Fn(usize, usize) -> f64,
|
||||
) -> Option<MeshPartitionReport> {
|
||||
if nodes.len() < 2 {
|
||||
// Mesh degenerated: drop state so a later rebuild starts clean.
|
||||
self.mincut = None;
|
||||
self.nodes.clear();
|
||||
self.edges.clear();
|
||||
return None;
|
||||
}
|
||||
let mut sorted: Vec<u8> = nodes.to_vec();
|
||||
sorted.sort_unstable();
|
||||
sorted.dedup();
|
||||
|
||||
// Desired quantized edge set for this cycle.
|
||||
let mut desired: BTreeMap<(u8, u8), i64> = BTreeMap::new();
|
||||
for i in 0..nodes.len() {
|
||||
for j in (i + 1)..nodes.len() {
|
||||
let (a, b) = if nodes[i] < nodes[j] {
|
||||
(nodes[i], nodes[j])
|
||||
} else {
|
||||
(nodes[j], nodes[i])
|
||||
};
|
||||
if a == b {
|
||||
continue;
|
||||
}
|
||||
let q = self.quantize(coupling(i, j));
|
||||
desired.insert((a, b), q);
|
||||
}
|
||||
}
|
||||
|
||||
// Change detection: count quantized-weight moves vs the installed set.
|
||||
let changed = if self.mincut.is_none() || self.nodes != sorted {
|
||||
usize::MAX // node set changed / first cycle: rebuild unconditionally
|
||||
} else {
|
||||
desired
|
||||
.iter()
|
||||
.filter(|(k, &q)| self.edges.get(k).copied().unwrap_or(0) != q)
|
||||
.count()
|
||||
};
|
||||
|
||||
let mut updates = 0usize;
|
||||
if changed > 0 {
|
||||
// Measured policy (criterion, 12-node mesh): a full exact rebuild
|
||||
// is ~170 µs while ONE DynamicMinCut delete+insert is ~240 µs —
|
||||
// the incremental machinery's overheads target much larger graphs.
|
||||
// At mesh scale the optimum is: change-gate aggressively (the
|
||||
// steady state below is ~7 µs and covers almost every cycle) and
|
||||
// rebuild whenever anything actually moved.
|
||||
let edges: Vec<(u64, u64, f64)> = desired
|
||||
.iter()
|
||||
.filter(|(_, &q)| q > 0)
|
||||
.map(|(&(a, b), &q)| {
|
||||
(u64::from(a), u64::from(b), q as f64 * self.weight_quantum)
|
||||
})
|
||||
.collect();
|
||||
updates = if changed == usize::MAX { edges.len() } else { changed };
|
||||
self.mincut = MinCutBuilder::new().exact().with_edges(edges).build().ok();
|
||||
self.nodes = sorted;
|
||||
self.edges = desired;
|
||||
}
|
||||
// changed == 0: steady state — zero graph work, cached cut reused.
|
||||
|
||||
// Nodes with no positive coupling never enter the cut structure (zero
|
||||
// edges are not installed) — they are already partitioned. Report them
|
||||
// as the degenerate cut before consulting the structure.
|
||||
let mut isolated: Vec<u8> = self
|
||||
.nodes
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|&v| {
|
||||
!self
|
||||
.edges
|
||||
.iter()
|
||||
.any(|(&(a, b), &q)| q > 0 && (a == v || b == v))
|
||||
})
|
||||
.collect();
|
||||
if !isolated.is_empty() {
|
||||
isolated.sort_unstable();
|
||||
return Some(MeshPartitionReport {
|
||||
cut_value: 0.0,
|
||||
at_risk: self.nodes.len() >= self.min_nodes,
|
||||
weak_side: isolated,
|
||||
updates_applied: updates,
|
||||
});
|
||||
}
|
||||
|
||||
let mc = self.mincut.as_ref()?;
|
||||
// A disconnected coupling graph is the degenerate cut: value 0.
|
||||
let cut_value = if mc.is_connected() { mc.min_cut_value() } else { 0.0 };
|
||||
let (side_a, side_b) = mc.partition();
|
||||
let weak_raw = if side_a.len() <= side_b.len() { side_a } else { side_b };
|
||||
let mut weak_side: Vec<u8> = weak_raw.into_iter().map(|v| v as u8).collect();
|
||||
weak_side.sort_unstable();
|
||||
let at_risk = self.nodes.len() >= self.min_nodes && cut_value <= self.risk_threshold;
|
||||
|
||||
Some(MeshPartitionReport { cut_value, at_risk, weak_side, updates_applied: updates })
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Triangle with one weakly-attached node: the cut isolates that node and
|
||||
/// the cut value equals its total coupling.
|
||||
#[test]
|
||||
fn weakly_attached_node_is_the_weak_side() {
|
||||
let mut g = MeshGuard::default();
|
||||
let nodes = [0u8, 1, 2];
|
||||
// 0–1 strongly coupled; node 2 hangs on by 0.05 + 0.05.
|
||||
let w = |i: usize, j: usize| match (i.min(j), i.max(j)) {
|
||||
(0, 1) => 1.0,
|
||||
_ => 0.05,
|
||||
};
|
||||
let r = g.update(&nodes, w).expect("3-node mesh");
|
||||
assert!(r.cut_value <= 0.13, "cut {} should be ~0.10", r.cut_value);
|
||||
assert_eq!(r.weak_side, vec![2]);
|
||||
assert!(r.at_risk, "weak coupling must flag partition risk");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strong_mesh_is_not_at_risk() {
|
||||
let mut g = MeshGuard::default();
|
||||
let r = g.update(&[0, 1, 2, 3], |_, _| 0.9).expect("mesh");
|
||||
assert!(r.cut_value > g.risk_threshold);
|
||||
assert!(!r.at_risk);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_node_mesh_reports_but_never_risks() {
|
||||
let mut g = MeshGuard::default();
|
||||
let r = g.update(&[0, 1], |_, _| 0.01).expect("2-node mesh");
|
||||
// Trivial cut exists but min_nodes=3 keeps the flag off.
|
||||
assert!(!r.at_risk);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fewer_than_two_nodes_yields_none() {
|
||||
let mut g = MeshGuard::default();
|
||||
assert!(g.update(&[7], |_, _| 1.0).is_none());
|
||||
assert!(g.update(&[], |_, _| 1.0).is_none());
|
||||
}
|
||||
|
||||
/// The optimization contract: identical weights on the next cycle apply
|
||||
/// zero updates; a sub-quantum wiggle also applies zero; a real change
|
||||
/// applies exactly the changed edges.
|
||||
#[test]
|
||||
fn steady_state_applies_zero_updates() {
|
||||
let mut g = MeshGuard::default();
|
||||
let nodes = [0u8, 1, 2, 3];
|
||||
let first = g.update(&nodes, |_, _| 0.5).unwrap();
|
||||
assert_eq!(first.updates_applied, 6); // cold build installs all edges
|
||||
|
||||
let second = g.update(&nodes, |_, _| 0.5).unwrap();
|
||||
assert_eq!(second.updates_applied, 0);
|
||||
|
||||
// Sub-quantum jitter (quantum is 1/64 ≈ 0.0156) is gated out.
|
||||
let third = g.update(&nodes, |_, _| 0.5 + 0.004).unwrap();
|
||||
assert_eq!(third.updates_applied, 0);
|
||||
|
||||
// One genuinely changed edge touches exactly one edge.
|
||||
let fourth = g
|
||||
.update(&nodes, |i, j| if (i.min(j), i.max(j)) == (0, 1) { 0.1 } else { 0.5 })
|
||||
.unwrap();
|
||||
assert_eq!(fourth.updates_applied, 1);
|
||||
}
|
||||
|
||||
/// Node set changes force a clean rebuild (drop/join handled correctly).
|
||||
#[test]
|
||||
fn node_join_and_drop_rebuild() {
|
||||
let mut g = MeshGuard::default();
|
||||
g.update(&[0, 1, 2], |_, _| 0.8).unwrap();
|
||||
// Node 3 joins.
|
||||
let joined = g.update(&[0, 1, 2, 3], |_, _| 0.8).unwrap();
|
||||
assert_eq!(joined.updates_applied, 6); // rebuild over 4 nodes
|
||||
// Node 0 drops.
|
||||
let dropped = g.update(&[1, 2, 3], |_, _| 0.8).unwrap();
|
||||
assert_eq!(dropped.updates_applied, 3);
|
||||
assert!(!dropped.at_risk);
|
||||
}
|
||||
|
||||
/// Determinism: same inputs, same report (cut value + weak side).
|
||||
#[test]
|
||||
fn reports_are_deterministic() {
|
||||
let run = || {
|
||||
let mut g = MeshGuard::default();
|
||||
let w = |i: usize, j: usize| match (i.min(j), i.max(j)) {
|
||||
(0, 1) => 0.9,
|
||||
(1, 2) => 0.6,
|
||||
_ => 0.07,
|
||||
};
|
||||
g.update(&[0, 1, 2], w).unwrap()
|
||||
};
|
||||
let a = run();
|
||||
let b = run();
|
||||
assert_eq!(a.cut_value.to_bits(), b.cut_value.to_bits());
|
||||
assert_eq!(a.weak_side, b.weak_side);
|
||||
}
|
||||
|
||||
/// Regression (review finding 3): a balanced mesh of ≥ 65 nodes has every
|
||||
/// pairwise coupling at ~1/n < quantum (1/64). The old floor-to-zero
|
||||
/// quantization erased all edges and reported the mesh permanently
|
||||
/// "already partitioned" (cut 0, at_risk). Nonzero sub-quantum couplings
|
||||
/// now saturate to one quantum, so the mesh reports a healthy cut.
|
||||
#[test]
|
||||
fn large_balanced_mesh_is_not_at_risk() {
|
||||
let mut g = MeshGuard::default();
|
||||
let nodes: Vec<u8> = (0..70u8).collect();
|
||||
// Attention-weight product coupling: (1/n)·(1/n)·n = 1/n ≈ 0.0143 < 1/64.
|
||||
let n = nodes.len() as f64;
|
||||
let r = g.update(&nodes, |_, _| 1.0 / n).expect("70-node mesh");
|
||||
assert!(
|
||||
r.cut_value > 0.0,
|
||||
"live couplings must not quantize to zero"
|
||||
);
|
||||
// Min cut isolates one node: 69 edges × one quantum (1/64) ≈ 1.08,
|
||||
// well above the 0.25 default risk threshold.
|
||||
assert!(r.cut_value > g.risk_threshold);
|
||||
assert!(
|
||||
!r.at_risk,
|
||||
"balanced large mesh must not be at partition risk"
|
||||
);
|
||||
assert!(r.weak_side.len() < nodes.len(), "no false full partition");
|
||||
}
|
||||
|
||||
/// Sub-quantum couplings saturate to one quantum but exact zero is still a
|
||||
/// real partition (the floor must not invent couplings).
|
||||
#[test]
|
||||
fn sub_quantum_saturates_but_zero_stays_zero() {
|
||||
let mut g = MeshGuard::default();
|
||||
// 0.001 < 1/64 everywhere: connected, tiny cut, flagged at risk
|
||||
// (cut = 2 × 1/64 ≈ 0.031 ≤ 0.25) — but NOT "already partitioned".
|
||||
let r = g.update(&[0, 1, 2], |_, _| 0.001).expect("mesh");
|
||||
assert!(r.cut_value > 0.0);
|
||||
assert!(r.at_risk);
|
||||
// Exact zero to node 2: degenerate cut 0, node 2 isolated.
|
||||
let mut g2 = MeshGuard::default();
|
||||
let r2 = g2
|
||||
.update(&[0, 1, 2], |i, j| if i == 2 || j == 2 { 0.0 } else { 0.5 })
|
||||
.expect("mesh");
|
||||
assert_eq!(r2.cut_value, 0.0);
|
||||
assert_eq!(r2.weak_side, vec![2]);
|
||||
}
|
||||
|
||||
/// A fully partitioned mesh (zero coupling to one node) reports cut 0.
|
||||
#[test]
|
||||
fn disconnected_mesh_is_cut_zero() {
|
||||
let mut g = MeshGuard::default();
|
||||
let w = |i: usize, j: usize| {
|
||||
if i == 2 || j == 2 { 0.0 } else { 0.9 }
|
||||
};
|
||||
let r = g.update(&[0, 1, 2], w).unwrap();
|
||||
assert_eq!(r.cut_value, 0.0);
|
||||
assert!(r.at_risk);
|
||||
assert_eq!(r.weak_side, vec![2]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user