mirror of
https://github.com/ruvnet/RuView
synced 2026-07-31 18:51:42 +00:00
29de574e63
* 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>
470 lines
20 KiB
Rust
470 lines
20 KiB
Rust
//! Live trust-path bridge: drive the governed [`StreamingEngine`] from the
|
|
//! sensing-server's live `NodeState` map.
|
|
//!
|
|
//! `multistatic_bridge.rs` already converts `NodeState` → `MultiBandCsiFrame`
|
|
//! and runs the *bare* `MultistaticFuser`. That path produces fused amplitudes
|
|
//! but skips the trust control plane: privacy demotion on contradiction, the
|
|
//! WorldGraph belief with mandatory provenance, and the deterministic witness
|
|
//! (ADR-135..146). This bridge routes the same live frames through
|
|
//! [`StreamingEngine::process_cycle`], so every governed belief carries
|
|
//! evidence + model + calibration + privacy decision and a BLAKE3 witness
|
|
//! (narrowing the gap called out in ADR-136 §8 and the beyond-SOTA system
|
|
//! review).
|
|
//!
|
|
//! ## Honest scope of the live-path governance
|
|
//!
|
|
//! The engine runs *alongside* the bare fusion path that feeds the live
|
|
//! `SensingUpdate`; it does not replace it. What the engine's decision **does**
|
|
//! gate on the live wire today: when a cycle is emitted at
|
|
//! [`PrivacyClass::Restricted`] (base mode or contradiction/mesh-risk
|
|
//! demotion), [`EngineBridge::suppress_raw_outputs`] is true and `main.rs`
|
|
//! strips the per-node raw amplitude vectors from the published update — the
|
|
//! same field mapping `wifi-densepose-bfld`'s privacy gate applies at
|
|
//! `Restricted` (drop amplitude/phase proxies). Trust state (latest witness,
|
|
//! effective class, recalibration flag, engine-error count) is readable on
|
|
//! `GET /api/v1/status`. Gating of the remaining *derived* outputs
|
|
//! (person count, classification, signal field) by privacy class is tracked
|
|
//! as a follow-up; until then those fields are published ungoverned.
|
|
//!
|
|
//! Determinism: this module reads server state and forwards explicit
|
|
//! timestamps/calibration ids; it introduces no wall-clock reads of its own, so
|
|
//! a given `(frames, calibration, now_ms)` always yields the same
|
|
//! [`TrustedOutput`] witness.
|
|
|
|
use std::collections::HashMap;
|
|
use std::time::{Duration, Instant};
|
|
|
|
use wifi_densepose_bfld::{PrivacyClass, PrivacyMode};
|
|
use wifi_densepose_engine::{AdapterInfo, EngineError, StreamingEngine, TrustedOutput};
|
|
use wifi_densepose_geo::types::GeoRegistration;
|
|
use wifi_densepose_signal::ruvsense::fusion_quality::CalibrationId;
|
|
use wifi_densepose_worldgraph::WorldId;
|
|
|
|
use super::multistatic_bridge::node_frames_from_states;
|
|
use super::NodeState;
|
|
|
|
/// Minimum spacing between engine-error warn logs (errors are still counted
|
|
/// every cycle; only the log line is rate-limited — a 20 Hz loop must not
|
|
/// emit 20 warns/s).
|
|
const ENGINE_ERROR_WARN_INTERVAL: Duration = Duration::from_secs(10);
|
|
|
|
/// Owns a [`StreamingEngine`] and the WorldGraph scope (one room + sensor) the
|
|
/// live sensing loop publishes beliefs into.
|
|
pub struct EngineBridge {
|
|
engine: StreamingEngine,
|
|
room: WorldId,
|
|
/// Nodes already wired into the WorldGraph as sensors (by `node_id`).
|
|
registered_nodes: HashMap<u8, WorldId>,
|
|
/// Calibration epoch applied to live frames until the ADR-135 baseline
|
|
/// stage supplies a real per-node id. Stable so witnesses are reproducible.
|
|
calibration: CalibrationId,
|
|
// ── Trust state observed from the most recent cycles (review finding 1:
|
|
// previously write-only fields on AppState; now recorded here and
|
|
// exposed via the status endpoint + output gating). ──────────────────
|
|
/// BLAKE3 witness of the most recent successful governed cycle.
|
|
last_witness: Option<[u8; 32]>,
|
|
/// Latest drift→recalibration recommendation (ADR-135 → ADR-150 §3.4).
|
|
recalibration_recommended: bool,
|
|
/// Privacy class the most recent cycle was emitted under (post-demotion).
|
|
effective_class: Option<PrivacyClass>,
|
|
/// Whether the most recent cycle was demoted (contradiction / mesh risk).
|
|
demoted: bool,
|
|
/// Total engine cycles that returned an error (previously swallowed by
|
|
/// `if let Some(Ok(..))` at the call sites).
|
|
engine_error_count: u64,
|
|
/// Last time an engine error was actually logged (rate limiter).
|
|
last_error_warn_at: Option<Instant>,
|
|
}
|
|
|
|
impl EngineBridge {
|
|
/// Build a bridge for one installation. `room_area_id`/`room_name` name the
|
|
/// observation scope; `mode` is the starting privacy mode.
|
|
pub fn new(mode: PrivacyMode, model_version: u16, room_area_id: &str, room_name: &str) -> Self {
|
|
let mut engine = StreamingEngine::new(mode, model_version, GeoRegistration::default());
|
|
let room = engine.add_room(room_area_id, room_name);
|
|
Self {
|
|
engine,
|
|
room,
|
|
registered_nodes: HashMap::new(),
|
|
calibration: CalibrationId(0x5256_0001), // "RV\0\x01" — placeholder epoch
|
|
last_witness: None,
|
|
recalibration_recommended: false,
|
|
effective_class: None,
|
|
demoted: false,
|
|
engine_error_count: 0,
|
|
last_error_warn_at: None,
|
|
}
|
|
}
|
|
|
|
/// Override the calibration epoch stamped onto live frames (ADR-135).
|
|
pub fn set_calibration(&mut self, calibration: CalibrationId) {
|
|
self.calibration = calibration;
|
|
}
|
|
|
|
/// Override the WorldGraph belief-retention cap (bounds memory on the live
|
|
/// loop; see `WorldGraph::prune_semantic_states`).
|
|
pub fn set_semantic_retention(&mut self, max_states: usize) {
|
|
self.engine.set_semantic_retention(max_states);
|
|
}
|
|
|
|
/// Switch the active privacy mode (operator/control-plane action).
|
|
pub fn set_privacy_mode(&mut self, mode: PrivacyMode) {
|
|
self.engine.set_privacy_mode(mode);
|
|
}
|
|
|
|
/// Activate a per-room calibration adapter (ADR-150 §3.4). The adapter's
|
|
/// content-derived id becomes part of provenance/witness from the next
|
|
/// cycle — weights can never swap silently on the live path.
|
|
pub fn set_room_adapter(&mut self, info: AdapterInfo) {
|
|
self.engine.set_room_adapter(info);
|
|
}
|
|
|
|
/// Deactivate the per-room adapter (revert to the shared base model).
|
|
pub fn clear_room_adapter(&mut self) {
|
|
self.engine.clear_room_adapter();
|
|
}
|
|
|
|
/// Borrow the engine (queries, WorldGraph snapshot, privacy audit).
|
|
pub fn engine(&self) -> &StreamingEngine {
|
|
&self.engine
|
|
}
|
|
|
|
/// Number of sensor nodes wired into the WorldGraph so far.
|
|
pub fn registered_node_count(&self) -> usize {
|
|
self.registered_nodes.len()
|
|
}
|
|
|
|
/// Run one governed trust cycle over the current live node states.
|
|
///
|
|
/// Returns `None` when no active node yields a frame (nothing to fuse —
|
|
/// the engine is not invoked, so no spurious belief is published). On a
|
|
/// real cycle it lazily wires any newly-seen node as a WorldGraph sensor,
|
|
/// then returns the witnessed [`TrustedOutput`] (or a fusion error).
|
|
///
|
|
/// `now_ms` is supplied by the caller (the sensing loop's clock), keeping
|
|
/// the bridge deterministic and replayable.
|
|
pub fn process_cycle_from_states(
|
|
&mut self,
|
|
node_states: &HashMap<u8, NodeState>,
|
|
now_ms: i64,
|
|
) -> Option<Result<TrustedOutput, EngineError>> {
|
|
let frames = node_frames_from_states(node_states);
|
|
if frames.is_empty() {
|
|
return None;
|
|
}
|
|
// Lazily register each contributing node as a sensor observing the room,
|
|
// so the privacy rollup can suppress it under identity-strict modes.
|
|
for f in &frames {
|
|
self.registered_nodes.entry(f.node_id).or_insert_with(|| {
|
|
self.engine
|
|
.add_sensor(&format!("node-{}", f.node_id), self.room)
|
|
});
|
|
}
|
|
Some(
|
|
self.engine
|
|
.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)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::collections::VecDeque;
|
|
use std::time::Instant;
|
|
use wifi_densepose_bfld::PrivacyClass;
|
|
|
|
fn node_state_with_history(amp: f64, n_sub: usize) -> NodeState {
|
|
let mut ns = NodeState::new();
|
|
let frame: Vec<f64> = (0..n_sub).map(|i| amp + 0.1 * i as f64).collect();
|
|
ns.frame_history = VecDeque::from(vec![frame]);
|
|
ns.last_frame_time = Some(Instant::now());
|
|
ns
|
|
}
|
|
|
|
fn two_node_states() -> HashMap<u8, NodeState> {
|
|
let mut m = HashMap::new();
|
|
m.insert(0u8, node_state_with_history(1.0, 56));
|
|
m.insert(1u8, node_state_with_history(1.05, 56));
|
|
m
|
|
}
|
|
|
|
#[test]
|
|
fn empty_states_produce_no_belief() {
|
|
let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "living_room", "Living Room");
|
|
let out = bridge.process_cycle_from_states(&HashMap::new(), 1_000);
|
|
assert!(out.is_none());
|
|
// No belief published, no sensor wired.
|
|
assert_eq!(bridge.registered_node_count(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn live_cycle_produces_witnessed_belief_with_provenance() {
|
|
let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "living_room", "Living Room");
|
|
let states = two_node_states();
|
|
let out = bridge
|
|
.process_cycle_from_states(&states, 10_000)
|
|
.expect("frames present")
|
|
.expect("fusion succeeds");
|
|
|
|
// Full provenance: evidence + model + calibration + privacy decision.
|
|
assert!(!out.provenance.evidence.is_empty());
|
|
assert_eq!(out.provenance.model_version, "rfenc-v1");
|
|
assert!(out.provenance.calibration_version.starts_with("cal:"));
|
|
assert!(out.provenance.privacy_decision.starts_with("PrivateHome/"));
|
|
// A witness was produced and the belief is in the WorldGraph.
|
|
assert_ne!(out.witness, [0u8; 32]);
|
|
assert!(bridge.engine().world().node(out.semantic_id).is_some());
|
|
// Both nodes are now wired as sensors.
|
|
assert_eq!(bridge.registered_node_count(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn live_path_is_deterministic() {
|
|
let states = two_node_states_fixed();
|
|
let run = || {
|
|
let mut b = EngineBridge::new(PrivacyMode::PrivateHome, 1, "r", "R");
|
|
b.process_cycle_from_states(&states, 5_000).unwrap().unwrap()
|
|
};
|
|
let a = run();
|
|
let b = run();
|
|
assert_eq!(a.witness, b.witness);
|
|
assert_eq!(a.provenance.calibration_version, b.provenance.calibration_version);
|
|
assert_eq!(a.effective_class, b.effective_class);
|
|
}
|
|
|
|
// Deterministic node states (no wall-clock in amplitude/history).
|
|
fn two_node_states_fixed() -> HashMap<u8, NodeState> {
|
|
let mut m = HashMap::new();
|
|
for (id, amp) in [(0u8, 1.0_f64), (1u8, 1.05)] {
|
|
let mut ns = NodeState::new();
|
|
ns.frame_history = VecDeque::from(vec![(0..56)
|
|
.map(|i| amp + 0.1 * i as f64)
|
|
.collect::<Vec<f64>>()]);
|
|
ns.last_frame_time = Some(Instant::now());
|
|
m.insert(id, ns);
|
|
}
|
|
m
|
|
}
|
|
|
|
#[test]
|
|
fn nodes_registered_once_across_cycles() {
|
|
let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "r", "R");
|
|
let states = two_node_states();
|
|
bridge.process_cycle_from_states(&states, 1_000);
|
|
bridge.process_cycle_from_states(&states, 2_000);
|
|
bridge.process_cycle_from_states(&states, 3_000);
|
|
// Still exactly two sensors — idempotent registration.
|
|
assert_eq!(bridge.registered_node_count(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn retention_bounds_world_graph_growth() {
|
|
let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "r", "R");
|
|
bridge.set_semantic_retention(5);
|
|
let states = two_node_states();
|
|
for i in 0..20i64 {
|
|
bridge.process_cycle_from_states(&states, 1_000 + i * 50);
|
|
}
|
|
// room + 2 sensors + at most 5 retained beliefs.
|
|
assert!(bridge.engine().world().node_count() <= 3 + 5);
|
|
}
|
|
|
|
#[test]
|
|
fn adapter_identity_flows_into_live_witness() {
|
|
let states = two_node_states_fixed();
|
|
let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "r", "R");
|
|
let base = bridge
|
|
.process_cycle_from_states(&states, 1_000)
|
|
.unwrap()
|
|
.unwrap();
|
|
bridge.set_room_adapter(AdapterInfo {
|
|
adapter_id: "deadbeefcafef00d".into(),
|
|
trained_samples: 120,
|
|
});
|
|
let adapted = bridge
|
|
.process_cycle_from_states(&states, 2_000)
|
|
.unwrap()
|
|
.unwrap();
|
|
assert!(adapted
|
|
.provenance
|
|
.model_version
|
|
.ends_with("+adapter:deadbeefcafef00d"));
|
|
assert_ne!(adapted.witness, base.witness);
|
|
// Clearing reverts to the base model identity.
|
|
bridge.clear_room_adapter();
|
|
let back = bridge
|
|
.process_cycle_from_states(&states, 3_000)
|
|
.unwrap()
|
|
.unwrap();
|
|
assert_eq!(back.provenance.model_version, "rfenc-v1");
|
|
}
|
|
|
|
/// Wiring (review finding 1): a live frame in → trust state recorded on
|
|
/// the bridge (witness, effective class, recalibration flag), readable by
|
|
/// the status endpoint, with a zero error count on the happy path.
|
|
#[test]
|
|
fn observe_cycle_records_trust_state() {
|
|
let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "r", "R");
|
|
assert!(bridge.last_trust_witness().is_none());
|
|
assert_eq!(bridge.effective_class(), None);
|
|
|
|
let out = bridge
|
|
.observe_cycle(&two_node_states(), 1_000)
|
|
.expect("two fresh nodes → governed cycle runs");
|
|
|
|
assert_eq!(bridge.last_trust_witness(), Some(out.witness));
|
|
assert_eq!(bridge.effective_class(), Some(out.effective_class));
|
|
assert_eq!(
|
|
bridge.recalibration_recommended(),
|
|
out.recalibration_recommended
|
|
);
|
|
assert_eq!(bridge.demoted(), out.demoted);
|
|
assert_eq!(bridge.engine_error_count(), 0);
|
|
// PrivateHome clean cycle → Anonymous → raw outputs NOT suppressed.
|
|
assert_eq!(bridge.effective_class(), Some(PrivacyClass::Anonymous));
|
|
assert!(!bridge.suppress_raw_outputs());
|
|
}
|
|
|
|
/// Error wiring (review finding 1a): two live nodes with mismatched
|
|
/// subcarrier counts make fusion return a `DimensionMismatch` →
|
|
/// `EngineError` — previously dropped by `if let Some(Ok(..))` at the
|
|
/// call sites. The counter must increment and the last good trust state
|
|
/// must survive a later failure.
|
|
#[test]
|
|
fn observe_cycle_counts_engine_errors() {
|
|
let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "r", "R");
|
|
let mut mismatched = HashMap::new();
|
|
mismatched.insert(0u8, node_state_with_history(1.0, 56));
|
|
mismatched.insert(1u8, node_state_with_history(1.05, 30)); // 30 ≠ 56 subcarriers
|
|
|
|
assert!(bridge.observe_cycle(&mismatched, 1_000).is_none());
|
|
assert_eq!(bridge.engine_error_count(), 1);
|
|
assert!(
|
|
bridge.last_trust_witness().is_none(),
|
|
"no witness from a failed cycle"
|
|
);
|
|
|
|
assert!(bridge.observe_cycle(&mismatched, 2_000).is_none());
|
|
assert_eq!(bridge.engine_error_count(), 2);
|
|
|
|
// A later good cycle records trust state; the audit count is kept.
|
|
let out = bridge.observe_cycle(&two_node_states(), 3_000);
|
|
assert!(out.is_some());
|
|
assert!(bridge.last_trust_witness().is_some());
|
|
assert_eq!(bridge.engine_error_count(), 2);
|
|
|
|
// And a subsequent failure keeps the last good witness readable.
|
|
assert!(bridge.observe_cycle(&mismatched, 4_000).is_none());
|
|
assert_eq!(bridge.engine_error_count(), 3);
|
|
assert!(bridge.last_trust_witness().is_some());
|
|
}
|
|
|
|
/// ADR-141 mapping (review finding 1c): a cycle emitted at class
|
|
/// Restricted flips `suppress_raw_outputs`, which `main.rs` uses to strip
|
|
/// per-node raw amplitude vectors from the live publish — the same field
|
|
/// mapping bfld's privacy gate applies at `Restricted`.
|
|
#[test]
|
|
fn restricted_class_suppresses_raw_outputs() {
|
|
let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "r", "R");
|
|
bridge.set_privacy_mode(PrivacyMode::StrictNoIdentity); // base = Restricted
|
|
bridge
|
|
.observe_cycle(&two_node_states(), 1_000)
|
|
.expect("cycle runs");
|
|
assert_eq!(bridge.effective_class(), Some(PrivacyClass::Restricted));
|
|
assert!(bridge.suppress_raw_outputs());
|
|
}
|
|
|
|
#[test]
|
|
fn identity_strict_mode_is_carried_into_provenance() {
|
|
let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "r", "R");
|
|
bridge.set_privacy_mode(PrivacyMode::StrictNoIdentity);
|
|
let out = bridge
|
|
.process_cycle_from_states(&two_node_states(), 7_000)
|
|
.unwrap()
|
|
.unwrap();
|
|
assert!(out.provenance.privacy_decision.starts_with("StrictNoIdentity/"));
|
|
// Effective class is a valid privacy class (sanity).
|
|
let _ = matches!(
|
|
out.effective_class,
|
|
PrivacyClass::Raw | PrivacyClass::Derived | PrivacyClass::Anonymous | PrivacyClass::Restricted
|
|
);
|
|
}
|
|
}
|