mirror of
https://github.com/ruvnet/RuView
synced 2026-08-03 19:21:42 +00:00
17471e93ff
* 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> * feat(training): two-checkerboard camera↔room calibration for ADR-079 labels (ADR-152 §2.1.3) Defends the camera-supervised pipeline against PerceptAlign's "coordinate overfitting": MediaPipe keypoints were emitted in raw camera coordinates with no shared frame and no transceiver-geometry metadata — the exact label shape that memorizes deployment layout and collapses cross-layout. - scripts/calibrate-camera-room.py + calibration_lib.py: OpenCV two-checkerboard calibration → versioned bundle JSON (intrinsics, camera→room extrinsics, checkerboard spec, transceiver geometry, sha256 calibration_id). Intrinsics resolve from file > cache > multi-view computation > loud-warning 2-view fallback. - collect-ground-truth.py --calibration <bundle>: every sample gains keypoints_room (unit bearing rays from the camera center in the room frame — documented projective alignment; raw image coords preserved so training chooses), camera_origin_room, calibration_id, and the transceiver geometry stamp. Without the flag, output is byte-identical to before (tested) + a one-line ADR-152 warning. Design finding (recorded for ADR-152): a single planar checkerboard's corner grid is centrosymmetric — the reversed corner ordering fits a ghost camera pose with IDENTICAL reprojection error, so per-board flip disambiguation is mathematically ill-posed. solve_two_board_extrinsics solves the joint wall+floor set over all 4 flip combinations, where the minimum is unique — an independent reason the TWO-checkerboard method is required, beyond what PerceptAlign states. 15 headless pytest tests green (synthetic corners: extrinsics recovery incl. ghost resolution, bundle round-trip + hash stability, ray transforms w/ distortion + cross-resolution, no-calibration byte identity). Co-Authored-By: RuFlo <ruv@ruv.net> * feat(benchmarks): WiFlow-STD reproduction harness + measurement (a) results (ADR-152 §2.2) Shipped checkpoint REFUTED (0.08% PCK@20, wrong keypoint normalization); 6 reproducibility defects documented (broken imports, corrupted dataset tail with float32-max garbage that NaN-poisons fp16 BatchNorm, unreachable test phase). After repairs, retraining with upstream defaults reproduces 96.09% PCK@20 full-test / 96.61% corruption-free (published 97.25%) on RTX 5080. Claims graded MEASURED-EQUIVALENT; 2.23M params + ~0.055 GFLOPs verified. Third-party code/weights/data stay out of tree (gitignored). Co-Authored-By: claude-flow <ruv@ruv.net> * feat: ADR-152 Rust integrations + ADR-153 802.11bf protocol model - calibration: GeometryEmbedding — 32-slot permutation-invariant NodeGeometry featurization for future LoRA-head conditioning (ADR-152 §2.1.2); derived SpecialistBank::geometry_embedding() accessor; 59 tests - train: MaePretrainConfig + patchify/random-mask with UNSW measured recipe (80% masking, (30,3) patches; ADR-152 §2.3, arXiv 2511.18792); strict no-truncate/no-NaN policy; proptest properties - train: WiFlowStdModel — tch-gated port of the verified ~96%-PCK@20 WiFlow-STD architecture (ADR-152 §2.2 beyond-SOTA); ungated param formula pinned to 2,225,042; 15/17-keypoint support; 239 crate tests - hardware: ieee80211bf forward-compatibility protocol model (ADR-153): SpecProfile gates, SensingCapabilities negotiation, required ConsentMode, session FSM, SensingTransport + SimTransport + OpportunisticCsiBridge; full acceptance checklist covered; 156+4 tests - deps: ruvector bumps per ADR-152 §2.6 survey (mincut/solver 2.0.6, attention 2.1.0, gnn 2.2.0); vendor/ruvector synced to a083bd77f - docs: ADR-153 accepted; ADR-152 §2.2 status, §2.4 amendment, §2.6 added Workspace: 162 test suites green (--no-default-features); Python proof PASS. Known pre-existing flake: homecore-api env_empty_falls_back_to_defaults (unserialized env-var mutation) — untouched, follow-up. Co-Authored-By: claude-flow <ruv@ruv.net> * docs: CHANGELOG + CLAUDE.md entries for ADR-152 integrations and ADR-153 Co-Authored-By: claude-flow <ruv@ruv.net> * fix(train): repair tch-backend bit-rot — gated path compiles and tests run again Mechanical API refresh against current tch: Vec::from(Tensor) -> try_from (+ explicit flatten), numel() usize cast, Rem/div ops -> remainder() / divide_scalar_mode(floor) — the latter fixed a silent true-division bug in heatmap argmax decoding; clamp(1.0, f64::MAX) -> clamp_min (torch 2.x scalar overflow panic); petgraph EdgeRef import; missing EvalMetrics and verify_checkpoint_dir APIs that tests documented. wiflow_std roundtrip test uses safetensors (.pt _save_parameters roundtrip broken in torch 2.11 Windows). Gated: 349 passed (incl. all 20 wiflow_std); ungated: unchanged. Known pre-existing: gaussian-heatmap convention mismatch (2 tests), proof seed race under parallel threads — documented, deliberate follow-ups. Co-Authored-By: claude-flow <ruv@ruv.net> * feat(train): WiFlow-STD PyTorch->tch weight import + numerical parity proof export_to_safetensors.py maps the retrained checkpoint (295 tensors -> 248 mapped, param sum exactly 2,225,042; num_batches_tracked dropped) into a tch-loadable safetensors plus a deterministic parity fixture. Gated #[ignore] integration test loads it strictly and asserts forward-pass agreement: max abs diff 1.192e-7 on the seed-42 fixture. dump_variable_names test makes the tch name layout authoritative. Zero architecture discrepancies found. Co-Authored-By: claude-flow <ruv@ruv.net> * fix: workflow-review findings — BN gamma init, ThresholdParams serde, init docs Concurrent validation workflow (2 review lanes + adversarial verification, 13 agents): 5 confirmed findings, 3 refuted. Fixes: - wiflow_std: pin BatchNorm gamma to 1.0 (tch default draws Uniform(0,1) — silently halves activations in from-scratch training; loaded checkpoints unaffected, parity re-verified after the change) - wiflow_std: document the conv-init divergences vs the reference's effective kaiming_normal(fan_out) re-init (from-scratch dynamics only) - ieee80211bf: ThresholdParams deserialization validates via try_from so the <=100 invariant holds for untrusted payloads (+ rejection test) Benchmarks (release, ruvzen): GeometryEmbedding 1.84us/call (542k/s), MAE tokenization 7.38us/window (135k/s), 802.11bf FSM 8.9M events/s — nothing suspicious. Co-Authored-By: claude-flow <ruv@ruv.net> * docs(adr): ADR-152 §2.1.4 gate resolved — PerceptAlign repo MIT, dataset on HF Co-Authored-By: claude-flow <ruv@ruv.net> * feat(benchmarks): edge optimization measured + measurement (b) blocked + 92.9% retraction Edge optimization (ADR-152 optimize track): ONNX Runtime fp32 is the CPU latency win (3.2 ms/window, ~3.4x faster than torch, parity 2.4e-7); ORT dynamic int8 reaches 2.44 MB (paper's ~2.2 MB claim plausible only via conv-capable toolchains; -0.16pt PCK@20, +18% MPJPE, 2x slower); torch dynamic quant converts 0% of this conv-only model; fp16 halves storage free but is slower on CPU. Measurement (b) BLOCKED-ON-DATA: only 1,077 paired ESP32 windows exist (stop rule <2k). Forensic recheck of the surviving April holdout RETRACTS the ADR-079 '92.9% PCK@20' figure: constant-output model, absolute (not torso) threshold, 69 near-static frames — mean predictor scores 100% under that protocol; torso-PCK@20 is 19.1%. Corroborates PR #535. Stale citations removed from user-guide, readme-details, ADR-152 §2.1.3; no-citation rule extended to ADR-079 accuracy claims. Unblock: >=2k-window multi-pose paired session + torso-PCK re-baseline. Co-Authored-By: claude-flow <ruv@ruv.net> * docs(user-guide): corrected camera-supervised collection tutorial Step 0 CSI-rate check + session-length math (window yield = frames/20 — the May session's 8x under-delivery was a ~12 Hz CSI rate, not an aligner bug); two-checkerboard calibration step (ADR-152 §2.1.3); pose-variety and confidence guidance; torso-normalized PCK + temporal-split + pred-variance eval protocol (lessons from the 92.9% retraction); scale presets re-keyed to realistic window counts. Co-Authored-By: claude-flow <ruv@ruv.net> * feat(benchmarks): static PTQ int8 (calibrated) results + overnight capture script Conv-only static QDQ beats dynamic int8 on accuracy (PCK@20 96.61-96.63% vs 96.52%, MPJPE +10% vs +18% over fp32) at ~equal size/latency; all-ops QDQ strictly worse (int8 activations through attention glue). Entropy calibration verified bit-identical to MinMax on this data. Deployment: ONNX fp32 for speed (3.2ms), static conv-only QDQ for smallest (2.53MB). Also: scripts/overnight-empty-capture.py — segmented UDP CSI recorder for empty-room baselines (no glob collisions, detach-safe). Co-Authored-By: claude-flow <ruv@ruv.net> * feat(benchmarks): measurement (b) MEASURED — optimization transfer only, mean-pose baseline wins WiFlow-STD fine-tuned on 2,046 fresh single-room ESP32 paired windows (temporal 70/15/15, 70->540 adapter, K=17): pretrained-init 65% PCK@20 vs scratch 0% (optimization transfer) but frozen-trunk ~0% (no feature transfer), and NOTHING beats the mean-pose baseline (95.9% PCK@20 — single subject, near-static normalized coords). Honesty gates held: pred std 0.0113 (non-constant model) but mean-baseline dominance means no citable CSI->pose capability from this data. ADR-152 open question 1 answered partially; definitive answer needs multi-subject/position data. Two new aligner findings: heterogeneous csi_shape with silent zero-padding (~20%), and extractCsiMatrix's transposed shape label (frame-major data, [nSc, nFrames] label) — fixes pending. Co-Authored-By: claude-flow <ruv@ruv.net> * feat(benchmarks): efficiency sweep MEASURED — half model dominates full reference Compact WiFlow-STD variants on the same data/split/protocol: half (843,834 params, 0.38x) strictly dominates the 2.23M reference (PCK@20 96.62 vs 96.61, PCK@50 99.47 vs 99.11, MPJPE 0.00898 vs 0.0094) — the published architecture is over-parameterized for its own benchmark. quarter (338k) 96.05%; tiny (56,290 params, 1/39.5) holds 94.11% — a ~220KB fp32 edge candidate. In-domain caveats recorded; cross-domain untested. Co-Authored-By: claude-flow <ruv@ruv.net> * feat(train): compact WiFlow-STD presets in Rust + tiny edge artifact (ADR-152) WiFlowStdConfig gains half()/quarter()/tiny() mirroring the overnight sweep exactly: TcnGroupsMode (Fixed/Gcd/Depthwise), input_pw_groups, derived stride schedule and decoder-mid (all default to upstream behavior; legacy serde JSON unaffected). Param formulas pin to trained ground truth first try: 843,834 / 338,600 / 56,290; default 2,225,042 pin and 1.192e-7 parity unchanged. 248 tests green. Tiny edge artifact (tiny_edge_bench.py): ONNX fp32 = 295 KB, 0.66 ms/win (~1,500/s CPU), 94.11% PCK@20 (matches sweep clean-test exactly; parity 1.49e-7). Static int8 is a bad trade at this scale (-1.43pt, +19% MPJPE, -16% size, slower) — recorded as negative result. Export note: width-16 breaks AdaptiveAvgPool((15,1)) TorchScript export; replaced by exact mean+matmul equivalent, proven by parity. Co-Authored-By: claude-flow <ruv@ruv.net> * fix: resolve all 10 confirmed code-review findings (7-angle review, 20/20 verified) wiflow_std: min_feature_width (default 15) replaces the keypoints->stride coupling — for_keypoints(17) now provably builds the trained [2,2,2,2] graph and pools 15->17, matching the validated Python protocol (pinned by tests); param_count() total on invalid configs; random_mask returns Result and rejects non-finite/out-of-range ratios; trainer checkpoints switched to safetensors (.pt VarStore roundtrip broken on Windows torch 2.11). ieee80211bf: SBP proxy now re-triggers instances and relays reports via Action::RelaySbpReport -> SensingFrame::SbpReport (clients consume via their existing path); missed_instances reset on success = consecutive semantics; SessionTable gains a guarded SBP entry point + unknown-id drop counter; initiator-role sessions reject inbound setup/SBP requests (RejectedNotSupported) closing the idle hijack; StartSetup/StartSbp outside Idle return InvalidStateForCommand; SBP validation unified through evaluate_setup with a 1:1 SetupStatus->SbpStatus mapping. events.rs split out to honor the 500-line cap. calibration/cli: enrollment geometry now actually reaches trained banks — both production call sites attach .with_geometry; --geometry flag on train-room and POST /enroll/geometry + train-body geometry on calibrate-serve give production a recording surface; geometry-free banks log the ADR-152 §2.1.2 note. benchmarks: corruption masks committed as ground truth (unregenerable after in-place cleaning; verified bit-identical regeneration from the pristine copy) + generate_corruption_masks.py producer; _bench_common.py dedups the 5x-copied shim/evaluate/seed/remap (post-refactor PCK@20 re-verified equal to the last digit); remote scripts get the mmap patch; tiny_edge --calib validated multiple-of-64; onnx_bench --help no longer executes (and overwrote) the export — artifact restored byte-exact. Workspace: 2,963 tests passed, 0 failed; Python proof PASS. Co-Authored-By: claude-flow <ruv@ruv.net> * ci: build workspace tests without debuginfo — runner disk exhaustion The combined 38-crate debug target exceeds the GitHub runner's disk ('final link failed: No space left on device'); the same tree measured 151GB locally with full debuginfo. CARGO_PROFILE_{DEV,TEST}_DEBUG=0 shrinks the target ~5-10x; debuginfo serves no purpose in CI test runs. Co-Authored-By: claude-flow <ruv@ruv.net>
500 lines
22 KiB
Rust
500 lines
22 KiB
Rust
//! 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<Option<[f32; 3]>> = sorted.iter().map(|g| valid_position(g)).collect();
|
||
let orientations: Vec<Option<(f32, f32)>> =
|
||
sorted.iter().map(|g| valid_orientation(g)).collect();
|
||
let measured = measured_pairs(&sorted);
|
||
let node_has_dist = |id: u8| measured.keys().any(|&(a, b)| a == id || b == id);
|
||
let has_dist: Vec<bool> = sorted.iter().map(|g| node_has_dist(g.node_id)).collect();
|
||
|
||
// Slots 0–3: node count + measurement-presence fractions.
|
||
let nf = n as f32;
|
||
v[0] = (nf / NODE_SLOTS as f32).min(2.0);
|
||
v[1] = positions.iter().flatten().count() as f32 / nf;
|
||
v[2] = orientations.iter().flatten().count() as f32 / nf;
|
||
v[3] = has_dist.iter().filter(|&&d| d).count() as f32 / nf;
|
||
|
||
// Slots 4–9: centroid + per-axis std of the known positions.
|
||
let known: Vec<[f32; 3]> = positions.iter().flatten().copied().collect();
|
||
if !known.is_empty() {
|
||
let kf = known.len() as f32;
|
||
let mut centroid = [0.0f32; 3];
|
||
for p in &known {
|
||
for (c, x) in centroid.iter_mut().zip(p) {
|
||
*c += x / kf;
|
||
}
|
||
}
|
||
for axis in 0..3 {
|
||
v[4 + axis] = clamp_m(centroid[axis]);
|
||
let mut var = 0.0;
|
||
for p in &known {
|
||
var += (p[axis] - centroid[axis]).powi(2) / kf;
|
||
}
|
||
v[7 + axis] = clamp_m(var.max(0.0).sqrt());
|
||
}
|
||
|
||
// Slots 10–12: pairwise position distance stats.
|
||
let mut dists = Vec::new();
|
||
for i in 0..known.len() {
|
||
for j in (i + 1)..known.len() {
|
||
dists.push(euclidean(&known[i], &known[j]));
|
||
}
|
||
}
|
||
write_min_mean_max(&mut v, 10, &dists);
|
||
|
||
// Slots 21–23: geometric diversity from the position covariance
|
||
// eigenstructure (see module docs for why over polygon area).
|
||
let (l1, l2, l3) = covariance_eigenvalues(&known, ¢roid);
|
||
if l1 > f32::EPSILON {
|
||
v[21] = (l2 / l1).clamp(0.0, 1.0);
|
||
v[22] = (l3 / l1).clamp(0.0, 1.0);
|
||
}
|
||
v[23] = clamp_m(l1.max(0.0).sqrt());
|
||
}
|
||
|
||
// Slots 13–16: inter-node distances — measured first, position fallback.
|
||
let mut inter = Vec::new();
|
||
for i in 0..n {
|
||
for j in (i + 1)..n {
|
||
let key = pair_key(sorted[i].node_id, sorted[j].node_id);
|
||
if let Some(&d) = measured.get(&key) {
|
||
inter.push(d);
|
||
} else if let (Some(a), Some(b)) = (&positions[i], &positions[j]) {
|
||
inter.push(euclidean(a, b));
|
||
}
|
||
}
|
||
}
|
||
write_min_mean_max(&mut v, 13, &inter);
|
||
let possible_pairs = n * n.saturating_sub(1) / 2;
|
||
if possible_pairs > 0 {
|
||
v[16] = (measured.len() as f32 / possible_pairs as f32).clamp(0.0, 1.0);
|
||
}
|
||
|
||
// Slots 17–20: orientation statistics (circular mean of azimuth).
|
||
let known_orient: Vec<(f32, f32)> = orientations.iter().flatten().copied().collect();
|
||
if !known_orient.is_empty() {
|
||
let of = known_orient.len() as f32;
|
||
let c = known_orient.iter().map(|(az, _)| az.cos()).sum::<f32>() / of;
|
||
let s = known_orient.iter().map(|(az, _)| az.sin()).sum::<f32>() / of;
|
||
v[17] = c.clamp(-1.0, 1.0);
|
||
v[18] = s.clamp(-1.0, 1.0);
|
||
v[19] = (c * c + s * s).sqrt().clamp(0.0, 1.0);
|
||
let el = known_orient.iter().map(|(_, e)| e).sum::<f32>() / of;
|
||
v[20] = el.clamp(-std::f32::consts::FRAC_PI_2, std::f32::consts::FRAC_PI_2);
|
||
}
|
||
|
||
// Slots 24–31: per-node measurement flags (first NODE_SLOTS by id).
|
||
for i in 0..n.min(NODE_SLOTS) {
|
||
v[24 + i] = 0.25
|
||
+ 0.25 * f32::from(positions[i].is_some() as u8)
|
||
+ 0.25 * f32::from(orientations[i].is_some() as u8)
|
||
+ 0.25 * f32::from(has_dist[i] as u8);
|
||
}
|
||
|
||
// The finite invariant must hold whatever happened above.
|
||
for x in &mut v {
|
||
if !x.is_finite() {
|
||
*x = 0.0;
|
||
}
|
||
}
|
||
|
||
Self {
|
||
schema_version: Self::SCHEMA_VERSION,
|
||
values: v,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// A position whose components are all finite and room-scale, else `None`.
|
||
fn valid_position(g: &NodeGeometry) -> Option<[f32; 3]> {
|
||
let p = g.position?;
|
||
let ok = |c: f32| c.is_finite() && c.abs() <= MAX_COORD_M;
|
||
(ok(p.x_m) && ok(p.y_m) && ok(p.z_m)).then_some([p.x_m, p.y_m, p.z_m])
|
||
}
|
||
|
||
/// An orientation whose angles are both finite, else `None`.
|
||
fn valid_orientation(g: &NodeGeometry) -> Option<(f32, f32)> {
|
||
let o = g.orientation?;
|
||
let ok = o.azimuth_rad.is_finite() && o.elevation_rad.is_finite();
|
||
ok.then_some((o.azimuth_rad, o.elevation_rad))
|
||
}
|
||
|
||
/// Canonical unordered pair key.
|
||
fn pair_key(a: u8, b: u8) -> (u8, u8) {
|
||
(a.min(b), a.max(b))
|
||
}
|
||
|
||
/// Valid measured distances between *enrolled* nodes, deduplicated to
|
||
/// unordered pairs (both directions recorded → averaged); distances to
|
||
/// non-enrolled node ids are ignored.
|
||
fn measured_pairs(sorted: &[&NodeGeometry]) -> BTreeMap<(u8, u8), f32> {
|
||
let ids: Vec<u8> = sorted.iter().map(|g| g.node_id).collect();
|
||
let mut sums: BTreeMap<(u8, u8), (f32, u32)> = BTreeMap::new();
|
||
for g in sorted {
|
||
for (&other, &d) in &g.distances_m {
|
||
let pair_ok = other != g.node_id && ids.contains(&other);
|
||
if pair_ok && d.is_finite() && d > 0.0 && d <= MAX_COORD_M {
|
||
let e = sums.entry(pair_key(g.node_id, other)).or_insert((0.0, 0));
|
||
e.0 += d;
|
||
e.1 += 1;
|
||
}
|
||
}
|
||
}
|
||
sums.into_iter()
|
||
.map(|(k, (sum, n))| (k, sum / n as f32))
|
||
.collect()
|
||
}
|
||
|
||
fn euclidean(a: &[f32; 3], b: &[f32; 3]) -> f32 {
|
||
let mut d2 = 0.0;
|
||
for k in 0..3 {
|
||
d2 += (a[k] - b[k]).powi(2);
|
||
}
|
||
d2.sqrt()
|
||
}
|
||
|
||
/// Write min/mean/max of a sample into slots `base..base+3` (left at zero
|
||
/// when the sample is empty), clamped to the meters range.
|
||
fn write_min_mean_max(v: &mut [f32; GeometryEmbedding::DIM], base: usize, xs: &[f32]) {
|
||
if xs.is_empty() {
|
||
return;
|
||
}
|
||
let (mut min, mut max, mut sum) = (f32::INFINITY, f32::NEG_INFINITY, 0.0);
|
||
for &x in xs {
|
||
min = min.min(x);
|
||
max = max.max(x);
|
||
sum += x;
|
||
}
|
||
v[base] = clamp_m(min);
|
||
v[base + 1] = clamp_m(sum / xs.len() as f32);
|
||
v[base + 2] = clamp_m(max);
|
||
}
|
||
|
||
/// Clamp a meters-valued slot into ±[`MAX_COORD_M`], mapping non-finite to 0.
|
||
fn clamp_m(x: f32) -> f32 {
|
||
if x.is_finite() {
|
||
x.clamp(-MAX_COORD_M, MAX_COORD_M)
|
||
} else {
|
||
0.0
|
||
}
|
||
}
|
||
|
||
/// Eigenvalues `λ1 ≥ λ2 ≥ λ3 ≥ 0` of the 3×3 position covariance, via the
|
||
/// closed-form trigonometric solution for symmetric matrices (no linear-
|
||
/// algebra dependency; f64 internally for conditioning).
|
||
fn covariance_eigenvalues(points: &[[f32; 3]], centroid: &[f32; 3]) -> (f32, f32, f32) {
|
||
let nf = points.len() as f64;
|
||
// Upper triangle of the symmetric covariance: (xx, yy, zz, xy, xz, yz).
|
||
const IJ: [(usize, usize); 6] = [(0, 0), (1, 1), (2, 2), (0, 1), (0, 2), (1, 2)];
|
||
let mut m = [0.0f64; 6];
|
||
for p in points {
|
||
let d: [f64; 3] = std::array::from_fn(|i| (p[i] - centroid[i]) as f64);
|
||
for (k, &(i, j)) in IJ.iter().enumerate() {
|
||
m[k] += d[i] * d[j] / nf;
|
||
}
|
||
}
|
||
let (a, b, c, d, e, f) = (m[0], m[1], m[2], m[3], m[4], m[5]);
|
||
let p1 = d * d + e * e + f * f;
|
||
let q = (a + b + c) / 3.0;
|
||
let p2 = (a - q).powi(2) + (b - q).powi(2) + (c - q).powi(2) + 2.0 * p1;
|
||
let p = (p2 / 6.0).sqrt();
|
||
let (l1, l2, l3) = if p < 1e-12 {
|
||
(q, q, q) // (Near-)isotropic: all eigenvalues equal — diagonal incl.
|
||
} else {
|
||
// r = det((M - qI)/p) / 2, clamped into acos' domain.
|
||
let (ba, bb, bc) = ((a - q) / p, (b - q) / p, (c - q) / p);
|
||
let (bd, be, bf) = (d / p, e / p, f / p);
|
||
let det = ba * (bb * bc - bf * bf) - bd * (bd * bc - bf * be) + be * (bd * bf - bb * be);
|
||
let phi = (det / 2.0).clamp(-1.0, 1.0).acos() / 3.0;
|
||
let e1 = q + 2.0 * p * phi.cos();
|
||
let e3 = q + 2.0 * p * (phi + 2.0 * std::f64::consts::PI / 3.0).cos();
|
||
(e1, 3.0 * q - e1 - e3, e3)
|
||
};
|
||
// PSD matrix: tiny negatives are numerical noise — clamp.
|
||
(l1.max(0.0) as f32, l2.max(0.0) as f32, l3.max(0.0) as f32)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
/// A fully-measured node at `(x, y, 1)` with boresight toward +Y.
|
||
fn node(id: u8, x: f32, y: f32) -> NodeGeometry {
|
||
NodeGeometry::new(id, "tape-measure")
|
||
.with_position(x, y, 1.0)
|
||
.with_orientation(std::f32::consts::FRAC_PI_2, 0.1)
|
||
}
|
||
|
||
/// 3 nodes on a 3-4-5 triangle; the (1,2) edge also measured by tape.
|
||
fn full_layout() -> Vec<NodeGeometry> {
|
||
vec![
|
||
node(1, 0.0, 0.0).with_distance(2, 3.0),
|
||
node(2, 3.0, 0.0).with_distance(1, 3.0),
|
||
node(3, 0.0, 4.0),
|
||
]
|
||
}
|
||
|
||
fn assert_all_finite(e: &GeometryEmbedding) {
|
||
for (i, x) in e.values.iter().enumerate() {
|
||
assert!(x.is_finite(), "slot {i} is not finite: {x}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn dimension_stable_and_empty_input_is_all_zero() {
|
||
assert_eq!(GeometryEmbedding::DIM, 32);
|
||
let full = GeometryEmbedding::from_nodes(&full_layout());
|
||
assert_eq!(full.as_slice().len(), GeometryEmbedding::DIM);
|
||
let empty = GeometryEmbedding::from_nodes(&[]);
|
||
assert_eq!(empty, GeometryEmbedding::default(), "all-zero");
|
||
}
|
||
|
||
#[test]
|
||
fn all_unknown_layout_degrades_gracefully() {
|
||
let nodes = vec![NodeGeometry::unknown(1), NodeGeometry::unknown(2)];
|
||
let e = GeometryEmbedding::from_nodes(&nodes);
|
||
assert_all_finite(&e);
|
||
assert!((e.values[0] - 2.0 / 8.0).abs() < 1e-6, "node count slot");
|
||
// No measurements: presence fractions and all stats at zero …
|
||
for slot in 1..24 {
|
||
assert_eq!(e.values[slot], 0.0, "slot {slot} should be 0");
|
||
}
|
||
// … but the per-node existence flags still say two nodes were there.
|
||
assert_eq!(&e.values[24..27], &[0.25, 0.25, 0.0]);
|
||
}
|
||
|
||
#[test]
|
||
fn single_node_has_no_pairwise_stats() {
|
||
let n = NodeGeometry::new(5, "t")
|
||
.with_position(1.0, 2.0, 1.5)
|
||
.with_orientation(0.0, 0.0);
|
||
let e = GeometryEmbedding::from_nodes(&[n]);
|
||
assert_all_finite(&e);
|
||
assert_eq!(&e.values[4..7], &[1.0, 2.0, 1.5], "centroid = the node");
|
||
assert_eq!(&e.values[7..10], &[0.0, 0.0, 0.0], "no spread");
|
||
assert_eq!(&e.values[10..17], &[0.0; 7], "no pairs");
|
||
assert_eq!(e.values[17], 1.0, "cos(0)");
|
||
assert_eq!(e.values[19], 1.0, "single boresight is fully concentrated");
|
||
assert_eq!(e.values[24], 0.75, "position + orientation, no distances");
|
||
}
|
||
|
||
/// Full-measurement layout: every slot family lands where the geometry
|
||
/// says it should, and shuffling node order changes nothing.
|
||
#[test]
|
||
fn full_layout_statistics_and_permutation_invariance() {
|
||
let nodes = full_layout();
|
||
let e = GeometryEmbedding::from_nodes(&nodes);
|
||
assert!((e.values[1] - 1.0).abs() < 1e-6, "all positioned");
|
||
assert!((e.values[2] - 1.0).abs() < 1e-6, "all oriented");
|
||
// 3-4-5 triangle: position-pair distances {3, 4, 5}.
|
||
assert!((e.values[10] - 3.0).abs() < 1e-5, "min dist");
|
||
assert!((e.values[11] - 4.0).abs() < 1e-5, "mean dist");
|
||
assert!((e.values[12] - 5.0).abs() < 1e-5, "max dist");
|
||
// Inter-node stats: pair (1,2) measured, (1,3)/(2,3) from positions.
|
||
assert!((e.values[14] - 4.0).abs() < 1e-5, "mean inter-node dist");
|
||
assert!((e.values[16] - 1.0 / 3.0).abs() < 1e-6, "1 of 3 measured");
|
||
// Parallel boresights: fully concentrated, pointing +Y.
|
||
assert!(e.values[17].abs() < 1e-6, "cos(π/2)");
|
||
assert!((e.values[18] - 1.0).abs() < 1e-5, "sin(π/2)");
|
||
assert!((e.values[19] - 1.0).abs() < 1e-5, "concentration");
|
||
assert!((e.values[20] - 0.1).abs() < 1e-5, "mean elevation");
|
||
// Coplanar triangle: λ1 ≈ 4.32, λ2 ≈ 1.23 (3-4-5 covariance), λ3 = 0.
|
||
assert!((e.values[21] - 0.286).abs() < 0.01, "λ2/λ1 planar");
|
||
assert!(e.values[22] < 1e-5, "λ3/λ1 ≈ 0 — coplanar nodes");
|
||
assert!(e.values[23] > 0.5, "dominant spread is meter-scale");
|
||
// Node 3 (rank 2) recorded no distances; nodes 1, 2 did.
|
||
assert_eq!(&e.values[24..27], &[1.0, 1.0, 0.75]);
|
||
|
||
let mut shuffled = nodes;
|
||
shuffled.rotate_left(1);
|
||
shuffled.swap(0, 1);
|
||
assert_eq!(e, GeometryEmbedding::from_nodes(&shuffled));
|
||
}
|
||
|
||
#[test]
|
||
fn measured_distance_overrides_position_distance() {
|
||
// Positions say 3 m apart, the tape measure said 2.5 m: measured wins.
|
||
let nodes = vec![
|
||
NodeGeometry::new(1, "t")
|
||
.with_position(0.0, 0.0, 1.0)
|
||
.with_distance(2, 2.5),
|
||
NodeGeometry::new(2, "t").with_position(3.0, 0.0, 1.0),
|
||
];
|
||
let e = GeometryEmbedding::from_nodes(&nodes);
|
||
assert!((e.values[10] - 3.0).abs() < 1e-5, "position pair stat raw");
|
||
assert!((e.values[14] - 2.5).abs() < 1e-5, "measured wins");
|
||
assert!((e.values[16] - 1.0).abs() < 1e-6, "full pair coverage");
|
||
}
|
||
|
||
#[test]
|
||
fn adversarial_inputs_never_produce_nan() {
|
||
let nodes = vec![
|
||
NodeGeometry::new(1, "garbage")
|
||
.with_position(f32::NAN, f32::INFINITY, -0.0)
|
||
.with_orientation(f32::NAN, f32::NEG_INFINITY)
|
||
.with_distance(2, f32::NAN)
|
||
.with_distance(3, -5.0)
|
||
.with_distance(1, 1.0), // self-distance: ignored
|
||
NodeGeometry::new(2, "garbage")
|
||
.with_position(1e30, 1e30, 1e30)
|
||
.with_distance(99, 4.0), // unknown node: ignored
|
||
NodeGeometry::new(3, "garbage").with_position(2.0, 0.0, 1.0),
|
||
];
|
||
let e = GeometryEmbedding::from_nodes(&nodes);
|
||
assert_all_finite(&e);
|
||
// Only node 3's position survived sanitization.
|
||
assert!((e.values[1] - 1.0 / 3.0).abs() < 1e-6);
|
||
assert_eq!(e.values[2], 0.0, "no valid orientations");
|
||
assert_eq!(e.values[16], 0.0, "no valid measured pairs");
|
||
assert!(e.values.iter().all(|x| x.abs() <= MAX_COORD_M), "bounded");
|
||
}
|
||
|
||
#[test]
|
||
fn more_than_eight_nodes_still_aggregates() {
|
||
let nodes: Vec<NodeGeometry> = (0..12)
|
||
.map(|i| NodeGeometry::new(i, "plan").with_position(i as f32, 0.0, 1.0))
|
||
.collect();
|
||
let e = GeometryEmbedding::from_nodes(&nodes);
|
||
assert!((e.values[0] - 12.0 / 8.0).abs() < 1e-6);
|
||
// All 8 flag slots filled (positions known, ranks 0..8 by node_id).
|
||
assert!(e.values[24..32].iter().all(|&f| f == 0.5));
|
||
// Collinear nodes: zero planar/volume diversity, meter-scale spread.
|
||
assert!(e.values[21] < 1e-5);
|
||
assert!(e.values[22] < 1e-5);
|
||
assert!(e.values[23] > 1.0);
|
||
}
|
||
|
||
#[test]
|
||
fn serde_roundtrip_and_schema_default() {
|
||
let e = GeometryEmbedding::from_nodes(&full_layout());
|
||
let json = serde_json::to_string(&e).unwrap();
|
||
let back: GeometryEmbedding = serde_json::from_str(&json).unwrap();
|
||
assert_eq!(back, e);
|
||
assert_eq!(back.schema_version, GeometryEmbedding::SCHEMA_VERSION);
|
||
// JSON written by a pre-versioning producer (no version field)
|
||
// defaults to the current schema — the NodeGeometry pattern.
|
||
let vals = serde_json::to_string(&e.values).unwrap();
|
||
let bare = format!("{{\"values\":{vals}}}");
|
||
let from_bare: GeometryEmbedding = serde_json::from_str(&bare).unwrap();
|
||
assert_eq!(from_bare.schema_version, 1);
|
||
assert_eq!(from_bare.values, e.values);
|
||
}
|
||
}
|