diff --git a/v2/crates/wifi-densepose-calibration/src/anchor.rs b/v2/crates/wifi-densepose-calibration/src/anchor.rs index ae91ecfb..d7e05956 100644 --- a/v2/crates/wifi-densepose-calibration/src/anchor.rs +++ b/v2/crates/wifi-densepose-calibration/src/anchor.rs @@ -8,6 +8,8 @@ use serde::{Deserialize, Serialize}; +use crate::geometry::NodeGeometry; + /// Coarse posture an anchor establishes. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum Posture { @@ -165,6 +167,17 @@ pub enum EnrollmentEvent { /// The accepted anchor. anchor: Anchor, }, + /// Transceiver geometry recorded for the session's nodes (ADR-152 §2.1.1). + /// Typically appended right after `Started`; a later event supersedes an + /// earlier one (latest wins), so a geometry correction is an append, not a + /// rewrite. Sessions persisted before this variant existed replay cleanly — + /// the variant is additive to the externally-tagged event encoding. + GeometryRecorded { + /// Per-node geometry records. + geometry: Vec, + /// Unix seconds. + at: i64, + }, /// An anchor failed the gate (re-prompt). AnchorRejected { /// Which anchor. @@ -230,6 +243,21 @@ impl EnrollmentSession { out } + /// Record the session's transceiver geometry (ADR-152 §2.1.1) — appends a + /// [`EnrollmentEvent::GeometryRecorded`] event; the latest recording wins. + pub fn record_geometry(&mut self, geometry: Vec, at: i64) { + self.apply(EnrollmentEvent::GeometryRecorded { geometry, at }); + } + + /// The geometry snapshot in effect (latest `GeometryRecorded` event), if + /// any was recorded. Derived from the event log, never stored separately. + pub fn geometry(&self) -> Option<&[NodeGeometry]> { + self.events.iter().rev().find_map(|ev| match ev { + EnrollmentEvent::GeometryRecorded { geometry, .. } => Some(geometry.as_slice()), + _ => None, + }) + } + /// The next anchor in the canonical sequence not yet accepted, if any. pub fn next_anchor(&self) -> Option { let accepted = self.accepted_anchors(); @@ -340,6 +368,47 @@ mod tests { ); } + #[test] + fn geometry_recorded_latest_wins_and_roundtrips() { + let mut s = EnrollmentSession::new("r", "b", 0); + assert!(s.geometry().is_none(), "no geometry before recording"); + + s.record_geometry(vec![NodeGeometry::unknown(1)], 5); + let corrected = vec![ + NodeGeometry::new(1, "tape-measure").with_position(0.0, 0.0, 1.0), + NodeGeometry::new(2, "tape-measure") + .with_position(3.0, 0.0, 1.0) + .with_distance(1, 3.0), + ]; + s.record_geometry(corrected.clone(), 10); + + // Latest recording wins, derived from the event log. + assert_eq!(s.geometry(), Some(corrected.as_slice())); + + // The whole session (incl. geometry events) survives persistence. + let json = serde_json::to_string(&s).unwrap(); + let back: EnrollmentSession = serde_json::from_str(&json).unwrap(); + assert_eq!(back.geometry(), Some(corrected.as_slice())); + assert_eq!(back.events.len(), s.events.len()); + } + + /// Sessions persisted BEFORE the GeometryRecorded variant existed must + /// deserialize cleanly and report no geometry (ADR-152 schema-compat rule). + #[test] + fn pre_geometry_session_json_loads() { + let old_json = r#"{ + "room_id": "r", + "baseline_id": "b", + "events": [ + {"Started": {"room_id": "r", "baseline_id": "b", "at": 0}}, + {"AnchorRejected": {"label": "sit", "reason": "no person", "at": 1}} + ] + }"#; + let s: EnrollmentSession = serde_json::from_str(old_json).unwrap(); + assert!(s.geometry().is_none()); + assert_eq!(s.next_anchor(), Some(AnchorLabel::Empty)); + } + #[test] fn posture_mapping() { assert_eq!(AnchorLabel::StandStill.posture(), Some(Posture::Standing)); diff --git a/v2/crates/wifi-densepose-calibration/src/bank.rs b/v2/crates/wifi-densepose-calibration/src/bank.rs index fc64c98e..b37eeef9 100644 --- a/v2/crates/wifi-densepose-calibration/src/bank.rs +++ b/v2/crates/wifi-densepose-calibration/src/bank.rs @@ -10,6 +10,7 @@ use serde::{Deserialize, Serialize}; use crate::error::{CalibrationError, Result}; use crate::extract::AnchorFeature; +use crate::geometry::NodeGeometry; use crate::specialist::{ AnomalySpecialist, BreathingSpecialist, HeartbeatSpecialist, PostureSpecialist, PresenceSpecialist, RestlessnessSpecialist, SpecialistKind, @@ -26,6 +27,13 @@ pub struct SpecialistBank { pub trained_at_unix_s: i64, /// Number of anchors used. pub anchor_count: usize, + /// Transceiver geometry snapshot the bank was trained under (ADR-152 + /// §2.1.1). Empty both for banks persisted before geometry existed (serde + /// default — same pattern as `PresenceSpecialist::mean_dist_threshold`) and + /// for enrollments where no geometry was recorded. Statistical specialists + /// ignore it; the ADR-151 P6 LoRA heads will consume it (ADR-152 §2.1.2). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub geometry: Vec, /// Presence gate (requires the `empty` + an occupied anchor). pub presence: Option, @@ -65,6 +73,7 @@ impl SpecialistBank { baseline_id: baseline_id.into(), trained_at_unix_s: at_unix_s, anchor_count: anchors.len(), + geometry: Vec::new(), presence: PresenceSpecialist::train(anchors), posture: PostureSpecialist::train(anchors), breathing: BreathingSpecialist::default(), @@ -74,6 +83,13 @@ impl SpecialistBank { }) } + /// Attach the enrollment's transceiver-geometry snapshot (ADR-152 §2.1.1), + /// builder style — typically `EnrollmentSession::geometry()` at train time. + pub fn with_geometry(mut self, geometry: Vec) -> Self { + self.geometry = geometry; + self + } + /// `true` if the bank was trained against a different baseline (it is STALE). pub fn is_stale(&self, current_baseline_id: &str) -> bool { self.baseline_id != current_baseline_id @@ -178,6 +194,45 @@ mod tests { assert_eq!(back.anchor_count, 6); } + #[test] + fn geometry_snapshot_roundtrips() { + let geometry = vec![ + NodeGeometry::new(1, "tape-measure").with_position(0.0, 0.0, 1.0), + NodeGeometry::unknown(2), + ]; + let bank = SpecialistBank::train("r", "base-1", &full_anchors(), 1000) + .unwrap() + .with_geometry(geometry.clone()); + let json = bank.to_json().unwrap(); + let back = SpecialistBank::from_json(&json).unwrap(); + assert_eq!(back.geometry, geometry); + } + + /// ADR-152 schema-compat fixture: bank JSON persisted BEFORE the geometry + /// field existed (captured from the pre-ADR-152 serializer shape) must + /// deserialize cleanly with an empty geometry snapshot. + #[test] + fn pre_geometry_bank_json_loads() { + let old_json = r#"{ + "room_id": "living-room", + "baseline_id": "base-1", + "trained_at_unix_s": 1000, + "anchor_count": 2, + "presence": {"threshold": 5.5, "occupied_var": 10.0}, + "posture": null, + "breathing": {"min_score": 0.0}, + "heartbeat": {"min_score": 0.0}, + "restlessness": null, + "anomaly": null + }"#; + let bank = SpecialistBank::from_json(old_json).unwrap(); + assert!(bank.geometry.is_empty(), "old banks carry no geometry"); + assert_eq!(bank.room_id, "living-room"); + assert!(bank.presence.is_some()); + // And a geometry-free bank serializes without the field (old shape). + assert!(!bank.to_json().unwrap().contains("geometry")); + } + #[test] fn staleness() { let bank = SpecialistBank::train("r", "base-1", &full_anchors(), 1000).unwrap(); diff --git a/v2/crates/wifi-densepose-calibration/src/geometry.rs b/v2/crates/wifi-densepose-calibration/src/geometry.rs new file mode 100644 index 00000000..985aae55 --- /dev/null +++ b/v2/crates/wifi-densepose-calibration/src/geometry.rs @@ -0,0 +1,161 @@ +//! Transceiver-geometry records (ADR-152 §2.1.1, extends ADR-151 Stage 2). +//! +//! PerceptAlign (ADR-152 F1) diagnosed "coordinate overfitting": pose heads +//! trained without an explicit layout model memorise the deployment-specific +//! transceiver geometry and break in unseen rooms. The first, cheap half of +//! the fix is to *record* the geometry at enrollment so every specialist bank +//! knows the layout it was trained under. +//! +//! This module is the record only. The learned geometry *embeddings* that +//! condition specialist heads (ADR-152 §2.1.2) are out of scope until the +//! ADR-151 P6 LoRA heads exist — statistical specialists ignore geometry. +//! +//! Every field is optional **by design**: geometry is captured when the +//! operator knows it (tape measure, checkerboard calibration, installer +//! floor plan) and omitted when they don't. An all-unknown record is still +//! useful — it pins down *which* nodes existed and that geometry was not +//! measured, rather than leaving the question open. + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; + +/// Estimated node position in the room frame (meters). +/// +/// The room frame is whatever frame the recording `method` defines (e.g. a +/// tape-measure origin at a room corner, or the shared 3D frame of the +/// two-checkerboard alignment, ADR-152 §2.1.3). Consistency *within* one +/// enrollment is what matters; there is no global frame. +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +pub struct PositionEstimate { + /// X coordinate (meters). + pub x_m: f32, + /// Y coordinate (meters). + pub y_m: f32, + /// Z coordinate / height (meters). + pub z_m: f32, +} + +/// Antenna boresight orientation (radians, room frame). +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +pub struct AntennaOrientation { + /// Azimuth from the room frame's +X axis, counter-clockwise (radians). + pub azimuth_rad: f32, + /// Elevation above the horizontal plane (radians). + pub elevation_rad: f32, +} + +fn unknown_method() -> String { + "unknown".to_string() +} + +/// Per-node transceiver geometry recorded at enrollment (ADR-152 §2.1.1). +/// +/// Stored in the [`EnrollmentSession`](crate::EnrollmentSession) event log and +/// snapshotted into the [`SpecialistBank`](crate::SpecialistBank), so a bank +/// always carries the layout it was trained under. Schema-versioned: banks and +/// sessions persisted before this record existed deserialize with no geometry +/// (serde defaults), same pattern as `PresenceSpecialist::mean_dist_threshold`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct NodeGeometry { + /// Node this record describes (same id space as the multistatic fusion). + pub node_id: u8, + /// Estimated position, if measured. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub position: Option, + /// Antenna orientation, if measured. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub orientation: Option, + /// Known distances to other nodes (node_id → meters). Empty = not measured. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub distances_m: BTreeMap, + /// How the geometry was obtained — free-form provenance, e.g. + /// `"tape-measure"`, `"checkerboard"`, `"floor-plan"`, `"unknown"`. + #[serde(default = "unknown_method")] + pub method: String, +} + +impl NodeGeometry { + /// A record with everything unknown except the node id. + pub fn unknown(node_id: u8) -> Self { + Self::new(node_id, "unknown") + } + + /// A record with no measurements yet, tagged with its provenance method. + pub fn new(node_id: u8, method: impl Into) -> Self { + Self { + node_id, + position: None, + orientation: None, + distances_m: BTreeMap::new(), + method: method.into(), + } + } + + /// Set the position estimate (builder style). + pub fn with_position(mut self, x_m: f32, y_m: f32, z_m: f32) -> Self { + self.position = Some(PositionEstimate { x_m, y_m, z_m }); + self + } + + /// Set the antenna orientation (builder style). + pub fn with_orientation(mut self, azimuth_rad: f32, elevation_rad: f32) -> Self { + self.orientation = Some(AntennaOrientation { + azimuth_rad, + elevation_rad, + }); + self + } + + /// Record a known distance to another node (builder style). + pub fn with_distance(mut self, other_node_id: u8, meters: f32) -> Self { + self.distances_m.insert(other_node_id, meters); + self + } + + /// `true` when nothing beyond the node id was measured. + pub fn is_unmeasured(&self) -> bool { + self.position.is_none() && self.orientation.is_none() && self.distances_m.is_empty() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn full_record_roundtrips() { + let g = NodeGeometry::new(1, "tape-measure") + .with_position(0.5, 2.0, 1.2) + .with_orientation(std::f32::consts::FRAC_PI_2, 0.0) + .with_distance(2, 3.4); + let json = serde_json::to_string(&g).unwrap(); + let back: NodeGeometry = serde_json::from_str(&json).unwrap(); + assert_eq!(back, g); + assert_eq!(back.distances_m.get(&2), Some(&3.4)); + assert!(!back.is_unmeasured()); + } + + #[test] + fn all_optional_empty_roundtrips() { + let g = NodeGeometry::unknown(7); + assert!(g.is_unmeasured()); + let json = serde_json::to_string(&g).unwrap(); + // Optional fields must be omitted, not serialized as null/empty. + assert!(!json.contains("position")); + assert!(!json.contains("orientation")); + assert!(!json.contains("distances_m")); + let back: NodeGeometry = serde_json::from_str(&json).unwrap(); + assert_eq!(back, g); + assert_eq!(back.method, "unknown"); + } + + #[test] + fn minimal_json_defaults_cleanly() { + // A record written by a producer that only knew the node id. + let g: NodeGeometry = serde_json::from_str(r#"{"node_id":3}"#).unwrap(); + assert_eq!(g.node_id, 3); + assert!(g.is_unmeasured()); + assert_eq!(g.method, "unknown"); + } +} diff --git a/v2/crates/wifi-densepose-calibration/src/lib.rs b/v2/crates/wifi-densepose-calibration/src/lib.rs index 18a841ac..8de1401f 100644 --- a/v2/crates/wifi-densepose-calibration/src/lib.rs +++ b/v2/crates/wifi-densepose-calibration/src/lib.rs @@ -7,7 +7,9 @@ //! //! Stages (ADR-151 §1.3): //! 1. **baseline** — empty-room environmental fingerprint (ADR-135; consumed here). -//! 2. **enroll** — guided anchors with an adaptive quality gate ([`anchor`], [`enrollment`]). +//! 2. **enroll** — guided anchors with an adaptive quality gate ([`anchor`], +//! [`enrollment`]) plus an optional transceiver-geometry record ([`geometry`], +//! ADR-152 §2.1.1). //! 3. **extract** — labelled feature records from anchor captures ([`extract`]). //! 4. **train** — a bank of small specialist models ([`specialist`], [`bank`]) and a //! confidence-gated mixture runtime ([`runtime`]). @@ -22,6 +24,7 @@ pub mod anchor; pub mod enrollment; pub mod error; pub mod extract; +pub mod geometry; pub mod specialist; pub mod bank; pub mod runtime; @@ -32,6 +35,7 @@ pub use bank::SpecialistBank; pub use enrollment::{AnchorQualityGate, AnchorRecorder}; pub use error::{CalibrationError, Result}; pub use extract::AnchorFeature; +pub use geometry::{AntennaOrientation, NodeGeometry, PositionEstimate}; pub use multistatic::MultiNodeMixture; pub use runtime::{MixtureOfSpecialists, RoomState}; pub use specialist::{Specialist, SpecialistKind, SpecialistReading}; diff --git a/v2/crates/wifi-densepose-calibration/src/multistatic.rs b/v2/crates/wifi-densepose-calibration/src/multistatic.rs index 4c52a55b..61d3ffb2 100644 --- a/v2/crates/wifi-densepose-calibration/src/multistatic.rs +++ b/v2/crates/wifi-densepose-calibration/src/multistatic.rs @@ -20,6 +20,7 @@ use std::collections::BTreeMap; use crate::bank::SpecialistBank; use crate::extract::Features; +use crate::geometry::NodeGeometry; use crate::runtime::{MixtureOfSpecialists, RoomState}; use crate::specialist::SpecialistReading; @@ -60,6 +61,26 @@ impl MultiNodeMixture { self.nodes.len() } + /// The transceiver-geometry snapshot a node's bank was trained under + /// (ADR-152 §2.1.1), if its enrollment recorded one. Threaded through for + /// the fusion logic; **not used algorithmically yet** — geometry-aware + /// fusion is the §2.1.2 learned-embedding work (ADR-151 P6). + pub fn node_geometry(&self, node_id: u8) -> Option<&[NodeGeometry]> { + self.nodes + .get(&node_id) + .map(|e| e.mixture.bank().geometry.as_slice()) + .filter(|g| !g.is_empty()) + } + + /// All registered nodes' geometry snapshots, keyed by node id. Nodes whose + /// banks carry no geometry are omitted. + pub fn geometries(&self) -> BTreeMap { + self.nodes + .keys() + .filter_map(|&id| self.node_geometry(id).map(|g| (id, g))) + .collect() + } + /// Fuse per-node feature windows into one room state. Nodes without a feature /// entry this window are skipped. pub fn infer(&self, per_node: &BTreeMap) -> RoomState { @@ -205,6 +226,22 @@ mod tests { assert_eq!(m.node_count(), 2); } + #[test] + fn geometry_threads_through_to_fusion() { + let geo1 = vec![NodeGeometry::new(1, "tape-measure") + .with_position(0.0, 0.0, 1.0) + .with_distance(2, 3.0)]; + let mut m = MultiNodeMixture::new(); + m.add_node(1, bank("b1").with_geometry(geo1.clone()), "b1"); + m.add_node(2, bank("b1"), "b1"); // no geometry recorded for node 2 + assert_eq!(m.node_geometry(1), Some(geo1.as_slice())); + assert_eq!(m.node_geometry(2), None, "geometry-free bank reads None"); + assert_eq!(m.node_geometry(9), None, "unknown node reads None"); + let all = m.geometries(); + assert_eq!(all.len(), 1); + assert_eq!(all.get(&1), Some(&geo1.as_slice())); + } + #[test] fn presence_or_across_nodes() { let mut m = MultiNodeMixture::new(); diff --git a/v2/crates/wifi-densepose-calibration/tests/full_loop.rs b/v2/crates/wifi-densepose-calibration/tests/full_loop.rs index e518ddaa..a9b9fa59 100644 --- a/v2/crates/wifi-densepose-calibration/tests/full_loop.rs +++ b/v2/crates/wifi-densepose-calibration/tests/full_loop.rs @@ -28,7 +28,7 @@ use num_complex::Complex64; use wifi_densepose_calibration::extract::Features; use wifi_densepose_calibration::{ AnchorFeature, AnchorLabel, AnchorQualityGate, AnchorRecorder, EnrollmentEvent, - EnrollmentSession, MixtureOfSpecialists, SpecialistBank, SpecialistKind, + EnrollmentSession, MixtureOfSpecialists, NodeGeometry, SpecialistBank, SpecialistKind, }; use wifi_densepose_core::types::{AntennaConfig, CsiFrame, CsiMetadata, DeviceId, FrequencyBand}; use wifi_densepose_signal::{BaselineCalibration, CalibrationConfig, CalibrationRecorder}; @@ -271,6 +271,19 @@ fn full_loop_baseline_enroll_extract_train_infer() { // -- Stage 2: guided-anchor enrollment with the quality gate ------------- let gate = AnchorQualityGate::default(); let mut session = EnrollmentSession::new(room_id, &baseline_id, 1_700_000_000); + + // Transceiver geometry recorded at session start (ADR-152 §2.1.1): a + // two-node layout, one tape-measured, one unknown — all fields optional. + let geometry = vec![ + NodeGeometry::new(1, "tape-measure") + .with_position(0.0, 0.0, 1.2) + .with_orientation(0.0, 0.0) + .with_distance(2, 3.5), + NodeGeometry::unknown(2), + ]; + session.record_geometry(geometry.clone(), 1_700_000_000); + assert_eq!(session.geometry(), Some(geometry.as_slice())); + let mut features: Vec = Vec::new(); for (i, label) in AnchorLabel::SEQUENCE.into_iter().enumerate() { @@ -345,8 +358,10 @@ fn full_loop_baseline_enroll_extract_train_infer() { ); // -- Stage 4: train the specialist bank + JSON persistence round-trip ---- + // The bank snapshots the geometry the enrollment recorded (ADR-152 §2.1.1). let bank = SpecialistBank::train(room_id, &baseline_id, &features, 1_700_000_400) - .expect("bank training"); + .expect("bank training") + .with_geometry(session.geometry().map(<[_]>::to_vec).unwrap_or_default()); assert_eq!(bank.room_id, room_id); assert_eq!(bank.anchor_count, 8); let kinds = bank.trained_kinds(); @@ -373,6 +388,10 @@ fn full_loop_baseline_enroll_extract_train_infer() { bank.presence.as_ref().map(|p| p.threshold), "presence threshold must survive persistence" ); + assert_eq!( + reloaded.geometry, geometry, + "the enrollment geometry snapshot must survive bank persistence" + ); // -- Stage 5: runtime inference through the mixture ---------------------- let mix = MixtureOfSpecialists::new(reloaded);