Files
ruvnet--RuView/v2/crates/wifi-densepose-engine/src/lib.rs
T
rUv 29de574e63 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>
2026-06-11 16:08:54 -04:00

1116 lines
48 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! # RuView Streaming Engine — integration layer
//!
//! This crate is the **composition root** that wires the ADR-135..146 building
//! blocks into one end-to-end *trust-traceable* pipeline cycle. Each block was
//! built and unit-tested independently; this crate proves they compose and that
//! the **trust throughline** holds end-to-end:
//!
//! > *Why believe the system when it says a person is present?* — every
//! > [`TrustedOutput`] names its **signal evidence** (ADR-137 `EvidenceRef`),
//! > its **model version** (ADR-136), its **calibration version** (ADR-135
//! > baseline id, ADR-136 `calibration_id`), and the **privacy decision**
//! > (ADR-141 mode → class) it was emitted under — and is anchored as a
//! > provenance-bearing node in the ADR-139 WorldGraph.
//!
//! One [`StreamingEngine::process_cycle`] performs, in order:
//! 1. **Fuse + score** the node frames (ADR-137 `fuse_scored`) → `QualityScore`
//! with per-node weights, evidence, and tolerated contradiction flags.
//! 2. **Stamp calibration provenance** (ADR-135/136): the `CalibrationId` the
//! calibration stage applied is recorded on the `QualityScore`.
//! 3. **Privacy control plane** (ADR-141): if the fusion recorded a tolerated
//! contradiction, the active privacy class is **demoted one step** before
//! emission (monotonic — information only ever removed).
//! 4. **Semantic state** (ADR-139/140): a `SemanticState` node is appended to
//! the WorldGraph with mandatory provenance and a `DerivedFrom` edge to the
//! room it was observed in.
//!
//! What is intentionally *not* here: the live 20 Hz I/O loop (sensing-server),
//! UWB hardware (ADR-144), and model training (ADR-146). This is the
//! composition + validation layer those will plug into.
#![forbid(unsafe_code)]
use std::collections::BTreeMap;
use wifi_densepose_bfld::{PrivacyAction, PrivacyClass, PrivacyMode, PrivacyModeRegistry};
use wifi_densepose_geo::types::GeoRegistration;
use wifi_densepose_ruvector::viewpoint::coherence::ClockQualityScore;
use wifi_densepose_signal::ruvsense::fusion_quality::CalibrationId;
use wifi_densepose_signal::ruvsense::multistatic::{MultistaticConfig, MultistaticFuser};
use wifi_densepose_signal::ruvsense::{
ArrayCoordinator, ArrayCoordinatorConfig, ArrayNodeInput, ChangePoint, DirectionalEvidence,
EvolutionTracker, MultiBandCsiFrame, QualityScore, ReflectorObservation, RfSlam,
};
use wifi_densepose_worldgraph::{
AnchorKind, EnuPoint, PrivacyRollup, SemanticProvenance, WorldEdge, WorldGraph, WorldGraphError,
WorldId, WorldNode, ZoneBoundsEnu,
};
pub mod mesh_guard;
pub use mesh_guard::{MeshGuard, MeshPartitionReport};
/// Errors from an engine cycle.
#[derive(Debug)]
pub enum EngineError {
/// Multistatic fusion failed (no frames, timestamp spread, dimension mismatch).
Fusion(wifi_densepose_signal::ruvsense::multistatic::MultistaticError),
}
impl core::fmt::Display for EngineError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
EngineError::Fusion(e) => write!(f, "fusion error: {e}"),
}
}
}
impl std::error::Error for EngineError {}
impl From<wifi_densepose_signal::ruvsense::multistatic::MultistaticError> for EngineError {
fn from(e: wifi_densepose_signal::ruvsense::multistatic::MultistaticError) -> Self {
EngineError::Fusion(e)
}
}
/// Geometry of a sensing node, needed to run the ADR-138 array coordinator.
#[derive(Debug, Clone, Copy)]
struct NodeGeom {
x: f32,
y: f32,
azimuth: f32,
}
/// The auditable result of one engine cycle — the trust chain made concrete.
#[derive(Debug, Clone)]
pub struct TrustedOutput {
/// The `SemanticState` node id created in the WorldGraph.
pub semantic_id: WorldId,
/// The fusion quality record (evidence + contradictions + calibration).
pub quality: QualityScore,
/// The privacy class the output was emitted under (after any demotion).
pub effective_class: PrivacyClass,
/// Whether a tolerated contradiction forced a privacy demotion this cycle.
pub demoted: bool,
/// The mandatory provenance attached to the semantic node.
pub provenance: SemanticProvenance,
/// ADR-138 directional evidence, when node geometry is registered for every
/// contributing node (else `None`).
pub directional: Option<DirectionalEvidence>,
/// ADR-142 cross-link change-point detected this cycle, if any (and the
/// `Event` node it was recorded as in the WorldGraph).
pub change_point: Option<(ChangePoint, WorldId)>,
/// 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.
pub struct StreamingEngine {
fuser: MultistaticFuser,
coherence_accept: f32,
privacy: PrivacyModeRegistry,
world: WorldGraph,
model_version: u16,
cycle: u64,
// ADR-138: array coordinator + per-node geometry (by frame node_id).
array: ArrayCoordinator,
node_geom: BTreeMap<u8, NodeGeom>,
// ADR-142: per-link evolution tracker (sized lazily to the node count).
evolution: Option<EvolutionTracker>,
// ADR-143: persistent reflector discovery (v2 mode).
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 {
/// Build an engine with a starting privacy mode and model version. The
/// WorldGraph is registered to the installation origin.
#[must_use]
pub fn new(mode: PrivacyMode, model_version: u16, registration: GeoRegistration) -> Self {
Self {
fuser: MultistaticFuser::with_config(MultistaticConfig::default()),
coherence_accept: 0.85,
privacy: PrivacyModeRegistry::new(mode),
world: WorldGraph::new(registration),
model_version,
cycle: 0,
array: ArrayCoordinator::new(ArrayCoordinatorConfig::default()),
node_geom: BTreeMap::new(),
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
/// modes). Returns the (stable) WorldGraph id.
pub fn update_person_track(
&mut self,
track_id: u64,
x: f32,
y: f32,
room: WorldId,
sensor: WorldId,
) -> WorldId {
let existing = self.person_tracks.get(&track_id).copied();
let node = WorldNode::PersonTrack {
id: existing.unwrap_or(WorldId::UNASSIGNED),
track_id,
last_position: EnuPoint { east_m: f64::from(x), north_m: f64::from(y), up_m: 0.0 },
reid_embedding_ref: None,
};
let id = self.world.upsert_node(node);
if existing.is_none() {
self.person_tracks.insert(track_id, id);
let _ = self.world.add_edge(id, room, WorldEdge::LocatedIn { since_unix_ms: 0 });
let _ = self.world.add_edge(
sensor,
id,
WorldEdge::Observes { quality: 1.0, last_seen_unix_ms: 0 },
);
}
id
}
/// ADR-139 §2.4 / ADR-141: materialise `PrivacyLimitedBy` edges for the
/// active privacy mode. Under an identity-suppressing mode, `person_track`
/// observations are denied; the rollup names what was suppressed.
pub fn apply_active_privacy_mode(&mut self) -> PrivacyRollup {
let mode = self.privacy.active_mode();
let suppress_identity = self.privacy.is_action_enforced(PrivacyAction::SuppressIdentity);
self.world.apply_privacy_mode(
&format!("{mode:?}"),
"SuppressIdentity",
move |_sensor_kind, node_kind| !(suppress_identity && node_kind == "person_track"),
)
}
/// Persist the WorldGraph as deterministic JSON (the RVF payload). Contains
/// only graph nodes/edges — **never** raw RF frames.
///
/// # Errors
/// [`WorldGraphError`] on serialisation failure.
pub fn snapshot_json(&self) -> Result<Vec<u8>, WorldGraphError> {
self.world.to_json()
}
/// Register a contributing node's geometry (ADR-138). When every frame's
/// `node_id` in a cycle has a registered geometry, the cycle runs the array
/// coordinator and folds its contradictions into the privacy decision.
pub fn register_node_geometry(&mut self, node_id: u8, x: f32, y: f32, azimuth: f32) {
self.node_geom.insert(node_id, NodeGeom { x, y, azimuth });
}
/// Ingest CIR-derived reflector sightings (ADR-143) and persist any newly
/// stable static anchors into the WorldGraph as `ObjectAnchor` nodes.
/// Returns the WorldGraph ids written this call.
pub fn ingest_reflectors(&mut self, observations: &[ReflectorObservation]) -> Vec<WorldId> {
for obs in observations {
self.slam.observe(obs);
}
let mut written = Vec::new();
for (pos, class) in self.slam.static_anchors(0.05, 1.0) {
let kind = match class {
wifi_densepose_signal::ruvsense::ReflectorClass::Wall => AnchorKind::Reflector,
wifi_densepose_signal::ruvsense::ReflectorClass::Furniture => AnchorKind::Furniture,
wifi_densepose_signal::ruvsense::ReflectorClass::Mobile => continue,
};
let id = self.world.upsert_node(WorldNode::ObjectAnchor {
id: WorldId::UNASSIGNED,
position: EnuPoint { east_m: pos[0], north_m: pos[1], up_m: pos[2] },
anchor_kind: kind,
confidence: 0.9,
});
written.push(id);
}
written
}
/// Register a room and return its WorldGraph id (the observation scope).
pub fn add_room(&mut self, area_id: &str, name: &str) -> WorldId {
self.world.upsert_node(WorldNode::Room {
id: WorldId::UNASSIGNED,
area_id: Some(area_id.to_string()),
name: name.to_string(),
bounds_enu: ZoneBoundsEnu::Rectangle { min_e: 0.0, min_n: 0.0, max_e: 5.0, max_n: 4.0 },
floor: 0,
})
}
/// Register a sensor node and an `observes` edge to a room.
pub fn add_sensor(&mut self, device_id: &str, room: WorldId) -> WorldId {
let id = self.world.upsert_node(WorldNode::Sensor {
id: WorldId::UNASSIGNED,
device_id: device_id.to_string(),
position: EnuPoint { east_m: 0.0, north_m: 0.0, up_m: 0.0 },
modality: wifi_densepose_worldgraph::SensorModality::WifiCsi,
});
let _ = self.world.add_edge(
id,
room,
WorldEdge::Observes { quality: 1.0, last_seen_unix_ms: 0 },
);
id
}
/// Switch the active privacy mode (records a hash-chained attestation).
pub fn set_privacy_mode(&mut self, mode: PrivacyMode) {
self.privacy.set_mode(mode);
}
/// Borrow the WorldGraph (for queries / persistence).
#[must_use]
pub fn world(&self) -> &WorldGraph {
&self.world
}
/// Borrow the privacy registry (for attestation audit).
#[must_use]
pub fn privacy(&self) -> &PrivacyModeRegistry {
&self.privacy
}
/// Cycles processed so far.
#[must_use]
pub fn cycle_count(&self) -> u64 {
self.cycle
}
/// Run one full trust-traceable cycle (see crate docs for the steps).
///
/// `calibration` is the [`CalibrationId`] the calibration stage applied to
/// these frames (ADR-135 `BaselineCalibration::calibration_id()`); `room` is
/// the observation scope (an existing WorldGraph Room id).
///
/// # Errors
/// [`EngineError::Fusion`] if multistatic fusion rejects the input.
pub fn process_cycle(
&mut self,
node_frames: &[MultiBandCsiFrame],
calibration: CalibrationId,
room: WorldId,
now_ms: i64,
) -> Result<TrustedOutput, EngineError> {
// Uniform-calibration convenience: every node shares one epoch.
let cals = vec![Some(calibration); node_frames.len()];
self.process_cycle_calibrated(node_frames, &cals, room, now_ms)
}
/// Like [`Self::process_cycle`] but with a **per-node** calibration epoch
/// (ADR-137 §2.3). If the nodes' calibrations disagree, fusion raises a
/// `CalibrationIdMismatch`, the score's `calibration_id` is `None`, and the
/// privacy class is demoted — proving the calibration → trust → privacy path.
///
/// # Errors
/// [`EngineError::Fusion`] if multistatic fusion rejects the input.
pub fn process_cycle_calibrated(
&mut self,
node_frames: &[MultiBandCsiFrame],
calibrations: &[Option<CalibrationId>],
room: WorldId,
now_ms: i64,
) -> Result<TrustedOutput, EngineError> {
// 1. Array coordination (ADR-138) — only when geometry is known for
// every contributing node. Its contradictions feed the privacy gate.
let directional = self.coordinate_array(node_frames);
let array_contradiction =
directional.as_ref().is_some_and(|d| !d.contradictions.is_empty());
// 2. Fuse + score with per-node calibration (ADR-137 §2.3).
let (fused, quality) =
self.fuser.fuse_scored_calibrated(node_frames, calibrations, self.coherence_accept)?;
// 4. Evolution change-point (ADR-142) over per-node mean amplitude.
let change_point = self.track_evolution(node_frames, now_ms, room);
// 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 || mesh_at_risk;
let effective_class = if demoted { demote_one(base_class) } else { base_class };
// 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,
calibration_version,
privacy_decision: format!("{:?}/{:?}", self.privacy.active_mode(), effective_class),
};
let statement = format!(
"occupancy coherence={:.2} nodes={} demoted={}",
quality.base_coherence, fused.active_nodes, demoted
);
let semantic_id = self.world.add_semantic_state(
statement,
quality.penalized_coherence(),
now_ms,
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);
// 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,
quality,
effective_class,
demoted,
provenance,
directional,
change_point,
witness,
recalibration_recommended,
mesh,
})
}
/// ADR-138: build per-node array inputs and coordinate, iff every frame's
/// `node_id` has a registered geometry. Returns `None` otherwise.
fn coordinate_array(&self, node_frames: &[MultiBandCsiFrame]) -> Option<DirectionalEvidence> {
if node_frames.is_empty() {
return None;
}
let mut inputs = Vec::with_capacity(node_frames.len());
for f in node_frames {
let g = self.node_geom.get(&f.node_id)?; // bail if any node lacks geometry
inputs.push(ArrayNodeInput {
node_id: u32::from(f.node_id),
position: (g.x, g.y),
azimuth: g.azimuth,
coherence: f.coherence,
clock: ClockQualityScore { offset_stdev_us: 50.0, age_us: 1_000, valid: true },
amplitude: f.channel_frames.first().map(|cf| cf.amplitude.clone()),
});
}
Some(self.array.coordinate(&inputs))
}
/// ADR-142: fold per-node mean amplitude into the evolution tracker and,
/// on a cross-link change-point, record an `Event` node in the WorldGraph.
fn track_evolution(
&mut self,
node_frames: &[MultiBandCsiFrame],
now_ms: i64,
room: WorldId,
) -> Option<(ChangePoint, WorldId)> {
let values: Vec<f64> = node_frames
.iter()
.filter_map(|f| f.channel_frames.first())
.map(|cf| {
if cf.amplitude.is_empty() {
0.0
} else {
cf.amplitude.iter().map(|&a| f64::from(a)).sum::<f64>() / cf.amplitude.len() as f64
}
})
.collect();
if values.is_empty() {
return None;
}
let n = values.len();
let tracker = self
.evolution
.get_or_insert_with(|| EvolutionTracker::new(n, 2.0, (n / 2).max(2)));
// Node count must be stable for the tracker to remain meaningful.
if tracker.n_links() != n {
return None;
}
let cp = tracker.observe_window(&values)?;
let event = self.world.upsert_node(WorldNode::Event {
id: WorldId::UNASSIGNED,
event_type: "baseline_topology_change".to_string(),
at_unix_ms: now_ms,
located_in: Some(room),
});
let _ = self.world.add_edge(event, room, WorldEdge::LocatedIn { since_unix_ms: now_ms });
Some((cp, event))
}
}
/// Deterministic BLAKE3 witness over a trust decision: the provenance tuple
/// (evidence ‖ model ‖ calibration ‖ privacy decision) plus the effective
/// privacy-class byte. Stable across runs for identical decisions — the
/// "signed operational belief" fingerprint (ADR-137 §2.7 / ADR-028).
fn witness_of(p: &SemanticProvenance, class: PrivacyClass) -> [u8; 32] {
let mut h = blake3::Hasher::new();
for e in &p.evidence {
h.update(e.as_bytes());
h.update(b"\x1f");
}
h.update(p.model_version.as_bytes());
h.update(p.calibration_version.as_bytes());
h.update(p.privacy_decision.as_bytes());
h.update(&[class.as_u8()]);
*h.finalize().as_bytes()
}
/// Demote a privacy class by one step (more restrictive), clamped at `Restricted`.
/// Monotonic: information is only ever removed (ADR-120/141).
fn demote_one(c: PrivacyClass) -> PrivacyClass {
let next = (c.as_u8() + 1).min(PrivacyClass::Restricted.as_u8());
PrivacyClass::try_from(next).unwrap_or(PrivacyClass::Restricted)
}
#[cfg(test)]
mod tests {
use super::*;
use wifi_densepose_signal::hardware_norm::{CanonicalCsiFrame, HardwareType};
fn node_frame(node_id: u8, ts_us: u64, n_sub: usize) -> MultiBandCsiFrame {
MultiBandCsiFrame {
node_id,
timestamp_us: ts_us,
channel_frames: vec![CanonicalCsiFrame {
amplitude: (0..n_sub).map(|i| 1.0 + 0.1 * i as f32).collect(),
phase: (0..n_sub).map(|i| i as f32 * 0.05).collect(),
hardware_type: HardwareType::Esp32S3,
}],
frequencies_mhz: vec![2412],
coherence: 0.9,
}
}
fn engine() -> (StreamingEngine, WorldId) {
let mut e = StreamingEngine::new(PrivacyMode::PrivateHome, 1, GeoRegistration::default());
let room = e.add_room("living_room", "Living Room");
e.add_sensor("esp32-com9", room);
(e, room)
}
/// End-to-end trust invariant: a clean cycle produces a SemanticState whose
/// provenance names evidence + model + calibration + privacy decision, and
/// the calibration id flows from input → QualityScore → provenance.
#[test]
fn cycle_carries_full_provenance() {
let (mut e, room) = engine();
let cal = CalibrationId(0xABCD_1234);
let frames = [node_frame(0, 1000, 56), node_frame(1, 1001, 56)];
let out = e.process_cycle(&frames, cal, room, 10_000).unwrap();
// Calibration flows all the way through.
assert_eq!(out.quality.calibration_id, Some(cal));
assert_eq!(out.provenance.calibration_version, "cal:00000000abcd1234");
// Model + privacy provenance present.
assert_eq!(out.provenance.model_version, "rfenc-v1");
assert!(out.provenance.privacy_decision.starts_with("PrivateHome/"));
// Evidence refs recorded.
assert!(!out.provenance.evidence.is_empty());
// Clean cycle (tight timestamps) → no demotion, stays Anonymous (PrivateHome).
assert!(!out.demoted);
assert_eq!(out.effective_class, PrivacyClass::Anonymous);
// The SemanticState is in the graph with a DerivedFrom edge to the room.
assert!(e.world().node(out.semantic_id).is_some());
assert!(e
.world()
.neighbors(out.semantic_id)
.iter()
.any(|(to, edge)| *to == room && matches!(edge, WorldEdge::DerivedFrom { .. })));
}
/// A tolerated contradiction (loose timestamp spread, within the hard guard)
/// demotes the privacy class one step — proving ADR-137 → ADR-141 wiring.
#[test]
fn contradiction_demotes_privacy() {
let (mut e, room) = engine();
let cal = CalibrationId(7);
// 2 ms spread: within the 5 ms hard guard but above the 1 ms soft guard.
let frames = [node_frame(0, 1000, 56), node_frame(1, 3000, 56)];
let out = e.process_cycle(&frames, cal, room, 20_000).unwrap();
assert!(out.demoted, "loose alignment must demote");
// PrivateHome base = Anonymous(2) → demoted to Restricted(3).
assert_eq!(out.effective_class, PrivacyClass::Restricted);
assert!(out.provenance.privacy_decision.contains("Restricted"));
// Penalized coherence is below the base coherence.
assert!(out.quality.penalized_coherence() <= out.quality.base_coherence);
}
/// Determinism: identical input twice → identical provenance + class
/// (the ADR-136 witness-replay spirit, end-to-end through the engine).
#[test]
fn cycle_is_deterministic() {
let cal = CalibrationId(42);
let frames = [node_frame(0, 1000, 56), node_frame(1, 1001, 56)];
let (mut e1, r1) = engine();
let o1 = e1.process_cycle(&frames, cal, r1, 5_000).unwrap();
let (mut e2, r2) = engine();
let o2 = e2.process_cycle(&frames, cal, r2, 5_000).unwrap();
assert_eq!(o1.provenance.calibration_version, o2.provenance.calibration_version);
assert_eq!(o1.provenance.evidence, o2.provenance.evidence);
assert_eq!(o1.effective_class, o2.effective_class);
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 ≤ (n1)/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,
timestamp_us: ts_us,
channel_frames: vec![CanonicalCsiFrame {
amplitude: (0..n_sub).map(|i| scale * (1.0 + 0.1 * i as f32)).collect(),
phase: (0..n_sub).map(|i| i as f32 * 0.05).collect(),
hardware_type: HardwareType::Esp32S3,
}],
frequencies_mhz: vec![2412],
coherence: 0.9,
}
}
/// ADR-138 composed: with node geometry registered, the cycle produces
/// directional evidence (admitted nodes + weights).
#[test]
fn array_coordinator_runs_when_geometry_registered() {
use std::f32::consts::PI;
let (mut e, room) = engine();
e.register_node_geometry(0, 1.0, 0.0, 0.0);
e.register_node_geometry(1, -1.0, 0.0, PI); // opposite → good diversity
let out = e
.process_cycle(&[node_frame(0, 1000, 56), node_frame(1, 1001, 56)], CalibrationId(1), room, 1)
.unwrap();
let d = out.directional.expect("geometry registered → directional evidence");
assert_eq!(d.n_admitted, 2);
assert!((d.weights.iter().map(|(_, w)| *w).sum::<f32>() - 1.0).abs() < 1e-3);
// Well-separated, coherent nodes → no array contradiction → no demotion.
assert!(!out.demoted);
}
/// ADR-138 composed: poor geometry (near-colinear nodes) raises a
/// GeometryInsufficient contradiction that demotes privacy.
#[test]
fn array_geometry_insufficient_demotes() {
let (mut e, room) = engine();
e.register_node_geometry(0, 1.0, 0.0, 0.0);
e.register_node_geometry(1, 1.0, 0.01, 0.01); // nearly colinear → low GDI
let out = e
.process_cycle(&[node_frame(0, 1000, 56), node_frame(1, 1001, 56)], CalibrationId(1), room, 1)
.unwrap();
let d = out.directional.unwrap();
assert!(!d.contradictions.is_empty(), "insufficient geometry flagged");
assert!(out.demoted && out.effective_class == PrivacyClass::Restricted);
}
/// ADR-142 composed: a sustained baseline then a simultaneous amplitude
/// shift on both links yields a change-point + an Event node in the graph.
#[test]
fn evolution_change_point_recorded_as_event() {
let (mut e, room) = engine();
let cal = CalibrationId(1);
// Jittered baseline so each link has non-zero std (constant std=0 is undefined).
for i in 0..30u64 {
let s = if i % 2 == 0 { 0.99 } else { 1.01 };
let out = e
.process_cycle(&[node_frame_scaled(0, 1000, 56, s), node_frame_scaled(1, 1001, 56, s)], cal, room, i as i64)
.unwrap();
assert!(out.change_point.is_none(), "baseline must not trip a change-point");
}
// Large simultaneous excursion on both links → change-point.
let out = e
.process_cycle(&[node_frame_scaled(0, 1000, 56, 1.6), node_frame_scaled(1, 1001, 56, 1.6)], cal, room, 99)
.unwrap();
let (_, event_id) = out.change_point.expect("simultaneous shift → change-point");
assert!(matches!(
e.world().node(event_id),
Some(WorldNode::Event { event_type, .. }) if event_type == "baseline_topology_change"
));
}
/// ADR-143 composed: ingesting stable reflector sightings writes an
/// ObjectAnchor node into the WorldGraph.
#[test]
fn reflector_ingestion_writes_object_anchors() {
use wifi_densepose_signal::ruvsense::ReflectorObservation;
let (mut e, _room) = engine();
let day_ns = 86_400_000_000_000u64;
// 8 tight, coherent sightings spanning ~a day → a stable Wall anchor.
let obs: Vec<ReflectorObservation> = (0..8u64)
.map(|i| {
let j = if i % 2 == 0 { 0.005 } else { -0.005 };
ReflectorObservation { position: [3.0 + j, 1.0, 0.0], delay_ns: 12.0, coherence: 0.9, at_ns: i * (day_ns / 8) }
})
.collect();
let written = e.ingest_reflectors(&obs);
assert!(!written.is_empty(), "stable reflector → ObjectAnchor written");
assert!(matches!(
e.world().node(written[0]),
Some(WorldNode::ObjectAnchor { .. })
));
}
/// ADR-137 acceptance (the trust-root path):
/// `two calibrated frames -> calibration mismatch -> QualityScore
/// contradiction -> Restricted -> calibration_id None -> witness stable`.
#[test]
fn calibration_mismatch_demotes_and_witness_stable() {
let run = || {
let (mut e, room) = engine();
// PrivateHome base = Anonymous; mismatch must demote to Restricted.
e.process_cycle_calibrated(
&[node_frame(0, 1000, 56), node_frame(1, 1001, 56)],
&[Some(CalibrationId(1)), Some(CalibrationId(2))], // DISAGREE
room,
1,
)
.unwrap()
};
let out = run();
// QualityScore raised the contradiction; no single calibration epoch.
assert!(out.quality.forces_privacy_demotion());
assert_eq!(out.quality.calibration_id, None);
assert_eq!(out.provenance.calibration_version, "cal:none");
// BFLD class demoted to Restricted (identity surface removed downstream).
assert!(out.demoted);
assert_eq!(out.effective_class, PrivacyClass::Restricted);
// Witness is deterministic across identical runs.
assert_eq!(out.witness, run().witness);
assert_ne!(out.witness, [0u8; 32]);
}
/// Agreeing calibrations set the epoch and do NOT demote (the happy path
/// counterpart, proving the mismatch test isn't trivially always-demoting).
#[test]
fn matching_calibration_sets_epoch_no_demotion() {
let (mut e, room) = engine();
let cal = CalibrationId(0xABCD);
let out = e
.process_cycle_calibrated(
&[node_frame(0, 1000, 56), node_frame(1, 1001, 56)],
&[Some(cal), Some(cal)],
room,
1,
)
.unwrap();
assert_eq!(out.quality.calibration_id, Some(cal));
assert!(!out.demoted);
assert_eq!(out.effective_class, PrivacyClass::Anonymous);
}
/// ADR-139 live-loop acceptance (the architecture-proving path):
/// `live_frame -> fusion_event -> worldgraph_update -> privacy_rollup ->
/// persist -> reload -> same_contents`, with NO raw RF frame persisted.
#[test]
fn live_frame_to_reload_same_contents() {
let mut e =
StreamingEngine::new(PrivacyMode::StrictNoIdentity, 1, GeoRegistration::default());
let room = e.add_room("living_room", "Living Room");
let sensor = e.add_sensor("esp32-com9", room);
// live_frame -> fusion_event -> worldgraph_update (SemanticState).
let out = e
.process_cycle(&[node_frame(0, 1000, 56), node_frame(1, 1001, 56)], CalibrationId(9), room, 100)
.unwrap();
// person track feeding.
let pt = e.update_person_track(7, 2.0, 2.0, room, sensor);
// privacy_rollup: StrictNoIdentity suppresses the person_track.
let rollup = e.apply_active_privacy_mode();
assert!(rollup.suppressed_nodes.contains(&pt), "person track suppressed");
assert!(rollup.denied_pairs.iter().any(|(_s, n)| *n == pt));
// persist.
let bytes = e.snapshot_json().unwrap();
// No raw RF frame persisted — the snapshot is graph nodes/edges only.
let json = String::from_utf8(bytes.clone()).unwrap();
assert!(!json.contains("\"amplitude\"") && !json.contains("\"data\""), "no raw RF in snapshot");
// reload.
let reloaded = WorldGraph::from_json(&bytes).unwrap();
// same_contents: node count, area resolution, the SemanticState + track,
// and an identical room-contents query before vs after reload.
assert_eq!(reloaded.node_count(), e.world().node_count());
assert_eq!(reloaded.room_for_area("living_room"), e.world().room_for_area("living_room"));
assert!(reloaded.node(out.semantic_id).is_some());
assert!(reloaded.node(pt).is_some());
let mut before = e.world().contents_of(room);
before.sort_by_key(|w| w.0);
let mut after = reloaded.contents_of(room);
after.sort_by_key(|w| w.0);
assert_eq!(before, after, "same room-contents query after reload");
// Deterministic persistence: re-serialising the reload is byte-identical.
assert_eq!(reloaded.to_json().unwrap(), bytes);
}
/// The privacy mode switch is recorded in a verifiable attestation chain
/// (ADR-141), and a stricter mode raises the emitted class.
#[test]
fn privacy_mode_switch_is_attested_and_effective() {
let (mut e, room) = engine();
e.set_privacy_mode(PrivacyMode::StrictNoIdentity);
assert!(e.privacy().verify_chain());
let out = e
.process_cycle(&[node_frame(0, 1000, 56), node_frame(1, 1001, 56)], CalibrationId(1), room, 1)
.unwrap();
// StrictNoIdentity base = Restricted, even with no contradiction.
assert_eq!(out.effective_class, PrivacyClass::Restricted);
}
}