mirror of
https://github.com/ruvnet/RuView
synced 2026-08-01 19:01: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>
567 lines
20 KiB
Rust
567 lines
20 KiB
Rust
//! ADR-139 §2.2–2.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);
|
||
}
|
||
}
|