//! Geometry embedding — deterministic featurization of transceiver layout
//! (ADR-152 §2.1.2, the second half of the PerceptAlign fix).
//!
//! §2.1.1 ([`geometry`](crate::geometry)) *records* the layout; this module
//! turns that record into a fixed-length conditioning vector. PerceptAlign
//! fuses transceiver-position embeddings with CSI features so pose heads stop
//! memorising the deployment layout; transplanted to our per-room banks, the
//! ADR-151 P6 LoRA heads will concatenate this vector with the backbone
//! embedding. Statistical specialists (current) ignore it. The crate is pure
//! Rust and edge-deployable (no torch/candle), so the "embedding" is **not a
//! trained network** — it is a deterministic, well-conditioned featurization;
//! the learned part (if any) lives in the head that consumes it.
//!
//! Properties, by construction: **fixed dimension** ([`GeometryEmbedding::DIM`]
//! = 32) for any node count (designed for 1..=8; more nodes still aggregate,
//! only the per-node flag slots truncate); **permutation-invariant** (nodes
//! sorted by `node_id`; aggregates are order-free); and **total** — missing
//! data degrades gracefully: an all-unknown layout (or empty slice) yields a
//! well-defined vector, never `NaN`/`inf`; adversarial inputs (non-finite
//! coordinates, absurd magnitudes) are treated as unmeasured.
//!
//! ## Slot layout (v1)
//!
//! Positions/distances are raw meters (room-scale values are already
//! O(1)–O(10)); angles in radians; fractions in `[0, 1]`. Unmeasurable
//! slots are `0.0`.
//!
//! | Slot | Content | Units / range |
//! |-------|---------|----------------|
//! | 0 | node count / 8 | `[0, 2]` (clamped; 8 nodes → 1.0) |
//! | 1 | fraction of nodes with a position | `[0, 1]` |
//! | 2 | fraction of nodes with an orientation | `[0, 1]` |
//! | 3 | fraction of nodes with ≥1 measured inter-node distance | `[0, 1]` |
//! | 4–6 | position centroid (x, y, z) | m, clamped ±[`MAX_COORD_M`] |
//! | 7–9 | position std-dev per axis (x, y, z) | m, `[0,` [`MAX_COORD_M`]`]` |
//! | 10–12 | pairwise position distance min / mean / max | m |
//! | 13–15 | inter-node distance min / mean / max — measured `distances_m`, falling back to position-derived distance per pair | m |
//! | 16 | measured-distance pair coverage (measured pairs / possible pairs) | `[0, 1]` |
//! | 17–18 | azimuth circular mean resultant vector (cos, sin components) | `[-1, 1]` |
//! | 19 | azimuth concentration (mean resultant length `R`; 1 = all boresights parallel) | `[0, 1]` |
//! | 20 | mean elevation | rad, `[-π/2, π/2]` |
//! | 21–22 | geometric diversity: eigenvalue ratios `λ2/λ1`, `λ3/λ1` of the position covariance — 0 = collinear/degenerate, →1 = isotropic spread (chosen over polygon area: defined for any node count, no 2-D planarity assumption) | `[0, 1]` |
//! | 23 | dominant spread scale `sqrt(λ1)` | m |
//! | 24–31 | per-node measurement flags, nodes sorted by `node_id`, rank `i` → slot `24+i` (first 8 nodes): `0` = no node at this rank, else `0.25` (node exists) `+0.25` (position) `+0.25` (orientation) `+0.25` (≥1 measured distance) | `{0}` ∪ `[0.25, 1]` |
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use crate::geometry::NodeGeometry;
/// Coordinates / distances beyond this magnitude (meters) are treated as
/// unmeasured — rooms are not kilometer-scale, and the guard keeps
/// adversarial values from overflowing the covariance into `inf`.
pub const MAX_COORD_M: f32 = 1_000.0;
/// Number of per-node flag slots (slots 24..32); designed node count 1..=8.
const NODE_SLOTS: usize = 8;
fn schema_v1() -> u32 {
GeometryEmbedding::SCHEMA_VERSION
}
/// Fixed-length featurization of a room's transceiver layout (ADR-152 §2.1.2).
///
/// Computed deterministically from the [`NodeGeometry`] snapshot via
/// [`GeometryEmbedding::from_nodes`]; the conditioning input the ADR-151 P6
/// LoRA heads concatenate with the backbone embedding. Not stored in the bank
/// — derive it via [`SpecialistBank::geometry_embedding`](crate::SpecialistBank::geometry_embedding)
/// — but schema-versioned and serde-serializable (the `NodeGeometry` compat
/// pattern) for callers that snapshot it alongside trained head weights.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct GeometryEmbedding {
/// Slot-layout version; bump when the slot table changes meaning.
#[serde(default = "schema_v1")]
pub schema_version: u32,
/// The embedding vector — see the module docs for the slot table.
/// Invariant: every value is finite (never `NaN`/`inf`).
pub values: [f32; GeometryEmbedding::DIM],
}
impl Default for GeometryEmbedding {
/// All slots zero — the embedding of an empty layout.
fn default() -> Self {
Self {
schema_version: Self::SCHEMA_VERSION,
values: [0.0; Self::DIM],
}
}
}
impl GeometryEmbedding {
/// Output dimension. Fixed regardless of node count.
pub const DIM: usize = 32;
/// Current slot-layout version.
pub const SCHEMA_VERSION: u32 = 1;
/// The embedding as a slice (always [`Self::DIM`] long).
pub fn as_slice(&self) -> &[f32] {
&self.values
}
/// Compute the embedding from a geometry snapshot. Permutation-invariant
/// (nodes are sorted by `node_id` internally) and total: any input —
/// empty, all-unknown, non-finite — produces a fully finite vector.
pub fn from_nodes(nodes: &[NodeGeometry]) -> Self {
let mut v = [0.0f32; Self::DIM];
// Permutation invariance: order by node_id before per-node slots.
let mut sorted: Vec<&NodeGeometry> = nodes.iter().collect();
sorted.sort_by_key(|g| g.node_id);
let n = sorted.len();
if n == 0 {
return Self::default();
}
// Sanitized views: a measurement with non-finite or absurd components
// counts as not taken at all.
let positions: Vec