diff --git a/v2/Cargo.lock b/v2/Cargo.lock index a436476d..6695c8a3 100644 --- a/v2/Cargo.lock +++ b/v2/Cargo.lock @@ -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" diff --git a/v2/Cargo.toml b/v2/Cargo.toml index 8339190f..71e547d3 100644 --- a/v2/Cargo.toml +++ b/v2/Cargo.toml @@ -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 diff --git a/v2/crates/wifi-densepose-worldgraph/Cargo.toml b/v2/crates/wifi-densepose-worldgraph/Cargo.toml new file mode 100644 index 00000000..fa9c45c0 --- /dev/null +++ b/v2/crates/wifi-densepose-worldgraph/Cargo.toml @@ -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" diff --git a/v2/crates/wifi-densepose-worldgraph/src/error.rs b/v2/crates/wifi-densepose-worldgraph/src/error.rs new file mode 100644 index 00000000..a1e697df --- /dev/null +++ b/v2/crates/wifi-densepose-worldgraph/src/error.rs @@ -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), +} diff --git a/v2/crates/wifi-densepose-worldgraph/src/graph.rs b/v2/crates/wifi-densepose-worldgraph/src/graph.rs new file mode 100644 index 00000000..b6a29921 --- /dev/null +++ b/v2/crates/wifi-densepose-worldgraph/src/graph.rs @@ -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, + index: HashMap, + 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, + /// 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, + /// (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 { + 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 { + 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 { + 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 { + 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(&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 = 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, 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 { + 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); + } +} diff --git a/v2/crates/wifi-densepose-worldgraph/src/lib.rs b/v2/crates/wifi-densepose-worldgraph/src/lib.rs new file mode 100644 index 00000000..c062d725 --- /dev/null +++ b/v2/crates/wifi-densepose-worldgraph/src/lib.rs @@ -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, +}; diff --git a/v2/crates/wifi-densepose-worldgraph/src/model.rs b/v2/crates/wifi-densepose-worldgraph/src/model.rs new file mode 100644 index 00000000..f4e18ea6 --- /dev/null +++ b/v2/crates/wifi-densepose-worldgraph/src/model.rs @@ -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, + /// 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, + /// 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, + /// 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, + }, + /// 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, + }, + /// 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", + } + } +}