feat(calibration): NodeGeometry transceiver-geometry recording (ADR-152 §2.1.1)

PerceptAlign-motivated geometry capture at enrollment: per-node optional
records (position, antenna orientation, inter-node distances, acquisition
method) — recorded when known, never required. Event-sourced via
EnrollmentEvent::GeometryRecorded (latest recording wins); persisted on
SpecialistBank with serde defaults so pre-ADR-152 bank JSON loads cleanly
(fixture-proven, and geometry-free banks serialize byte-shape-identical
to the old schema); threaded through MultiNodeMixture as data only — the
learned geometry embeddings and algorithmic fusion use are §2.1.2,
deliberately deferred until the ADR-151 P6 LoRA heads exist.

Geometry recorded from now on means banks captured today remain usable
for layout-conditioned training later — you can't retroactively add
geometry to data you didn't record.

8 new tests (3 geometry, 2 anchor, 2 bank, 1 multistatic) + full-loop
extension (2-node geometry, one tape-measured + one unknown, surviving
the bank JSON round-trip the runtime loads from). 50/50 calibration
(both feature configs) + 23 CLI tests green.

Co-Authored-By: RuFlo <ruv@ruv.net>
This commit is contained in:
ruv
2026-06-10 15:48:50 -04:00
parent 2a307138f2
commit 1981149329
6 changed files with 348 additions and 3 deletions
@@ -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<NodeGeometry>,
/// 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<NodeGeometry>, 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<AnchorLabel> {
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));
@@ -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<NodeGeometry>,
/// Presence gate (requires the `empty` + an occupied anchor).
pub presence: Option<PresenceSpecialist>,
@@ -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<NodeGeometry>) -> 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();
@@ -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<PositionEstimate>,
/// Antenna orientation, if measured.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub orientation: Option<AntennaOrientation>,
/// Known distances to other nodes (node_id → meters). Empty = not measured.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub distances_m: BTreeMap<u8, f32>,
/// 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<String>) -> 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");
}
}
@@ -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};
@@ -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<u8, &[NodeGeometry]> {
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<u8, Features>) -> 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();
@@ -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<AnchorFeature> = 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);