Files
ruvnet--RuView/v2/crates/wifi-densepose-worldgraph/src/graph.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

567 lines
20 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.
//! ADR-139 §2.22.5 — graph container, provenance, privacy rollup, queries.
use std::collections::HashMap;
use petgraph::stable_graph::{NodeIndex, StableDiGraph};
use petgraph::visit::{EdgeRef, IntoEdgeReferences};
use petgraph::Direction;
use serde::{Deserialize, Serialize};
use wifi_densepose_geo::types::GeoRegistration;
use crate::error::WorldGraphError;
use crate::model::{SemanticProvenance, WorldEdge, WorldId, WorldNode};
/// Current persisted schema version (ADR-136 §2.1 reserved-flag pattern).
pub const SCHEMA_VERSION: u16 = 1;
/// The typed environmental digital twin (ADR-139). Wraps a petgraph
/// `StableDiGraph` and exposes a domain API; stable `WorldId → NodeIndex`
/// mapping survives node removal.
#[derive(Debug)]
pub struct WorldGraph {
inner: StableDiGraph<WorldNode, WorldEdge>,
index: HashMap<WorldId, NodeIndex>,
registration: GeoRegistration,
next_id: u64,
schema_version: u16,
}
/// Serializable snapshot of a [`WorldGraph`] for RVF/JSON persistence.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct WorldGraphSnapshot {
schema_version: u16,
registration: GeoRegistration,
next_id: u64,
nodes: Vec<WorldNode>,
/// Edges as (from_id, to_id, edge).
edges: Vec<(WorldId, WorldId, WorldEdge)>,
}
/// Result of a privacy-impact rollup (ADR-139 §2.4).
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct PrivacyRollup {
/// Active mode name.
pub mode: String,
/// Nodes that become unobservable under this mode.
pub suppressed_nodes: Vec<WorldId>,
/// (sensor, node) pairs newly denied.
pub denied_pairs: Vec<(WorldId, WorldId)>,
/// Count of still-allowed (sensor, node) pairs.
pub allowed_pairs: usize,
}
impl WorldGraph {
/// Create an empty graph registered to an installation origin.
#[must_use]
pub fn new(registration: GeoRegistration) -> Self {
Self {
inner: StableDiGraph::new(),
index: HashMap::new(),
registration,
next_id: 1,
schema_version: SCHEMA_VERSION,
}
}
/// Installation geo-registration (ADR-044).
#[must_use]
pub fn registration(&self) -> &GeoRegistration {
&self.registration
}
/// Number of live nodes.
#[must_use]
pub fn node_count(&self) -> usize {
self.inner.node_count()
}
/// Insert or replace a node, returning its stable `WorldId`. If the node's
/// embedded id is `UNASSIGNED`, a fresh id is allocated; if it names an
/// existing id, that node's weight is replaced in place (upsert).
pub fn upsert_node(&mut self, mut node: WorldNode) -> WorldId {
let id = if node.id().is_unassigned() {
let fresh = WorldId(self.next_id);
self.next_id += 1;
node.set_id(fresh);
fresh
} else {
self.next_id = self.next_id.max(node.id().0 + 1);
node.id()
};
if let Some(&idx) = self.index.get(&id) {
self.inner[idx] = node;
} else {
let idx = self.inner.add_node(node);
self.index.insert(id, idx);
}
id
}
/// Add a typed edge between two known nodes.
///
/// # Errors
/// [`WorldGraphError::UnknownNode`] if either endpoint is unknown.
pub fn add_edge(
&mut self,
from: WorldId,
to: WorldId,
edge: WorldEdge,
) -> Result<(), WorldGraphError> {
let f = *self.index.get(&from).ok_or(WorldGraphError::UnknownNode(from))?;
let t = *self.index.get(&to).ok_or(WorldGraphError::UnknownNode(to))?;
self.inner.add_edge(f, t, edge);
Ok(())
}
/// Borrow a node by id.
#[must_use]
pub fn node(&self, id: WorldId) -> Option<&WorldNode> {
self.index.get(&id).map(|&idx| &self.inner[idx])
}
/// Remove a node and its incident edges (e.g. a person leaves).
pub fn remove_node(&mut self, id: WorldId) -> Option<WorldNode> {
let idx = self.index.remove(&id)?;
self.inner.remove_node(idx)
}
/// Outgoing neighbours of a node with the connecting edge.
pub fn neighbors(&self, id: WorldId) -> Vec<(WorldId, WorldEdge)> {
let Some(&idx) = self.index.get(&id) else {
return Vec::new();
};
self.inner
.edges_directed(idx, Direction::Outgoing)
.map(|e| (self.inner[e.target()].id(), e.weight().clone()))
.collect()
}
/// Resolve a HomeCore `area_id` to its Room node (entity linkage, ADR-127).
#[must_use]
pub fn room_for_area(&self, area_id: &str) -> Option<WorldId> {
self.inner.node_weights().find_map(|n| match n {
WorldNode::Room { id, area_id: Some(a), .. } if a == area_id => Some(*id),
_ => None,
})
}
// ---- ADR-139 §2.5 query API (v1) ----
/// Observability chain: which nodes a sensor currently `observes`.
#[must_use]
pub fn observed_by(&self, sensor: WorldId) -> Vec<WorldId> {
self.neighbors(sensor)
.into_iter()
.filter(|(_, e)| matches!(e, WorldEdge::Observes { .. }))
.map(|(id, _)| id)
.collect()
}
/// Location query: contents of a room/zone (incoming `located_in` edges).
#[must_use]
pub fn contents_of(&self, container: WorldId) -> Vec<WorldId> {
let Some(&idx) = self.index.get(&container) else {
return Vec::new();
};
self.inner
.edges_directed(idx, Direction::Incoming)
.filter(|e| matches!(e.weight(), WorldEdge::LocatedIn { .. }))
.map(|e| self.inner[e.source()].id())
.collect()
}
/// Append-with-provenance: insert a `SemanticState` and wire `DerivedFrom`
/// edges to its evidence sources (ADR-139 §2.3). Sources unknown to the
/// graph are skipped (evidence may be raw frames not modelled as nodes).
pub fn add_semantic_state(
&mut self,
statement: String,
confidence: f32,
valid_from_unix_ms: i64,
provenance: SemanticProvenance,
evidence_sources: &[WorldId],
) -> WorldId {
let evidence_handles = provenance.evidence.clone();
let id = self.upsert_node(WorldNode::SemanticState {
id: WorldId::UNASSIGNED,
statement,
confidence,
provenance,
valid_from_unix_ms,
});
for (src, handle) in evidence_sources.iter().zip(
evidence_handles
.iter()
.cloned()
.chain(std::iter::repeat(String::new())),
) {
let _ = self.add_edge(id, *src, WorldEdge::DerivedFrom { evidence: handle });
}
id
}
/// Retention: evict the oldest `SemanticState` nodes (with their incident
/// edges) until at most `max_states` remain. Returns the evicted ids,
/// oldest first.
///
/// The live loop appends one belief per cycle (`StreamingEngine::
/// process_cycle`), which at 20 Hz is ~1.7M nodes/day — unbounded without
/// this. The WorldGraph holds *current* beliefs; durable history belongs to
/// the recorder (`homecore-recorder`), so evicting old beliefs loses no
/// audit data.
///
/// Deterministic: eviction order is ascending `(valid_from_unix_ms, id)`,
/// so replaying the same cycle sequence prunes identically. Only
/// `SemanticState` nodes are eligible — rooms, zones, sensors, anchors,
/// person tracks, and events are never evicted by this method.
pub fn prune_semantic_states(&mut self, max_states: usize) -> Vec<WorldId> {
let mut states: Vec<(i64, u64)> = self
.inner
.node_weights()
.filter_map(|n| match n {
WorldNode::SemanticState { id, valid_from_unix_ms, .. } => {
Some((*valid_from_unix_ms, id.0))
}
_ => None,
})
.collect();
if states.len() <= max_states {
return Vec::new();
}
states.sort_unstable();
let n_evict = states.len() - max_states;
states.truncate(n_evict);
states
.into_iter()
.map(|(_, raw)| {
let id = WorldId(raw);
self.remove_node(id);
id
})
.collect()
}
/// Record a contradiction between two still-live beliefs (ADR-139 §2.3).
/// Neither node is deleted — the disagreement stays queryable.
///
/// # Errors
/// [`WorldGraphError::UnknownNode`] if either node is unknown.
pub fn add_contradiction(
&mut self,
a: WorldId,
b: WorldId,
magnitude: f32,
flag: String,
) -> Result<(), WorldGraphError> {
self.add_edge(a, b, WorldEdge::Contradicts { magnitude, flag })
}
/// Recompute `PrivacyLimitedBy` edges for the active mode (ADR-139 §2.4).
///
/// `policy(modality_kind, node_kind) -> allowed` decides, for each existing
/// `Observes` edge, whether the sensor may still observe the target under
/// `mode`. A matching `PrivacyLimitedBy` edge is appended recording the
/// decision; denied pairs are rolled up.
pub fn apply_privacy_mode<F>(&mut self, mode: &str, action: &str, policy: F) -> PrivacyRollup
where
F: Fn(&str, &str) -> bool,
{
// Collect (sensor, target, allowed) from current Observes edges.
let mut decisions: Vec<(WorldId, WorldId, bool)> = Vec::new();
for e in self.inner.edge_references() {
if matches!(e.weight(), WorldEdge::Observes { .. }) {
let sensor = &self.inner[e.source()];
let target = &self.inner[e.target()];
let allowed = policy(sensor.kind(), target.kind());
decisions.push((sensor.id(), target.id(), allowed));
}
}
let mut denied_pairs = Vec::new();
let mut suppressed = Vec::new();
let mut allowed_pairs = 0usize;
for (sensor, target, allowed) in &decisions {
let _ = self.add_edge(
*sensor,
*target,
WorldEdge::PrivacyLimitedBy {
mode: mode.to_string(),
action: action.to_string(),
allowed: *allowed,
},
);
if *allowed {
allowed_pairs += 1;
} else {
denied_pairs.push((*sensor, *target));
if !suppressed.contains(target) {
suppressed.push(*target);
}
}
}
PrivacyRollup {
mode: mode.to_string(),
suppressed_nodes: suppressed,
denied_pairs,
allowed_pairs,
}
}
// ---- Persistence (RVF/JSON) ----
/// Snapshot the graph for persistence.
#[must_use]
pub fn snapshot(&self) -> WorldGraphSnapshot {
let nodes: Vec<WorldNode> = self.inner.node_weights().cloned().collect();
let edges: Vec<(WorldId, WorldId, WorldEdge)> = self
.inner
.edge_references()
.map(|e| {
(
self.inner[e.source()].id(),
self.inner[e.target()].id(),
e.weight().clone(),
)
})
.collect();
WorldGraphSnapshot {
schema_version: self.schema_version,
registration: self.registration.clone(),
next_id: self.next_id,
nodes,
edges,
}
}
/// Serialize to deterministic JSON bytes (RVF payload).
///
/// # Errors
/// [`WorldGraphError::Serde`] on serialisation failure.
pub fn to_json(&self) -> Result<Vec<u8>, WorldGraphError> {
Ok(serde_json::to_vec(&self.snapshot())?)
}
/// Reconstruct a graph from a snapshot's JSON bytes.
///
/// # Errors
/// [`WorldGraphError::Serde`] on parse failure.
pub fn from_json(bytes: &[u8]) -> Result<Self, WorldGraphError> {
let snap: WorldGraphSnapshot = serde_json::from_slice(bytes)?;
let mut g = Self::new(snap.registration);
g.schema_version = snap.schema_version;
for node in snap.nodes {
g.upsert_node(node);
}
for (from, to, edge) in snap.edges {
g.add_edge(from, to, edge)?;
}
g.next_id = snap.next_id;
Ok(g)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{EnuPoint, SensorModality, WorldEdge, ZoneBoundsEnu};
fn enu(e: f64, n: f64) -> EnuPoint {
EnuPoint { east_m: e, north_m: n, up_m: 0.0 }
}
fn living_room() -> WorldNode {
WorldNode::Room {
id: WorldId::UNASSIGNED,
area_id: Some("living_room".into()),
name: "Living Room".into(),
bounds_enu: ZoneBoundsEnu::Rectangle { min_e: 0.0, min_n: 0.0, max_e: 5.0, max_n: 4.0 },
floor: 0,
}
}
#[test]
fn upsert_allocates_and_replaces() {
let mut g = WorldGraph::new(GeoRegistration::default());
let id = g.upsert_node(living_room());
assert!(!id.is_unassigned());
assert_eq!(g.node_count(), 1);
// Upsert same id with new name → replace in place, count unchanged.
g.upsert_node(WorldNode::Room {
id,
area_id: Some("living_room".into()),
name: "Lounge".into(),
bounds_enu: ZoneBoundsEnu::Rectangle { min_e: 0.0, min_n: 0.0, max_e: 5.0, max_n: 4.0 },
floor: 0,
});
assert_eq!(g.node_count(), 1);
assert!(matches!(g.node(id), Some(WorldNode::Room { name, .. }) if name == "Lounge"));
}
#[test]
fn area_linkage_and_observability() {
let mut g = WorldGraph::new(GeoRegistration::default());
let room = g.upsert_node(living_room());
let sensor = g.upsert_node(WorldNode::Sensor {
id: WorldId::UNASSIGNED,
device_id: "esp32-com9".into(),
position: enu(1.0, 1.0),
modality: SensorModality::WifiCsi,
});
g.add_edge(sensor, room, WorldEdge::Observes { quality: 0.9, last_seen_unix_ms: 1 })
.unwrap();
assert_eq!(g.room_for_area("living_room"), Some(room));
assert_eq!(g.observed_by(sensor), vec![room]);
}
#[test]
fn add_edge_unknown_endpoint_errors() {
let mut g = WorldGraph::new(GeoRegistration::default());
let room = g.upsert_node(living_room());
let err = g.add_edge(room, WorldId(999), WorldEdge::Observes { quality: 1.0, last_seen_unix_ms: 0 });
assert!(matches!(err, Err(WorldGraphError::UnknownNode(WorldId(999)))));
}
#[test]
fn location_query_contents_of() {
let mut g = WorldGraph::new(GeoRegistration::default());
let room = g.upsert_node(living_room());
let person = g.upsert_node(WorldNode::PersonTrack {
id: WorldId::UNASSIGNED,
track_id: 7,
last_position: enu(2.0, 2.0),
reid_embedding_ref: None,
});
g.add_edge(person, room, WorldEdge::LocatedIn { since_unix_ms: 100 }).unwrap();
assert_eq!(g.contents_of(room), vec![person]);
}
#[test]
fn semantic_state_provenance_and_contradiction() {
let mut g = WorldGraph::new(GeoRegistration::default());
let event = g.upsert_node(WorldNode::Event {
id: WorldId::UNASSIGNED,
event_type: "motion".into(),
at_unix_ms: 10,
located_in: None,
});
let prov = SemanticProvenance {
evidence: vec!["ev:abc".into()],
model_version: "rfenc-1.0".into(),
calibration_version: "cal:uuid".into(),
privacy_decision: "PrivateHome/Allow".into(),
};
let s1 = g.add_semantic_state("present".into(), 0.9, 11, prov.clone(), &[event]);
// DerivedFrom edge to the evidence event exists.
assert!(g.neighbors(s1).iter().any(|(to, e)| *to == event
&& matches!(e, WorldEdge::DerivedFrom { .. })));
let s2 = g.add_semantic_state("absent".into(), 0.6, 12, prov, &[event]);
g.add_contradiction(s1, s2, 0.3, "flag:ts".into()).unwrap();
// Both beliefs retained; contradiction queryable.
assert!(g.node(s1).is_some() && g.node(s2).is_some());
assert!(g.neighbors(s1).iter().any(|(_, e)| matches!(e, WorldEdge::Contradicts { .. })));
}
#[test]
fn prune_semantic_states_evicts_oldest_only() {
let mut g = WorldGraph::new(GeoRegistration::default());
let room = g.upsert_node(living_room());
let prov = SemanticProvenance {
evidence: vec!["ev:abc".into()],
model_version: "rfenc-1.0".into(),
calibration_version: "cal:uuid".into(),
privacy_decision: "PrivateHome/Allow".into(),
};
let ids: Vec<WorldId> = (0..10)
.map(|t| g.add_semantic_state(format!("s{t}"), 0.9, t, prov.clone(), &[room]))
.collect();
assert_eq!(g.node_count(), 11); // room + 10 beliefs
let evicted = g.prune_semantic_states(3);
// Oldest 7 evicted, in ascending timestamp order.
assert_eq!(evicted, ids[..7].to_vec());
assert_eq!(g.node_count(), 4); // room + 3 newest beliefs
for kept in &ids[7..] {
assert!(g.node(*kept).is_some());
}
// The room (structural node) is never eligible for eviction.
assert!(g.node(room).is_some());
// Below the cap, pruning is a no-op.
assert!(g.prune_semantic_states(3).is_empty());
}
#[test]
fn prune_is_deterministic_for_equal_timestamps() {
let prov = SemanticProvenance {
evidence: vec![],
model_version: "m".into(),
calibration_version: "c".into(),
privacy_decision: "p".into(),
};
let build = || {
let mut g = WorldGraph::new(GeoRegistration::default());
let room = g.upsert_node(living_room());
for _ in 0..6 {
// Identical timestamps: tie-break must fall back to id order.
g.add_semantic_state("s".into(), 0.5, 100, prov.clone(), &[room]);
}
g
};
let mut g1 = build();
let mut g2 = build();
assert_eq!(g1.prune_semantic_states(2), g2.prune_semantic_states(2));
}
#[test]
fn privacy_rollup_suppresses_person_tracks() {
let mut g = WorldGraph::new(GeoRegistration::default());
let room = g.upsert_node(living_room());
let person = g.upsert_node(WorldNode::PersonTrack {
id: WorldId::UNASSIGNED,
track_id: 1,
last_position: enu(1.0, 1.0),
reid_embedding_ref: None,
});
let sensor = g.upsert_node(WorldNode::Sensor {
id: WorldId::UNASSIGNED,
device_id: "s".into(),
position: enu(0.0, 0.0),
modality: SensorModality::WifiCsi,
});
g.add_edge(sensor, room, WorldEdge::Observes { quality: 1.0, last_seen_unix_ms: 0 }).unwrap();
g.add_edge(sensor, person, WorldEdge::Observes { quality: 1.0, last_seen_unix_ms: 0 }).unwrap();
// StrictNoIdentity: rooms observable, person_tracks suppressed.
let rollup = g.apply_privacy_mode("StrictNoIdentity", "SuppressIdentity", |_modality, node_kind| {
node_kind != "person_track"
});
assert_eq!(rollup.allowed_pairs, 1);
assert_eq!(rollup.denied_pairs, vec![(sensor, person)]);
assert_eq!(rollup.suppressed_nodes, vec![person]);
}
#[test]
fn json_roundtrip_preserves_nodes_and_edges() {
let mut g = WorldGraph::new(GeoRegistration::default());
let room = g.upsert_node(living_room());
let sensor = g.upsert_node(WorldNode::Sensor {
id: WorldId::UNASSIGNED,
device_id: "s".into(),
position: enu(0.0, 0.0),
modality: SensorModality::WifiCsi,
});
g.add_edge(sensor, room, WorldEdge::Observes { quality: 0.8, last_seen_unix_ms: 5 }).unwrap();
let bytes = g.to_json().unwrap();
let g2 = WorldGraph::from_json(&bytes).unwrap();
assert_eq!(g2.node_count(), 2);
assert_eq!(g2.room_for_area("living_room"), Some(room));
assert_eq!(g2.observed_by(sensor), vec![room]);
// Deterministic: re-serialising the reconstructed graph matches.
assert_eq!(g2.to_json().unwrap(), bytes);
}
}