feat(worldgraph): ADR-139 WorldGraph environmental digital twin (#843)
New crate wifi-densepose-worldgraph: - model.rs: WorldNode (10 kinds) + WorldEdge (7 relations) as serde enums (no trait objects → deterministic RVF persistence); WorldId, EnuPoint, ZoneBoundsEnu (with point-in-bounds), SemanticProvenance (house-rule tuple) - graph.rs: WorldGraph over petgraph StableDiGraph; upsert/add_edge/neighbors, room_for_area (HomeCore area_id linkage), observed_by/contents_of queries, add_semantic_state (append-with-provenance DerivedFrom), add_contradiction (both beliefs retained), apply_privacy_mode → PrivacyRollup, JSON persistence - 7 tests (upsert/replace, linkage, unknown-endpoint, location, provenance+ contradiction, privacy rollup, deterministic JSON round-trip) - workspace 0 errors Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
parent
fc7674bde9
commit
521a012d84
|
|
@ -10908,6 +10908,17 @@ dependencies = [
|
|||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wifi-densepose-worldgraph"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"petgraph",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
"wifi-densepose-geo",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi"
|
||||
version = "0.3.9"
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ members = [
|
|||
"crates/wifi-densepose-desktop",
|
||||
"crates/wifi-densepose-pointcloud",
|
||||
"crates/wifi-densepose-geo",
|
||||
"crates/wifi-densepose-worldgraph", # ADR-139 — WorldGraph environmental digital twin
|
||||
"crates/nvsim",
|
||||
"crates/nvsim-server",
|
||||
"crates/homecore", # ADR-127 — HOMECORE state machine
|
||||
|
|
|
|||
|
|
@ -0,0 +1,19 @@
|
|||
[package]
|
||||
name = "wifi-densepose-worldgraph"
|
||||
description = "ADR-139 — WorldGraph environmental digital twin (typed petgraph) for RuView"
|
||||
version = "0.3.0"
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
petgraph.workspace = true
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json.workspace = true
|
||||
thiserror.workspace = true
|
||||
wifi-densepose-geo = { path = "../wifi-densepose-geo" }
|
||||
|
||||
[lints.rust]
|
||||
unsafe_code = "forbid"
|
||||
missing_docs = "warn"
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
//! WorldGraph error type.
|
||||
|
||||
use crate::model::WorldId;
|
||||
|
||||
/// Errors from WorldGraph operations.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum WorldGraphError {
|
||||
/// An edge endpoint referenced an unknown node.
|
||||
#[error("unknown node {0:?}")]
|
||||
UnknownNode(WorldId),
|
||||
|
||||
/// (De)serialisation of the persisted graph failed.
|
||||
#[error("serialization error: {0}")]
|
||||
Serde(#[from] serde_json::Error),
|
||||
}
|
||||
|
|
@ -0,0 +1,475 @@
|
|||
//! 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
|
||||
}
|
||||
|
||||
/// 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 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
//! # WiFi-DensePose WorldGraph (ADR-139)
|
||||
//!
|
||||
//! The environmental digital twin for the RuView streaming engine: a typed
|
||||
//! [`petgraph`] `StableDiGraph` of rooms, zones, walls, doorways, sensors, RF
|
||||
//! links, person tracks, object anchors, events, and semantic-state beliefs,
|
||||
//! connected by typed relations (observes / located_in / adjacent_to /
|
||||
//! supports / contradicts / derived_from / privacy_limited_by).
|
||||
//!
|
||||
//! It sits downstream of fusion (ADR-137) — storing fused *beliefs*, not raw
|
||||
//! frames — and upstream of the semantic/agent layer (ADR-140) and evaluation
|
||||
//! harness (ADR-145). Every [`model::WorldNode::SemanticState`] carries
|
||||
//! mandatory [`model::SemanticProvenance`] (signal evidence + model +
|
||||
//! calibration + privacy decision), honouring the house rule structurally.
|
||||
//!
|
||||
//! Persistence is via [`graph::WorldGraph::to_json`] /
|
||||
//! [`graph::WorldGraph::from_json`] (the RVF payload); the serde-enum node/edge
|
||||
//! model guarantees a deterministic, schema-versioned wire layout.
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod error;
|
||||
pub mod graph;
|
||||
pub mod model;
|
||||
|
||||
pub use error::WorldGraphError;
|
||||
pub use graph::{PrivacyRollup, WorldGraph, WorldGraphSnapshot, SCHEMA_VERSION};
|
||||
pub use model::{
|
||||
AnchorKind, EnuPoint, SemanticProvenance, SensorModality, WorldEdge, WorldId, WorldNode,
|
||||
ZoneBoundsEnu,
|
||||
};
|
||||
|
|
@ -0,0 +1,385 @@
|
|||
//! ADR-139 §2.1 — typed node/edge model.
|
||||
//!
|
||||
//! Nodes and edges are `serde` enums (NOT boxed trait objects) for
|
||||
//! deterministic, schema-versioned, RVF-friendly persistence. Cross-ADR
|
||||
//! references (ADR-137 evidence, ADR-141 privacy decision) are carried as
|
||||
//! opaque content-address `String` handles so the WorldGraph compiles and
|
||||
//! persists independently of those crates (§2.1, §2.3).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Stable, monotonic identity for a world entity. Distinct from petgraph's
|
||||
/// `NodeIndex` (graph-internal handle); `WorldId` survives RVF round-trips and
|
||||
/// node removal. `WorldId(0)` is the "assign me one" sentinel for `upsert_node`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct WorldId(pub u64);
|
||||
|
||||
impl WorldId {
|
||||
/// The "allocate a fresh id" sentinel.
|
||||
pub const UNASSIGNED: WorldId = WorldId(0);
|
||||
|
||||
/// Whether this id is the unassigned sentinel.
|
||||
#[must_use]
|
||||
pub fn is_unassigned(&self) -> bool {
|
||||
self.0 == 0
|
||||
}
|
||||
}
|
||||
|
||||
/// Local ENU coordinate in metres relative to the installation origin (ADR-044).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct EnuPoint {
|
||||
/// East offset (m).
|
||||
pub east_m: f64,
|
||||
/// North offset (m).
|
||||
pub north_m: f64,
|
||||
/// Up offset (m).
|
||||
pub up_m: f64,
|
||||
}
|
||||
|
||||
/// MAT `ZoneBounds` reprojected into the installation ENU frame.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(tag = "shape", rename_all = "snake_case")]
|
||||
pub enum ZoneBoundsEnu {
|
||||
/// Axis-aligned rectangle.
|
||||
Rectangle {
|
||||
/// Minimum east (m).
|
||||
min_e: f64,
|
||||
/// Minimum north (m).
|
||||
min_n: f64,
|
||||
/// Maximum east (m).
|
||||
max_e: f64,
|
||||
/// Maximum north (m).
|
||||
max_n: f64,
|
||||
},
|
||||
/// Circle.
|
||||
Circle {
|
||||
/// Centre east (m).
|
||||
center_e: f64,
|
||||
/// Centre north (m).
|
||||
center_n: f64,
|
||||
/// Radius (m).
|
||||
radius_m: f64,
|
||||
},
|
||||
/// Polygon (east, north) vertices.
|
||||
Polygon {
|
||||
/// (east, north) vertices.
|
||||
vertices: Vec<(f64, f64)>,
|
||||
},
|
||||
}
|
||||
|
||||
impl ZoneBoundsEnu {
|
||||
/// Whether an ENU point lies within these bounds (up ignored).
|
||||
#[must_use]
|
||||
pub fn contains(&self, p: &EnuPoint) -> bool {
|
||||
match self {
|
||||
Self::Rectangle { min_e, min_n, max_e, max_n } => {
|
||||
p.east_m >= *min_e && p.east_m <= *max_e && p.north_m >= *min_n && p.north_m <= *max_n
|
||||
}
|
||||
Self::Circle { center_e, center_n, radius_m } => {
|
||||
let de = p.east_m - center_e;
|
||||
let dn = p.north_m - center_n;
|
||||
(de * de + dn * dn).sqrt() <= *radius_m
|
||||
}
|
||||
Self::Polygon { vertices } => point_in_polygon(p.east_m, p.north_m, vertices),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn point_in_polygon(px: f64, py: f64, verts: &[(f64, f64)]) -> bool {
|
||||
if verts.len() < 3 {
|
||||
return false;
|
||||
}
|
||||
// Ray-casting parity test.
|
||||
let mut inside = false;
|
||||
let mut j = verts.len() - 1;
|
||||
for i in 0..verts.len() {
|
||||
let (xi, yi) = verts[i];
|
||||
let (xj, yj) = verts[j];
|
||||
let intersect = ((yi > py) != (yj > py))
|
||||
&& (px < (xj - xi) * (py - yi) / (yj - yi) + xi);
|
||||
if intersect {
|
||||
inside = !inside;
|
||||
}
|
||||
j = i;
|
||||
}
|
||||
inside
|
||||
}
|
||||
|
||||
/// Sensing modality of a physical device placement.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SensorModality {
|
||||
/// WiFi CSI sensing node (ESP32-S3/C6).
|
||||
WifiCsi,
|
||||
/// 60 GHz mmWave FMCW radar.
|
||||
MmWave,
|
||||
/// Ultra-wideband ranging beacon (ADR-144).
|
||||
Uwb,
|
||||
/// Coarse presence sensor.
|
||||
Presence,
|
||||
}
|
||||
|
||||
/// Kind of persistent static anchor.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AnchorKind {
|
||||
/// A persistent RF reflector (ADR-143 RF SLAM).
|
||||
Reflector,
|
||||
/// A piece of furniture inferred from reflector clustering.
|
||||
Furniture,
|
||||
/// A surveyed UWB beacon (ADR-144).
|
||||
UwbBeacon,
|
||||
}
|
||||
|
||||
/// Mandatory provenance for every [`WorldNode::SemanticState`] (house rule):
|
||||
/// every semantic belief traces to signal evidence + model + calibration +
|
||||
/// privacy decision.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct SemanticProvenance {
|
||||
/// ADR-137 `EvidenceRef` content-address handle(s).
|
||||
pub evidence: Vec<String>,
|
||||
/// Model version (ADR-136 `model_id`/`model_version`) that produced this.
|
||||
pub model_version: String,
|
||||
/// Calibration version (ADR-135 baseline id) in effect.
|
||||
pub calibration_version: String,
|
||||
/// Privacy decision (ADR-141 mode + action) it was derived under.
|
||||
pub privacy_decision: String,
|
||||
}
|
||||
|
||||
/// A typed world node (ADR-139 §2.1). Persistence-deterministic serde enum.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum WorldNode {
|
||||
/// A bounded interior space, linked to a HomeCore `area_id` (ADR-127).
|
||||
Room {
|
||||
/// Stable id (or `UNASSIGNED` to allocate).
|
||||
id: WorldId,
|
||||
/// HomeCore registry area_id — the entity-linkage join key.
|
||||
area_id: Option<String>,
|
||||
/// Human name.
|
||||
name: String,
|
||||
/// Room footprint in local ENU.
|
||||
bounds_enu: ZoneBoundsEnu,
|
||||
/// Floor index.
|
||||
floor: i16,
|
||||
},
|
||||
/// A sub-region of a room targeted for sensing (MAT ScanZone analogue).
|
||||
Zone {
|
||||
/// Stable id.
|
||||
id: WorldId,
|
||||
/// Containing room.
|
||||
parent_room: WorldId,
|
||||
/// Human name.
|
||||
name: String,
|
||||
/// Zone footprint.
|
||||
bounds_enu: ZoneBoundsEnu,
|
||||
},
|
||||
/// A wall segment (coarse 2D topological element in ENU).
|
||||
Wall {
|
||||
/// Stable id.
|
||||
id: WorldId,
|
||||
/// Segment start.
|
||||
a: EnuPoint,
|
||||
/// Segment end.
|
||||
b: EnuPoint,
|
||||
/// Coarse RF attenuation (dB): drywall ≈ 3, brick ≈ 12.
|
||||
rf_attenuation_db: f32,
|
||||
},
|
||||
/// A passable opening between two rooms.
|
||||
Doorway {
|
||||
/// Stable id.
|
||||
id: WorldId,
|
||||
/// Centre point.
|
||||
center: EnuPoint,
|
||||
/// Opening width (m).
|
||||
width_m: f32,
|
||||
},
|
||||
/// A physical sensing device placement (ADR-113 placement target).
|
||||
Sensor {
|
||||
/// Stable id.
|
||||
id: WorldId,
|
||||
/// Matches HomeCore `EntityEntry.device_id`.
|
||||
device_id: String,
|
||||
/// Placement in local ENU.
|
||||
position: EnuPoint,
|
||||
/// Sensing modality.
|
||||
modality: SensorModality,
|
||||
},
|
||||
/// A directed RF propagation channel between two sensors (ADR-138 LinkGroup member).
|
||||
RfLink {
|
||||
/// Stable id.
|
||||
id: WorldId,
|
||||
/// Transmit sensor node.
|
||||
tx: WorldId,
|
||||
/// Receive sensor node.
|
||||
rx: WorldId,
|
||||
/// ADR-138 MLO LinkGroup id.
|
||||
link_group_id: Option<String>,
|
||||
/// Centre frequency (MHz).
|
||||
center_freq_mhz: u32,
|
||||
},
|
||||
/// A tracked person (Kalman track id from ruvsense `pose_tracker`).
|
||||
PersonTrack {
|
||||
/// Stable id.
|
||||
id: WorldId,
|
||||
/// Tracker track id.
|
||||
track_id: u64,
|
||||
/// Last known ENU position.
|
||||
last_position: EnuPoint,
|
||||
/// AETHER re-ID embedding handle.
|
||||
reid_embedding_ref: Option<String>,
|
||||
},
|
||||
/// A persistent static reflector / object (ADR-143 / ADR-144 anchor).
|
||||
ObjectAnchor {
|
||||
/// Stable id.
|
||||
id: WorldId,
|
||||
/// ENU position.
|
||||
position: EnuPoint,
|
||||
/// Anchor classification.
|
||||
anchor_kind: AnchorKind,
|
||||
/// Confidence in [0, 1].
|
||||
confidence: f32,
|
||||
},
|
||||
/// A discrete detected event (fall, entry, gesture) at a point in time.
|
||||
Event {
|
||||
/// Stable id.
|
||||
id: WorldId,
|
||||
/// Event type tag.
|
||||
event_type: String,
|
||||
/// Wall-clock time (Unix ms).
|
||||
at_unix_ms: i64,
|
||||
/// Containing room/zone.
|
||||
located_in: Option<WorldId>,
|
||||
},
|
||||
/// A fused semantic belief about the world (the ADR-140 record's graph anchor).
|
||||
SemanticState {
|
||||
/// Stable id.
|
||||
id: WorldId,
|
||||
/// Human-readable belief statement.
|
||||
statement: String,
|
||||
/// Confidence in [0, 1].
|
||||
confidence: f32,
|
||||
/// Mandatory provenance (house rule).
|
||||
provenance: SemanticProvenance,
|
||||
/// Belief validity start (Unix ms).
|
||||
valid_from_unix_ms: i64,
|
||||
},
|
||||
}
|
||||
|
||||
impl WorldNode {
|
||||
/// The embedded stable id of this node.
|
||||
#[must_use]
|
||||
pub fn id(&self) -> WorldId {
|
||||
match self {
|
||||
Self::Room { id, .. }
|
||||
| Self::Zone { id, .. }
|
||||
| Self::Wall { id, .. }
|
||||
| Self::Doorway { id, .. }
|
||||
| Self::Sensor { id, .. }
|
||||
| Self::RfLink { id, .. }
|
||||
| Self::PersonTrack { id, .. }
|
||||
| Self::ObjectAnchor { id, .. }
|
||||
| Self::Event { id, .. }
|
||||
| Self::SemanticState { id, .. } => *id,
|
||||
}
|
||||
}
|
||||
|
||||
/// Overwrite the embedded id (used by `upsert_node` when allocating one).
|
||||
pub(crate) fn set_id(&mut self, new: WorldId) {
|
||||
match self {
|
||||
Self::Room { id, .. }
|
||||
| Self::Zone { id, .. }
|
||||
| Self::Wall { id, .. }
|
||||
| Self::Doorway { id, .. }
|
||||
| Self::Sensor { id, .. }
|
||||
| Self::RfLink { id, .. }
|
||||
| Self::PersonTrack { id, .. }
|
||||
| Self::ObjectAnchor { id, .. }
|
||||
| Self::Event { id, .. }
|
||||
| Self::SemanticState { id, .. } => *id = new,
|
||||
}
|
||||
}
|
||||
|
||||
/// Static kind tag for diagnostics/queries.
|
||||
#[must_use]
|
||||
pub fn kind(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Room { .. } => "room",
|
||||
Self::Zone { .. } => "zone",
|
||||
Self::Wall { .. } => "wall",
|
||||
Self::Doorway { .. } => "doorway",
|
||||
Self::Sensor { .. } => "sensor",
|
||||
Self::RfLink { .. } => "rf_link",
|
||||
Self::PersonTrack { .. } => "person_track",
|
||||
Self::ObjectAnchor { .. } => "object_anchor",
|
||||
Self::Event { .. } => "event",
|
||||
Self::SemanticState { .. } => "semantic_state",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A typed edge between two [`WorldNode`]s (ADR-139 §2.1). Stored as the
|
||||
/// petgraph edge weight; metadata is structurally per-relation.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(tag = "rel", rename_all = "snake_case")]
|
||||
pub enum WorldEdge {
|
||||
/// sensor/rf_link → observable node. Weight is field-of-regard quality.
|
||||
Observes {
|
||||
/// Field-of-regard quality in [0, 1].
|
||||
quality: f32,
|
||||
/// Last observation time (Unix ms).
|
||||
last_seen_unix_ms: i64,
|
||||
},
|
||||
/// person/object/event → room/zone containment.
|
||||
LocatedIn {
|
||||
/// Containment start (Unix ms).
|
||||
since_unix_ms: i64,
|
||||
},
|
||||
/// room ↔ room through a doorway (undirected pair stored as two edges).
|
||||
AdjacentTo {
|
||||
/// The connecting doorway node.
|
||||
via_doorway: WorldId,
|
||||
},
|
||||
/// sensor/rf_link → sensor/rf_link physical/clock support (ADR-138).
|
||||
Supports {
|
||||
/// Support strength in [0, 1].
|
||||
strength: f32,
|
||||
},
|
||||
/// evidence/state → evidence/state: sources disagree (ADR-137).
|
||||
Contradicts {
|
||||
/// Disagreement magnitude.
|
||||
magnitude: f32,
|
||||
/// ADR-137 contradiction-flag content-address handle.
|
||||
flag: String,
|
||||
},
|
||||
/// semantic_state → prior state/evidence provenance chain (ADR-137).
|
||||
DerivedFrom {
|
||||
/// ADR-137 evidence content-address handle.
|
||||
evidence: String,
|
||||
},
|
||||
/// sensor → node: observation constrained by a privacy mode (ADR-141).
|
||||
PrivacyLimitedBy {
|
||||
/// Limiting privacy mode name.
|
||||
mode: String,
|
||||
/// Action evaluated.
|
||||
action: String,
|
||||
/// Whether observation is allowed under the current mode.
|
||||
allowed: bool,
|
||||
},
|
||||
}
|
||||
|
||||
impl WorldEdge {
|
||||
/// Static relation tag.
|
||||
#[must_use]
|
||||
pub fn rel(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Observes { .. } => "observes",
|
||||
Self::LocatedIn { .. } => "located_in",
|
||||
Self::AdjacentTo { .. } => "adjacent_to",
|
||||
Self::Supports { .. } => "supports",
|
||||
Self::Contradicts { .. } => "contradicts",
|
||||
Self::DerivedFrom { .. } => "derived_from",
|
||||
Self::PrivacyLimitedBy { .. } => "privacy_limited_by",
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue