diff --git a/docs/adr/ADR-152-wifi-pose-sota-2026-intake.md b/docs/adr/ADR-152-wifi-pose-sota-2026-intake.md
index b4b1231f..3f18ccc1 100644
--- a/docs/adr/ADR-152-wifi-pose-sota-2026-intake.md
+++ b/docs/adr/ADR-152-wifi-pose-sota-2026-intake.md
@@ -64,7 +64,7 @@ Pull the Apache-2.0 weights + 360k-sample dataset; run three measurements: (a) t
### 2.4 Hardware watch items — ACCEPTED (no code now)
-- **802.11bf**: track silicon/certification; revisit when any commodity chipset exposes standardized sensing measurements. Our opportunistic CSI extraction remains the mechanism until then.
+- **802.11bf**: track silicon/certification; OTA binding remains deferred until commodity chipsets expose standardized sensing measurements. **Amended by ADR-153** (2026-06-10): implement a pure Rust forward-compatibility protocol layer now — typed procedure models, a deterministic session FSM, a transport abstraction, simulation tests, and an `OpportunisticCsiBridge` that maps today's ESP32 CSI batches into standardized sensing-report shape.
- **esp_wifi_sensing**: benchmark our presence pipeline against the vendor FSM (one afternoon; useful external baseline). Do **not** treat as drop-in (refuted claim).
- **ZTECSITool AP**: optional high-resolution anchor node for the ADR-029 multistatic mesh — procurement-gated; only pursue if a 160 MHz anchor materially helps tomography.
@@ -73,6 +73,29 @@ Pull the Apache-2.0 weights + 360k-sample dataset; run three measurements: (a) t
- No pivot toward "wireless foundation model" papers that don't ship WiFi-CSI artifacts (HeterCSI, FMCW pilot, surveys).
- No DensePose-UV work item: the field has not demonstrated UV regression from commodity WiFi; keypoints remain our supervised target (F5).
+### 2.6 RuVector vendor sync + integration opportunities (added 2026-06-10)
+
+**Vendor sync record.** `vendor/ruvector` moved from pin `e38347601` (2026-05-07) to `a083bd77f` (origin/main, 3 commits past tag `ruvector-v0.2.28`; vendored workspace version 2.2.3). 111 commits in the range, roughly half NAPI-binary/lint chores. Substantive: graph condensation + differentiable min-cut (#547), core HNSW correctness fixes v2.2.3 (#502), RUSTSEC/clippy hardening (#504), ONNX embedder API-contract fix (#523/#525 — npm/TypeScript package only), dead parallel-worker import removal (#532). *Evidence: MEASURED (git range + commit-stat inspection).*
+
+**Opportunity table.** Workspace policy is crates.io versions only, so unpublished crates are WATCH by definition regardless of fit.
+
+| Crate | What it offers | wifi-densepose target | crates.io | Verdict |
+|---|---|---|---|---|
+| `ruvector-graph-condense` (new, #547) | Training-free min-cut graph condensation + **differentiable normalized-cut loss** (`DiffCutCondenser`, analytic MinCutPool-style gradients, gradient-checked tests; provenance-retaining super-nodes) | `subcarrier_selection.rs` (condense 114 subcarriers into cut-preserving regions instead of raw min-cut); auxiliary clustering regularizer for `wifi-densepose-train`; `DynamicPersonMatcher` region structure | **Not published** | **WATCH** — strongest technical fit in the sync; adopt when published. README's "no published method uses graph-cut condensation" is CLAIMED; the diffcut implementation + tests are MEASURED |
+| `ruvector-attention` 2.1.0 | #304 SOTA modules: MLA, KV-cache, SSM, sparse/MoE, hybrid search, Graph RAG (publish date 2026-03-27 matches the #304 commit — MEASURED) | Supersedes pinned 2.0.4 used by `model.rs` spatial attention + `bvp.rs`; SSM/MLA are candidate pure-Rust edge-inference primitives for the ADR-150 encoder | 2.1.0 (pinned **2.0.4**) | **ADOPT** (minor bump; API-compat check first) |
+| `ruvector-gnn` 2.2.0 | panic→`Result` constructors, gradient clipping, MSE/CE/BCE losses, seeded-RNG layer init (#495 is post-2.2.0) | `wifi-densepose-train` GNN path (pinned 2.0.5, `default-features = false`) | 2.2.0 (pinned **2.0.5**) | **ADOPT** (bump) |
+| `ruvector-mincut` / `ruvector-solver` 2.0.6 | Patch-level fixes (workspace republish 2026-03-25) | `metrics.rs` DynamicPersonMatcher, subcarrier interpolation, triangulation | 2.0.6 (pinned **2.0.4** each) | **ADOPT** (routine patch bump) |
+| `ruvector-core` 2.2.3 (vendor) | HNSW correctness: k=0 guard, sorted results, flat-index fixes, cross-integration helpers (#502 — MEASURED, `index/hnsw.rs` + new integration tests) | `homecore-recorder` `RuvectorSemanticIndex` (real HNSW consumer); `sketch.rs` quantization unaffected | **2.2.0 = latest published**; 2.2.3 unpublished | **WATCH** — bump the moment 2.2.3 publishes |
+| `ruvector-cnn` 2.0.6 | Pure-Rust SIMD conv kernels (AVX2/NEON/WASM), MobileNetV3, INT8 quantization, contrastive losses (InfoNCE/triplet, #252) | **Not** the WiFlow-STD training port — `wiflow_std/model.rs` is tch/libtorch (MEASURED). Relevant to the *edge inference* path of the trained ~2.2 MB int8 model, and InfoNCE/triplet overlaps AETHER (ADR-024) | 2.0.6 | **EVALUATE** — only if/when we commit to a no-libtorch edge runtime for WiFlow-STD-class models |
+| `ruvector-acorn` (new-ish) | ACORN predicate-agnostic filtered HNSW (SIGMOD'24 algorithm; γ·M denser graphs for low-selectivity filters) | Metadata-filtered pattern search over ADR-151 calibration banks — speculative; bank sizes are far below where filtered-ANN recall collapse matters | **Not published** | **WATCH** |
+| `ruvector-cluster` 2.0.6 | Distributed sharding, gossip discovery, DAG consensus | No current need; ADR-029 mesh coordination is ESP32-side, not vector-DB-side | 2.0.6 | **WATCH** |
+| ONNX embedder fix (#523/#525) | API-contract + packaging fixes in `npm/packages/ruvector` (TypeScript) | None — `wifi-densepose-nn`'s ONNX backend is Rust (ort/tract), untouched by this change (MEASURED: commit touches npm/ only) | n/a | No action |
+| `ruvector-perception` (new, #547) | "Physical perception substrate" (hypothesis/topology/witness modules) — agent-perception oriented, not RF | None identified | Not published | WATCH (name-overlap only) |
+
+**Security note (RUSTSEC #504).** The substantive fixes target `ruvllm`, `ruvector-dag`, `prime-radiant`, `rvagent-*`, and the `ruvector-server` HTTP endpoint (NaN-safe `partial_cmp`, input-validation guards, env-allowlisted exec) — **none of which we pin**. The commit states `cargo audit` returns clean across the workspace. *Evidence: MEASURED (commit message + file list). Conclusion: no pinned version has an outstanding advisory; no urgent bump required.* The NaN-sort hardening is panic-robustness hygiene our pinned 2.0.4-era crates predate, which is one more reason for the routine bumps below.
+
+**Version-bump recommendations (follow-up PR — no Cargo.toml change in this ADR):** `ruvector-mincut` 2.0.4→2.0.6, `ruvector-solver` 2.0.4→2.0.6, `ruvector-attention` 2.0.4→2.1.0, `ruvector-gnn` 2.0.5→2.2.0. Current: `ruvector-core` 2.2.0, `ruvector-attn-mincut` 2.0.4, `ruvector-temporal-tensor` 2.0.6, `ruvector-crv` 0.1.1 — all at latest published. Nothing in the sync changes §2.1.2 geometry conditioning (our `viewpoint/attention.rs` `GeometricBias` already implements the fusion mechanism) or the ADR-150 MAE recipe (training stays in tch).
+
## 3. Consequences
**Positive:** the calibration system gains the one mechanism (geometry conditioning) the 2026 literature identifies as the difference between layout-brittle and layout-robust supervised WiFi pose; ADR-150 gets a measured training recipe instead of a guessed one; we acquire two external benchmarks (WiFlow-STD, PerceptAlign dataset) to keep our claims honest.
diff --git a/docs/adr/ADR-153-ieee-802-11bf-sensing-protocol-layer.md b/docs/adr/ADR-153-ieee-802-11bf-sensing-protocol-layer.md
new file mode 100644
index 00000000..22a86b98
--- /dev/null
+++ b/docs/adr/ADR-153-ieee-802-11bf-sensing-protocol-layer.md
@@ -0,0 +1,168 @@
+# ADR-153: IEEE 802.11bf-2025 Forward-Compatibility Protocol Model for wifi-densepose-hardware
+
+- **Status**: accepted
+- **Date**: 2026-06-10
+- **Deciders**: ruv
+- **Tags**: hardware, protocol, sensing, 802.11bf, forward-compatibility
+
+## Context
+
+IEEE 802.11bf-2025 (WLAN Sensing) is an **Active Standard**: board approval
+2025-05-28, published 2025-09-26 (verified against the IEEE SA record,
+). Its scope modifies the
+MAC, HE and EHT PHY service interfaces, plus DMG and EDMG PHYs, for WLAN
+sensing in **1–7.125 GHz** and **above 45 GHz** bands, with formal sensing
+measurement setup, measurement instance, feedback/reporting, and
+sensing-by-proxy (SBP) procedures (ADR-152 F4, evidence grade MEASURED).
+
+No commodity silicon implements the standard yet — ESP32 parts included.
+ADR-152 §2.4 therefore decided "track silicon; no code now", with RuView's
+opportunistic CSI extraction remaining the mechanism. That left a gap: when
+silicon does land, RuView would have no typed model of the standard's
+procedures to bind to, and the integration would start from zero.
+
+ADR-152 §2.4 originally classified 802.11bf as a hardware watch item with no
+implementation work until commodity silicon exposes standardized sensing
+measurements. This ADR amends that clause: OTA binding remains deferred, but
+a pure Rust protocol model, session FSM, transport seam, and opportunistic
+CSI bridge will be implemented now so RuView consumers can target a stable
+standardized sensing interface before silicon arrives.
+
+The user directed (2026-06-10) that this **forward-compatibility protocol
+model** — a protocol surface, not a conformance implementation — be built
+now.
+
+## Decision
+
+Implement an `ieee80211bf` **forward-compatibility protocol model** in
+`wifi-densepose-hardware` (pure Rust, no internal deps, simulation-testable,
+no OTA path):
+
+> This module is not a certified 802.11bf implementation. It models the
+> public procedure shape needed by RuView and RuvSense, while intentionally
+> avoiding OTA frame binding until chipset support and vendor APIs exist.
+
+1. **`types.rs`** — typed structures for the standard's sensing procedures
+ (sub-7 GHz focus; DMG stubbed): Sensing Measurement Setup (setup ID,
+ initiator/responder and transmitter/receiver roles, bandwidth,
+ periodicity, threshold-based reporting parameters), Sensing Measurement
+ Instance, Sensing Measurement Report (CSI-variant payload), SBP
+ request/response, termination. Two future-proofing requirements:
+
+ - **Version gates** — every negotiated surface is tagged with a spec
+ profile, because vendors will expose partial or renamed capabilities
+ first:
+
+ ```rust
+ pub enum SpecProfile {
+ DraftCompatible,
+ Ieee80211Bf2025,
+ VendorExtension(String),
+ }
+ ```
+
+ - **Capability negotiation** — no hardcoded ESP32 assumptions in the
+ future-silicon path:
+
+ ```rust
+ pub struct SensingCapabilities {
+ pub sub_7_ghz: bool,
+ pub dmg: bool,
+ pub edmg: bool,
+ pub csi_report: bool,
+ pub threshold_reporting: bool,
+ pub sensing_by_proxy: bool,
+ pub max_bandwidth_mhz: u16,
+ pub max_period_ms: u32,
+ pub max_active_setups: u16,
+ }
+ ```
+
+ - **Privacy and governance fields** — sensing is presence inference, not
+ just radio telemetry. Every `SensingMeasurementSetup` carries policy
+ metadata (required, not optional), for enterprise, elderly-care,
+ retail, workplace, and municipal deployments:
+
+ ```rust
+ pub enum ConsentMode {
+ LabOnly,
+ ExplicitConsent,
+ ManagedEnterprisePolicy,
+ Disabled,
+ }
+ ```
+
+2. **`session.rs`** — deterministic event-driven session state machine:
+ `Idle → SetupNegotiating → Active → Terminating → Idle`, with explicit
+ rejection paths (unsupported parameters, setup-ID collision) and timeout
+ handling.
+3. **`transport.rs`** — a `SensingTransport` trait abstracting frame
+ exchange; a `SimTransport` test double; and an `OpportunisticCsiBridge`
+ adapter mapping today's ESP32 CSI extraction onto the report path
+ (measurement instances ≈ CSI frame batches), so current hardware sits
+ behind the standardized interface. **Replaceability benchmark
+ (acceptance test):** RuvSense must consume either ESP32 opportunistic CSI
+ or future 802.11bf chipset reports through the same `SensingTransport`
+ and `SensingMeasurementReport` path, with no consumer-side rewrite — a
+ future chipset adapter replaces `OpportunisticCsiBridge` without changing
+ consumers.
+
+Constraints: input validation at boundaries (typed errors, no panics on
+adversarial input), files under 500 lines, all protocol tests runnable
+without hardware.
+
+### Acceptance checklist
+
+| Area | Acceptance test |
+| --------------- | -------------------------------------------------------------------- |
+| Types | Serde round trip for setup, instance, report, SBP, termination |
+| FSM | Idle → setup → active → terminating → idle |
+| Rejection | Unsupported bandwidth, invalid period, duplicate setup ID |
+| Timeout | Negotiation timeout returns typed error and resets to Idle |
+| Threshold | Report emitted only when threshold condition is crossed |
+| SBP | Proxy request maps to responder path without direct sensor coupling |
+| Bridge | ESP32 CSI batch becomes standardized measurement report |
+| Safety | No panics on malformed inputs |
+| CI | All protocol tests run without hardware |
+| Maintainability | Each file under 500 lines |
+
+### Non-Goals
+
+This ADR does not claim IEEE 802.11bf conformance, certification, or OTA
+interoperability. It creates a typed protocol compatibility layer so RuView
+can consume standardized sensing reports when commodity silicon exposes
+them. Vendor-specific frame exchange, firmware hooks, trigger-frame
+sounding, and certification test vectors remain future ADRs.
+
+## Consequences
+
+### Positive
+- RuView can adopt standardized WLAN sensing the day any chipset exposes
+ 802.11bf measurements — the data model, session FSM, and transport seam
+ already exist and are tested.
+- The `OpportunisticCsiBridge` gives current ESP32 nodes a standardized-shape
+ interface now, decoupling RuvSense consumers from the extraction mechanism.
+- Simulation transport enables protocol-level tests in CI without hardware.
+- `SpecProfile` + `SensingCapabilities` give a clean escape hatch for the
+ partial/renamed vendor capabilities that will certainly arrive first.
+- Consent/policy metadata is structural from day one, not retrofitted.
+
+### Negative
+- Code written against a standard with zero silicon risks drift: vendor
+ implementations may interpret parameters differently; the layer may need
+ rework at first real binding (drift risk scored 7/10 at acceptance).
+- Adds maintenance surface to wifi-densepose-hardware before any
+ user-visible benefit (maintenance cost scored 3/10 — small without OTA).
+
+### Neutral
+- ADR-152 §2.4's "watch item" remains: revisit when silicon/certification
+ appears (re-check by 2026-12). This ADR changes only the "no code now"
+ clause.
+
+## Links
+
+- ADR-152 — WiFi-Pose SOTA 2026 Intake (F4, §2.4 — amended by this ADR)
+- ADR-028 — ESP32 capability audit (opportunistic CSI extraction baseline)
+- ADR-029 — RuvSense multistatic sensing mode (consumer of sensing reports)
+- IEEE 802.11bf-2025 — Active Standard, board approval 2025-05-28, published
+ 2025-09-26:
diff --git a/v2/Cargo.lock b/v2/Cargo.lock
index e24248fc..034eedc8 100644
--- a/v2/Cargo.lock
+++ b/v2/Cargo.lock
@@ -7328,9 +7328,9 @@ dependencies = [
[[package]]
name = "ruvector-attention"
-version = "2.0.4"
+version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cb4233c1cecd0ea826d95b787065b398489328885042247ff5ffcbb774e864ff"
+checksum = "a92e8e456458188d04aee946579aa7cf96d7b8f276cbf6094532b2c3f6d8cc0b"
dependencies = [
"rand 0.8.5",
"rayon",
@@ -7395,14 +7395,14 @@ dependencies = [
[[package]]
name = "ruvector-gnn"
-version = "2.0.5"
+version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8e17c1cf1ff3380026b299ff3c1ba3a5685c3d8d54700e6ab0b585b6cec21d7b"
+checksum = "a251f9ced8d3231395d922369edc803ef0fc513c7776128f7b4ef21f20dd1f4b"
dependencies = [
"anyhow",
"dashmap",
"libc",
- "ndarray 0.16.1",
+ "ndarray 0.17.2",
"parking_lot",
"rand 0.8.5",
"rand_distr 0.4.3",
@@ -7415,9 +7415,9 @@ dependencies = [
[[package]]
name = "ruvector-mincut"
-version = "2.0.4"
+version = "2.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6d62e10cbb7d80b1e2b72d55c1e3eb7f0c4c5e3f31984bc3baa9b7a02700741e"
+checksum = "d60947433f740d0f589a2911d7b72a02e07a916e7257e478b14386f0ff068fb7"
dependencies = [
"anyhow",
"crossbeam",
@@ -7437,9 +7437,9 @@ dependencies = [
[[package]]
name = "ruvector-solver"
-version = "2.0.4"
+version = "2.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ce69cbde4ee5747281edb1d987a8292940397723924262b6218fc19022cbf687"
+checksum = "9be7c4f61940ae8b451f88b9a629a08ee8ee5c8e6b00ab96ca10ecf59e70f558"
dependencies = [
"dashmap",
"getrandom 0.2.17",
@@ -11040,7 +11040,7 @@ version = "0.3.1"
dependencies = [
"approx",
"criterion",
- "ruvector-attention 2.0.4",
+ "ruvector-attention 2.1.0",
"ruvector-attn-mincut",
"ruvector-core",
"ruvector-crv",
@@ -11098,7 +11098,7 @@ dependencies = [
"num-traits",
"proptest",
"rustfft",
- "ruvector-attention 2.0.4",
+ "ruvector-attention 2.1.0",
"ruvector-attn-mincut",
"ruvector-mincut",
"ruvector-solver",
@@ -11129,7 +11129,7 @@ dependencies = [
"num-traits",
"petgraph",
"proptest",
- "ruvector-attention 2.0.4",
+ "ruvector-attention 2.1.0",
"ruvector-attn-mincut",
"ruvector-mincut",
"ruvector-solver",
diff --git a/v2/Cargo.toml b/v2/Cargo.toml
index 26fdee2c..22dc8a7a 100644
--- a/v2/Cargo.toml
+++ b/v2/Cargo.toml
@@ -187,15 +187,16 @@ midstreamer-temporal-compare = "0.2"
midstreamer-attractor = "0.2"
# ruvector integration (published on crates.io)
-# Vendored at v2.1.0 in vendor/ruvector; using crates.io versions until published.
+# Vendored at origin/main (a083bd77f) in vendor/ruvector; using crates.io versions
+# until published. Bumps per ADR-152 §2.6 (2026-06-10 vendor sync survey).
ruvector-core = "2.2.0"
-ruvector-mincut = "2.0.4"
+ruvector-mincut = "2.0.6"
ruvector-attn-mincut = "2.0.4"
ruvector-temporal-tensor = "2.0.6"
-ruvector-solver = "2.0.4"
-ruvector-attention = "2.0.4"
+ruvector-solver = "2.0.6"
+ruvector-attention = "2.1.0"
ruvector-crv = "0.1.1"
-ruvector-gnn = { version = "2.0.5", default-features = false }
+ruvector-gnn = { version = "2.2.0", default-features = false }
# Internal crates
diff --git a/v2/crates/wifi-densepose-calibration/src/anchor.rs b/v2/crates/wifi-densepose-calibration/src/anchor.rs
index d7e05956..1640fa18 100644
--- a/v2/crates/wifi-densepose-calibration/src/anchor.rs
+++ b/v2/crates/wifi-densepose-calibration/src/anchor.rs
@@ -98,9 +98,7 @@ impl AnchorLabel {
/// Suggested capture duration (seconds).
pub fn duration_s(&self) -> u32 {
match self {
- AnchorLabel::BreatheSlow
- | AnchorLabel::BreatheNormal
- | AnchorLabel::SleepPosture => 30,
+ AnchorLabel::BreatheSlow | AnchorLabel::BreatheNormal | AnchorLabel::SleepPosture => 30,
_ => 20,
}
}
@@ -269,10 +267,7 @@ impl EnrollmentSession {
/// `(accepted, total)` progress.
pub fn progress(&self) -> (usize, usize) {
- (
- self.accepted_anchors().len(),
- AnchorLabel::SEQUENCE.len(),
- )
+ (self.accepted_anchors().len(), AnchorLabel::SEQUENCE.len())
}
/// Whether every anchor in the sequence has been accepted.
diff --git a/v2/crates/wifi-densepose-calibration/src/bank.rs b/v2/crates/wifi-densepose-calibration/src/bank.rs
index b37eeef9..db3900d4 100644
--- a/v2/crates/wifi-densepose-calibration/src/bank.rs
+++ b/v2/crates/wifi-densepose-calibration/src/bank.rs
@@ -90,6 +90,15 @@ impl SpecialistBank {
self
}
+ /// The fixed-length geometry embedding of the bank's snapshot (ADR-152
+ /// §2.1.2) — the conditioning vector the ADR-151 P6 LoRA heads concatenate
+ /// with the backbone embedding. Derived on demand from [`Self::geometry`]
+ /// (it is a pure function of the snapshot), so it adds no schema surface;
+ /// a geometry-free bank yields the well-defined all-zero embedding.
+ pub fn geometry_embedding(&self) -> crate::geometry_embedding::GeometryEmbedding {
+ crate::geometry_embedding::GeometryEmbedding::from_nodes(&self.geometry)
+ }
+
/// `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
@@ -208,6 +217,31 @@ mod tests {
assert_eq!(back.geometry, geometry);
}
+ /// ADR-152 §2.1.2: the embedding is derived from the snapshot — present
+ /// geometry conditions it, absent geometry yields the all-zero vector.
+ #[test]
+ fn geometry_embedding_derives_from_snapshot() {
+ let bare = SpecialistBank::train("r", "base-1", &full_anchors(), 1000).unwrap();
+ assert_eq!(
+ bare.geometry_embedding(),
+ crate::geometry_embedding::GeometryEmbedding::default(),
+ "no geometry → all-zero embedding"
+ );
+
+ let geometry = 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),
+ ];
+ let bank = bare.with_geometry(geometry.clone());
+ let emb = bank.geometry_embedding();
+ assert_eq!(
+ emb,
+ crate::geometry_embedding::GeometryEmbedding::from_nodes(&geometry),
+ "embedding is a pure function of the snapshot"
+ );
+ assert!(emb.as_slice().iter().any(|&x| x != 0.0));
+ }
+
/// 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.
diff --git a/v2/crates/wifi-densepose-calibration/src/enrollment.rs b/v2/crates/wifi-densepose-calibration/src/enrollment.rs
index 813762d4..4163df6b 100644
--- a/v2/crates/wifi-densepose-calibration/src/enrollment.rs
+++ b/v2/crates/wifi-densepose-calibration/src/enrollment.rs
@@ -203,13 +203,13 @@ impl AnchorRecorder {
/// Evaluate the capture against the gate and produce an `Anchor` (accepted
/// or not) plus a rejection reason.
- pub fn finalize(
- &self,
- gate: &AnchorQualityGate,
- at_unix_s: i64,
- ) -> (Anchor, Option) {
- let (quality, reason) =
- gate.evaluate(self.label, self.presence_z(), self.motion_rate(), self.frames);
+ pub fn finalize(&self, gate: &AnchorQualityGate, at_unix_s: i64) -> (Anchor, Option) {
+ let (quality, reason) = gate.evaluate(
+ self.label,
+ self.presence_z(),
+ self.motion_rate(),
+ self.frames,
+ );
(
Anchor {
label: self.label,
@@ -255,7 +255,13 @@ mod tests {
/// Alternating z (every frame's |Δz| exceeds Z_DELTA_MOTION ⇒ all motion).
fn run_jittery(label: AnchorLabel, z: f32, n: usize) -> (Anchor, Option) {
let zs: Vec = (0..n)
- .map(|i| if i % 2 == 0 { z } else { z + 2.0 * Z_DELTA_MOTION })
+ .map(|i| {
+ if i % 2 == 0 {
+ z
+ } else {
+ z + 2.0 * Z_DELTA_MOTION
+ }
+ })
.collect();
run_series(label, &zs)
}
@@ -268,7 +274,10 @@ mod tests {
let (a, reason) = run_still(AnchorLabel::StandStill, 3.0, 400);
assert!(a.quality.accepted, "z-band squeeze is back: {reason:?}");
assert!(reason.is_none());
- assert!(a.quality.motion_rate < 0.05, "flat z-series must read still");
+ assert!(
+ a.quality.motion_rate < 0.05,
+ "flat z-series must read still"
+ );
}
#[test]
@@ -301,7 +310,11 @@ mod tests {
let mut r = AnchorRecorder::new(AnchorLabel::LieDown);
for i in 0..400 {
let mut s = score(1.8);
- s.phase_drift_median = if i % 2 == 0 { 0.0 } else { PHASE_DELTA_MOTION * 1.5 };
+ s.phase_drift_median = if i % 2 == 0 {
+ 0.0
+ } else {
+ PHASE_DELTA_MOTION * 1.5
+ };
r.record_score(&s);
}
let (a, reason) = r.finalize(&AnchorQualityGate::default(), 100);
diff --git a/v2/crates/wifi-densepose-calibration/src/extract.rs b/v2/crates/wifi-densepose-calibration/src/extract.rs
index 9b6d5d70..2c458c5e 100644
--- a/v2/crates/wifi-densepose-calibration/src/extract.rs
+++ b/v2/crates/wifi-densepose-calibration/src/extract.rs
@@ -58,7 +58,13 @@ impl Features {
} else {
0.0
};
- [self.mean, self.variance, self.motion, breathing_hz, heart_hz]
+ [
+ self.mean,
+ self.variance,
+ self.motion,
+ breathing_hz,
+ heart_hz,
+ ]
}
/// Squared Euclidean distance between two embeddings.
@@ -85,8 +91,7 @@ impl Features {
};
}
let mean = series.iter().copied().sum::() / n as f32;
- let variance =
- series.iter().map(|v| (v - mean) * (v - mean)).sum::() / n as f32;
+ let variance = series.iter().map(|v| (v - mean) * (v - mean)).sum::() / n as f32;
let motion = if n > 1 {
series.windows(2).map(|w| (w[1] - w[0]).abs()).sum::() / (n - 1) as f32
} else {
@@ -234,8 +239,12 @@ mod tests {
#[test]
fn motion_distinguishes_still_from_noisy() {
let still = vec![1.0f32; 200];
- let noisy: Vec = (0..200).map(|i| if i % 2 == 0 { 0.0 } else { 5.0 }).collect();
- assert!(Features::from_series(&still, 15.0).motion < Features::from_series(&noisy, 15.0).motion);
+ let noisy: Vec = (0..200)
+ .map(|i| if i % 2 == 0 { 0.0 } else { 5.0 })
+ .collect();
+ assert!(
+ Features::from_series(&still, 15.0).motion < Features::from_series(&noisy, 15.0).motion
+ );
}
#[test]
diff --git a/v2/crates/wifi-densepose-calibration/src/geometry_embedding.rs b/v2/crates/wifi-densepose-calibration/src/geometry_embedding.rs
new file mode 100644
index 00000000..e8226246
--- /dev/null
+++ b/v2/crates/wifi-densepose-calibration/src/geometry_embedding.rs
@@ -0,0 +1,499 @@
+//! 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