mirror of
https://github.com/ruvnet/RuView
synced 2026-07-27 18:11:43 +00:00
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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user