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> = sorted.iter().map(|g| valid_position(g)).collect(); + let orientations: Vec> = + 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 = 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::() / of; + let s = known_orient.iter().map(|(az, _)| az.sin()).sum::() / 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::() / 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 = 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 { + 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 = (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); + } +} diff --git a/v2/crates/wifi-densepose-calibration/src/lib.rs b/v2/crates/wifi-densepose-calibration/src/lib.rs index 8de1401f..db407cf7 100644 --- a/v2/crates/wifi-densepose-calibration/src/lib.rs +++ b/v2/crates/wifi-densepose-calibration/src/lib.rs @@ -9,7 +9,8 @@ //! 1. **baseline** — empty-room environmental fingerprint (ADR-135; consumed here). //! 2. **enroll** — guided anchors with an adaptive quality gate ([`anchor`], //! [`enrollment`]) plus an optional transceiver-geometry record ([`geometry`], -//! ADR-152 §2.1.1). +//! ADR-152 §2.1.1) and its fixed-length conditioning featurization +//! ([`geometry_embedding`], ADR-152 §2.1.2). //! 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`]). @@ -21,14 +22,15 @@ #![warn(missing_docs)] pub mod anchor; +pub mod bank; pub mod enrollment; pub mod error; pub mod extract; pub mod geometry; -pub mod specialist; -pub mod bank; -pub mod runtime; +pub mod geometry_embedding; pub mod multistatic; +pub mod runtime; +pub mod specialist; pub use anchor::{Anchor, AnchorLabel, AnchorQuality, EnrollmentEvent, EnrollmentSession, Posture}; pub use bank::SpecialistBank; @@ -36,6 +38,7 @@ pub use enrollment::{AnchorQualityGate, AnchorRecorder}; pub use error::{CalibrationError, Result}; pub use extract::AnchorFeature; pub use geometry::{AntennaOrientation, NodeGeometry, PositionEstimate}; +pub use geometry_embedding::GeometryEmbedding; pub use multistatic::MultiNodeMixture; pub use runtime::{MixtureOfSpecialists, RoomState}; pub use specialist::{Specialist, SpecialistKind, SpecialistReading}; diff --git a/v2/crates/wifi-densepose-calibration/src/multistatic.rs b/v2/crates/wifi-densepose-calibration/src/multistatic.rs index 61d3ffb2..7fbcb422 100644 --- a/v2/crates/wifi-densepose-calibration/src/multistatic.rs +++ b/v2/crates/wifi-densepose-calibration/src/multistatic.rs @@ -46,7 +46,12 @@ impl MultiNodeMixture { /// Register a node's bank. `current_baseline_id` is the baseline the node is /// observing now (drift vs the bank's training baseline → STALE). - pub fn add_node(&mut self, node_id: u8, bank: SpecialistBank, current_baseline_id: impl Into) { + pub fn add_node( + &mut self, + node_id: u8, + bank: SpecialistBank, + current_baseline_id: impl Into, + ) { self.nodes.insert( node_id, NodeEntry { @@ -130,15 +135,13 @@ impl MultiNodeMixture { /// Presence: a person is present if ANY node sees one; confidence = max. fn fuse_presence(states: &[RoomState]) -> Option { - let readings: Vec<&SpecialistReading> = states.iter().filter_map(|s| s.presence.as_ref()).collect(); + let readings: Vec<&SpecialistReading> = + states.iter().filter_map(|s| s.presence.as_ref()).collect(); if readings.is_empty() { return None; } let any_present = readings.iter().any(|r| r.value > 0.5); - let confidence = readings - .iter() - .map(|r| r.confidence) - .fold(0.0f32, f32::max); + let confidence = readings.iter().map(|r| r.confidence).fold(0.0f32, f32::max); Some(SpecialistReading { kind: readings[0].kind, value: if any_present { 1.0 } else { 0.0 }, diff --git a/v2/crates/wifi-densepose-calibration/src/specialist.rs b/v2/crates/wifi-densepose-calibration/src/specialist.rs index 686bf324..19190203 100644 --- a/v2/crates/wifi-densepose-calibration/src/specialist.rs +++ b/v2/crates/wifi-densepose-calibration/src/specialist.rs @@ -123,9 +123,7 @@ impl Specialist for PresenceSpecialist { fn infer(&self, f: &Features) -> Option { let by_variance = f.variance > self.threshold; let mean_dist = (f.mean - self.empty_mean).abs(); - let by_mean = self - .mean_dist_threshold - .is_some_and(|thr| mean_dist > thr); + let by_mean = self.mean_dist_threshold.is_some_and(|thr| mean_dist > thr); let present = by_variance || by_mean; // Confidence: strongest margin among the channels that are enabled. @@ -228,7 +226,11 @@ impl Specialist for BreathingSpecialist { SpecialistKind::Breathing } fn infer(&self, f: &Features) -> Option { - let min = if self.min_score > 0.0 { self.min_score } else { 0.25 }; + let min = if self.min_score > 0.0 { + self.min_score + } else { + 0.25 + }; if f.breathing_score < min || f.breathing_hz <= 0.0 { return None; } @@ -253,7 +255,11 @@ impl Specialist for HeartbeatSpecialist { SpecialistKind::Heartbeat } fn infer(&self, f: &Features) -> Option { - let min = if self.min_score > 0.0 { self.min_score } else { 0.3 }; + let min = if self.min_score > 0.0 { + self.min_score + } else { + 0.3 + }; if f.heart_score < min || f.heart_hz <= 0.0 { return None; } diff --git a/v2/crates/wifi-densepose-hardware/src/ieee80211bf/messages.rs b/v2/crates/wifi-densepose-hardware/src/ieee80211bf/messages.rs new file mode 100644 index 00000000..4b4cab98 --- /dev/null +++ b/v2/crates/wifi-densepose-hardware/src/ieee80211bf/messages.rs @@ -0,0 +1,174 @@ +//! Procedure message types for the 802.11bf sensing model: measurement +//! setup request/response, measurement instance, CSI-variant measurement +//! report, sensing-by-proxy (SBP) exchange, session termination, and the +//! minimal DMG (>45 GHz) stubs. Negotiation-core types (identifiers, +//! parameters, capabilities, statuses) live in [`super::types`]. + +use serde::{Deserialize, Serialize}; + +use super::types::{ + BfError, MeasurementInstanceId, MeasurementSetupId, MeasurementSetupParams, SetupStatus, + SpecProfile, MAX_REPORT_SUBCARRIERS, +}; + +/// Sensing measurement setup request (initiator → responder). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SensingMeasurementSetupRequest { + /// Version gate for the negotiated surface. + pub profile: SpecProfile, + pub setup_id: MeasurementSetupId, + pub params: MeasurementSetupParams, +} + +impl SensingMeasurementSetupRequest { + pub fn validate(&self) -> Result<(), BfError> { + self.params.validate() + } +} + +/// Sensing measurement setup response (responder → initiator). +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +pub struct SensingMeasurementSetupResponse { + pub setup_id: MeasurementSetupId, + pub status: SetupStatus, +} + +/// One scheduled sensing measurement instance within an active setup. +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +pub struct SensingMeasurementInstance { + pub setup_id: MeasurementSetupId, + pub instance_id: MeasurementInstanceId, + /// Deterministic schedule offset of this instance (µs since setup + /// activation; synthesized from the negotiated periodicity). + pub timestamp_us: u64, +} + +/// CSI-variant sensing measurement report payload (amplitude/phase per +/// usable subcarrier, averaged over the measurement instance). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct CsiReportPayload { + pub n_subcarriers: u16, + pub amplitudes: Vec, + pub phases: Vec, +} + +impl CsiReportPayload { + /// Boundary validation: shape coherence and value sanity. Rejects NaN, + /// infinities, and negative amplitudes from adversarial peers. + pub fn validate(&self) -> Result<(), BfError> { + if self.n_subcarriers == 0 { + return Err(BfError::EmptyPayload); + } + if self.n_subcarriers > MAX_REPORT_SUBCARRIERS { + return Err(BfError::PayloadTooLarge { + count: self.n_subcarriers, + }); + } + let declared = self.n_subcarriers as usize; + if self.amplitudes.len() != declared || self.phases.len() != declared { + return Err(BfError::PayloadLengthMismatch { + declared, + amplitudes: self.amplitudes.len(), + phases: self.phases.len(), + }); + } + for (index, a) in self.amplitudes.iter().enumerate() { + if !a.is_finite() || *a < 0.0 { + return Err(BfError::PayloadValueInvalid { index }); + } + } + for (index, p) in self.phases.iter().enumerate() { + if !p.is_finite() { + return Err(BfError::PayloadValueInvalid { index }); + } + } + Ok(()) + } + + /// Mean amplitude across subcarriers (threshold-trigger metric). + pub fn mean_amplitude(&self) -> f64 { + if self.amplitudes.is_empty() { + return 0.0; + } + self.amplitudes.iter().map(|a| *a as f64).sum::() / self.amplitudes.len() as f64 + } +} + +/// Sensing measurement report (sensing receiver → initiator). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SensingMeasurementReport { + pub setup_id: MeasurementSetupId, + pub instance_id: MeasurementInstanceId, + pub payload: CsiReportPayload, +} + +impl SensingMeasurementReport { + pub fn validate(&self) -> Result<(), BfError> { + self.payload.validate() + } +} + +/// Sensing-by-Proxy (SBP) request: a non-AP STA asks an AP to act as sensing +/// initiator on its behalf and forward the resulting reports. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SbpRequest { + pub profile: SpecProfile, + /// Setup ID the proxy uses for the sensing it conducts on our behalf. + pub proxy_setup_id: MeasurementSetupId, + pub params: MeasurementSetupParams, +} + +impl SbpRequest { + pub fn validate(&self) -> Result<(), BfError> { + self.params.validate() + } +} + +/// Status carried by an SBP response. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum SbpStatus { + Accepted, + RejectedNotSupported, + RejectedUnsupportedParams, + RejectedByPolicy, +} + +/// Sensing-by-Proxy (SBP) response (proxy AP → requesting STA). +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +pub struct SbpResponse { + pub proxy_setup_id: MeasurementSetupId, + pub status: SbpStatus, +} + +/// Reason carried by a sensing session termination. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum TerminationReason { + InitiatorRequested, + ResponderRequested, + Timeout, + PolicyChange, +} + +/// Sensing measurement setup termination (either side may send). +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +pub struct SensingSessionTermination { + pub setup_id: MeasurementSetupId, + pub reason: TerminationReason, +} + +/// Minimal stub for DMG/EDMG (>45 GHz) sensing types. The standard also +/// covers directional multi-gigabit sensing; this model does not elaborate +/// it beyond a typed placeholder (ADR-153 scope: sub-7 GHz focus). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum DmgSensingType { + Monostatic, + Bistatic, + Multistatic, +} + +/// Placeholder for a future DMG sensing setup surface. +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +pub struct DmgSensingSetupStub { + pub setup_id: MeasurementSetupId, + pub sensing_type: DmgSensingType, +} diff --git a/v2/crates/wifi-densepose-hardware/src/ieee80211bf/mod.rs b/v2/crates/wifi-densepose-hardware/src/ieee80211bf/mod.rs new file mode 100644 index 00000000..9845dee4 --- /dev/null +++ b/v2/crates/wifi-densepose-hardware/src/ieee80211bf/mod.rs @@ -0,0 +1,71 @@ +//! IEEE 802.11bf-2025 WLAN sensing — forward-compatibility protocol model +//! (ADR-153, amending ADR-152 §2.4). +//! +//! # Why this exists +//! +//! IEEE 802.11bf-2025 ("WLAN Sensing") was **published 2025-09-26** (verified +//! against the IEEE SA record — ADR-152 §1.1 F4, evidence grade MEASURED). +//! Sensing standardization is complete for sub-7 GHz and >45 GHz (DMG) bands, +//! with formal sensing measurement setup, measurement instance, +//! feedback/reporting, and sensing-by-proxy (SBP) procedures. +//! +//! **No commodity silicon — ESP32 parts included — implements the standard +//! yet.** ADR-152 §2.4 originally decided "track silicon; no code now"; +//! ADR-153 amends that clause: build the typed protocol surface now, so +//! RuView can adopt standardized sensing the day any chipset exposes it. +//! This layer is simulation-tested forward compatibility — the OTA binding +//! lands when silicon does. Today's opportunistic CSI extraction (ADR-018 / +//! ADR-028) remains the backend, mapped onto the standardized report path by +//! [`transport::OpportunisticCsiBridge`]. +//! +//! > 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. +//! +//! # Layout +//! +//! - [`types`] — typed structures for the sensing procedures (setup, roles, +//! measurement instances, CSI-variant reports, SBP, termination), plus the +//! ADR-153 future-proofing surfaces: [`types::SpecProfile`] version gates, +//! [`types::SensingCapabilities`] negotiation, and required +//! [`types::ConsentMode`] governance metadata on every setup. +//! - [`messages`] — the procedure message types (setup request/response, +//! measurement instance, CSI-variant report, SBP exchange, termination). +//! - [`session`] — deterministic event-driven session FSM: +//! `Idle → SetupNegotiating → Active → Terminating → Idle`, with explicit +//! rejection paths and timeout handling. No async, no clocks. +//! - [`table`] — responder-side setup registry (setup-ID collision and +//! capacity rejection paths). +//! - [`transport`] — the [`transport::SensingTransport`] seam, the +//! [`transport::SimTransport`] test double, and the ESP32 bridge. + +pub mod messages; +pub mod session; +pub mod table; +pub mod transport; +pub mod types; + +pub use messages::{ + CsiReportPayload, DmgSensingSetupStub, DmgSensingType, SbpRequest, SbpResponse, SbpStatus, + SensingMeasurementInstance, SensingMeasurementReport, SensingMeasurementSetupRequest, + SensingMeasurementSetupResponse, SensingSessionTermination, TerminationReason, +}; +pub use session::{Action, CloseReason, SensingSession, SessionConfig, SessionEvent, SessionState}; +pub use table::SessionTable; +pub use transport::{ + action_to_frame, frame_to_event, OpportunisticCsiBridge, SensingFrame, SensingTransport, + SimTransport, TransportError, +}; +pub use types::{ + bandwidth_mhz, BfError, ConsentMode, MeasurementInstanceId, MeasurementSetupId, + MeasurementSetupParams, ReportingConfig, SensingCapabilities, SensingRole, SetupStatus, + SpecProfile, ThresholdParams, TransceiverRole, MAX_BURST_INSTANCES, MAX_PERIOD_MS, + MAX_REPORT_SUBCARRIERS, MAX_SETUP_ID, MIN_PERIOD_MS, +}; + +#[cfg(test)] +mod tests; +#[cfg(test)] +mod tests_fsm; +#[cfg(test)] +mod testutil; diff --git a/v2/crates/wifi-densepose-hardware/src/ieee80211bf/session.rs b/v2/crates/wifi-densepose-hardware/src/ieee80211bf/session.rs new file mode 100644 index 00000000..4b6f92a7 --- /dev/null +++ b/v2/crates/wifi-densepose-hardware/src/ieee80211bf/session.rs @@ -0,0 +1,497 @@ +//! Sensing session state machine for the 802.11bf forward-compatibility model. +//! +//! Deterministic, event-driven, no async, no clocks: callers inject +//! [`SessionEvent`]s (including `Timeout` ticks) and act on the returned +//! [`Action`]s. State flow (ADR-153): +//! +//! ```text +//! Idle → SetupNegotiating → Active → Terminating → Idle +//! ``` +//! +//! Rejection paths: unsupported parameters / incompatible profile / policy +//! (responder responds with a rejected setup status), setup-ID collision +//! ([`super::table::SessionTable`]), and negotiation timeout (typed +//! [`BfError::NegotiationTimeout`] + reset to Idle). + +use super::messages::{ + CsiReportPayload, SbpRequest, SbpResponse, SbpStatus, SensingMeasurementInstance, + SensingMeasurementReport, SensingMeasurementSetupRequest, SensingMeasurementSetupResponse, + SensingSessionTermination, TerminationReason, +}; +use super::types::{ + BfError, MeasurementInstanceId, MeasurementSetupId, MeasurementSetupParams, ReportingConfig, + SensingCapabilities, SensingRole, SetupStatus, SpecProfile, +}; + +/// Session FSM states. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SessionState { + Idle, + SetupNegotiating, + Active, + Terminating, +} + +/// Inputs to the session FSM. `Start*` are local commands; `*Received` are +/// frames from the peer; `Timeout`/`InstanceElapsed` are scheduler ticks. +#[derive(Debug, Clone, PartialEq)] +pub enum SessionEvent { + /// Local command (initiator): begin setup negotiation. + StartSetup(SensingMeasurementSetupRequest), + /// Local command (initiator): request sensing-by-proxy from an AP. + StartSbp(SbpRequest), + SetupRequestReceived(SensingMeasurementSetupRequest), + SetupResponseReceived(SensingMeasurementSetupResponse), + SbpRequestReceived(SbpRequest), + SbpResponseReceived(SbpResponse), + /// Scheduler tick: the negotiated periodicity elapsed (initiator emits + /// the next measurement-instance trigger). + InstanceElapsed, + /// A sensing receiver captured a measurement for an instance (payload is + /// fed by the transport/bridge — see `OpportunisticCsiBridge`). + MeasurementCaptured { + instance_id: MeasurementInstanceId, + payload: CsiReportPayload, + }, + ReportReceived(SensingMeasurementReport), + /// Generic timeout tick for the current state. + Timeout, + /// Local command: terminate the session. + Terminate(TerminationReason), + TerminationReceived(SensingSessionTermination), +} + +/// Outputs of the session FSM. `Send*`/`TriggerInstance` go to the transport; +/// `DeliverReport`/`SessionClosed` go to the local consumer. +#[derive(Debug, Clone, PartialEq)] +pub enum Action { + SendSetupRequest(SensingMeasurementSetupRequest), + SendSetupResponse(SensingMeasurementSetupResponse), + SendSbpRequest(SbpRequest), + SendSbpResponse(SbpResponse), + TriggerInstance(SensingMeasurementInstance), + SendReport(SensingMeasurementReport), + DeliverReport(SensingMeasurementReport), + SendTermination(SensingSessionTermination), + SessionClosed(CloseReason), +} + +/// Why a session returned to Idle. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum CloseReason { + SetupRejected(SetupStatus), + SbpRejected(SbpStatus), + Terminated(TerminationReason), + /// Terminating-state quiescence completed (no peer echo required). + Completed, +} + +/// Static configuration for a sensing session. +#[derive(Debug, Clone, PartialEq)] +pub struct SessionConfig { + /// Spec profile this endpoint advertises/accepts. + pub profile: SpecProfile, + /// Capability set used to evaluate inbound setups. + pub capabilities: SensingCapabilities, + /// Consecutive negotiation timeouts before aborting to Idle. + pub max_setup_timeouts: u8, + /// Consecutive missed instances (Active timeouts) before terminating. + pub max_missed_instances: u8, +} + +impl Default for SessionConfig { + fn default() -> Self { + Self { + profile: SpecProfile::Ieee80211Bf2025, + capabilities: SensingCapabilities::sim_full(), + max_setup_timeouts: 3, + max_missed_instances: 5, + } + } +} + +/// One sensing session (one measurement setup) on one endpoint. +#[derive(Debug, Clone)] +pub struct SensingSession { + role: SensingRole, + state: SessionState, + config: SessionConfig, + /// Last setup request we sent (for negotiation re-sends). + pending_request: Option, + /// Negotiated (or in-negotiation) setup. + setup: Option<(MeasurementSetupId, MeasurementSetupParams)>, + /// True when this session awaits proxied sensing (SBP client). + sbp_client: bool, + setup_timeouts: u8, + missed_instances: u8, + instance_counter: u32, + /// Mean amplitude of the last *reported* measurement (threshold trigger). + last_reported_mean: Option, +} + +impl SensingSession { + pub fn new_initiator(config: SessionConfig) -> Self { + Self::new(SensingRole::Initiator, config) + } + + pub fn new_responder(config: SessionConfig) -> Self { + Self::new(SensingRole::Responder, config) + } + + fn new(role: SensingRole, config: SessionConfig) -> Self { + Self { + role, + state: SessionState::Idle, + config, + pending_request: None, + setup: None, + sbp_client: false, + setup_timeouts: 0, + missed_instances: 0, + instance_counter: 0, + last_reported_mean: None, + } + } + + pub fn state(&self) -> SessionState { + self.state + } + + pub fn role(&self) -> SensingRole { + self.role + } + + pub fn setup_id(&self) -> Option { + self.setup.as_ref().map(|(id, _)| *id) + } + + /// Drive the FSM with one event. Protocol-level rejections surface as + /// `Ok` actions (responses to the peer); malformed/adversarial input and + /// negotiation timeout surface as typed `Err` (never a panic). + pub fn handle(&mut self, event: SessionEvent) -> Result, BfError> { + match self.state { + SessionState::Idle => self.handle_idle(event), + SessionState::SetupNegotiating => self.handle_negotiating(event), + SessionState::Active => self.handle_active(event), + SessionState::Terminating => self.handle_terminating(event), + } + } + + fn handle_idle(&mut self, event: SessionEvent) -> Result, BfError> { + match event { + SessionEvent::StartSetup(req) => { + if self.role != SensingRole::Initiator { + return Err(BfError::InvalidStateForCommand { + state: "Idle (responder cannot StartSetup)", + }); + } + req.validate()?; + self.setup = Some((req.setup_id, req.params.clone())); + self.pending_request = Some(req.clone()); + self.setup_timeouts = 0; + self.state = SessionState::SetupNegotiating; + Ok(vec![Action::SendSetupRequest(req)]) + } + SessionEvent::StartSbp(sbp) => { + if self.role != SensingRole::Initiator { + return Err(BfError::InvalidStateForCommand { + state: "Idle (responder cannot StartSbp)", + }); + } + sbp.validate()?; + self.setup = Some((sbp.proxy_setup_id, sbp.params.clone())); + self.sbp_client = true; + self.setup_timeouts = 0; + self.state = SessionState::SetupNegotiating; + Ok(vec![Action::SendSbpRequest(sbp)]) + } + SessionEvent::SetupRequestReceived(req) => { + let response = |status| { + Action::SendSetupResponse(SensingMeasurementSetupResponse { + setup_id: req.setup_id, + status, + }) + }; + match self.evaluate_setup(&req) { + SetupStatus::Accepted => { + self.setup = Some((req.setup_id, req.params.clone())); + self.missed_instances = 0; + self.last_reported_mean = None; + self.state = SessionState::Active; + Ok(vec![response(SetupStatus::Accepted)]) + } + status => Ok(vec![response(status)]), + } + } + SessionEvent::SbpRequestReceived(sbp) => Ok(self.handle_sbp_request(sbp)), + // Stray frames/ticks in Idle are ignored, not errors. + _ => Ok(vec![]), + } + } + + /// SBP proxy path: accept the request, then run the *standard initiator + /// path* toward the actual sensing responder. No direct sensor coupling — + /// the proxied setup is an ordinary `SendSetupRequest` on the transport. + fn handle_sbp_request(&mut self, sbp: SbpRequest) -> Vec { + let respond = |status| { + Action::SendSbpResponse(SbpResponse { + proxy_setup_id: sbp.proxy_setup_id, + status, + }) + }; + if !self.config.capabilities.sensing_by_proxy { + return vec![respond(SbpStatus::RejectedNotSupported)]; + } + if !self.config.profile.accepts(&sbp.profile) { + return vec![respond(SbpStatus::RejectedUnsupportedParams)]; + } + match sbp.validate() { + Err(BfError::SensingDisabledByPolicy) => { + return vec![respond(SbpStatus::RejectedByPolicy)]; + } + Err(_) => return vec![respond(SbpStatus::RejectedUnsupportedParams)], + Ok(()) => {} + } + if self.config.capabilities.evaluate(&sbp.params).is_err() { + return vec![respond(SbpStatus::RejectedUnsupportedParams)]; + } + let req = SensingMeasurementSetupRequest { + profile: sbp.profile.clone(), + setup_id: sbp.proxy_setup_id, + params: sbp.params.clone(), + }; + self.setup = Some((req.setup_id, req.params.clone())); + self.pending_request = Some(req.clone()); + self.setup_timeouts = 0; + self.state = SessionState::SetupNegotiating; + vec![respond(SbpStatus::Accepted), Action::SendSetupRequest(req)] + } + + fn evaluate_setup(&self, req: &SensingMeasurementSetupRequest) -> SetupStatus { + if !self.config.profile.accepts(&req.profile) { + return SetupStatus::RejectedIncompatibleProfile; + } + match req.validate() { + Err(BfError::SensingDisabledByPolicy) => return SetupStatus::RejectedByPolicy, + Err(_) => return SetupStatus::RejectedUnsupportedParams, + Ok(()) => {} + } + match self.config.capabilities.evaluate(&req.params) { + Err(status) => status, + Ok(()) => SetupStatus::Accepted, + } + } + + fn handle_negotiating(&mut self, event: SessionEvent) -> Result, BfError> { + match event { + SessionEvent::SetupResponseReceived(resp) => { + let expected = match self.setup_id() { + Some(id) => id, + None => return Ok(vec![]), + }; + if resp.setup_id != expected { + return Err(BfError::SetupIdMismatch { + expected: expected.value(), + got: resp.setup_id.value(), + }); + } + match resp.status { + SetupStatus::Accepted => { + self.setup_timeouts = 0; + self.missed_instances = 0; + self.state = SessionState::Active; + match self.next_instance_record() { + Some(instance) => Ok(vec![Action::TriggerInstance(instance)]), + None => Ok(vec![]), + } + } + status => { + self.reset(); + Ok(vec![Action::SessionClosed(CloseReason::SetupRejected( + status, + ))]) + } + } + } + SessionEvent::SbpResponseReceived(resp) if self.sbp_client => { + let expected = match self.setup_id() { + Some(id) => id, + None => return Ok(vec![]), + }; + if resp.proxy_setup_id != expected { + return Err(BfError::SetupIdMismatch { + expected: expected.value(), + got: resp.proxy_setup_id.value(), + }); + } + match resp.status { + SbpStatus::Accepted => { + // Proxied reports will arrive via ReportReceived. + self.setup_timeouts = 0; + self.state = SessionState::Active; + Ok(vec![]) + } + status => { + self.reset(); + Ok(vec![Action::SessionClosed(CloseReason::SbpRejected( + status, + ))]) + } + } + } + SessionEvent::Timeout => { + self.setup_timeouts = self.setup_timeouts.saturating_add(1); + if self.setup_timeouts >= self.config.max_setup_timeouts { + let setup_id = self.setup_id().map(|id| id.value()).unwrap_or(0); + let attempts = self.setup_timeouts; + self.reset(); + Err(BfError::NegotiationTimeout { setup_id, attempts }) + } else if let Some(req) = &self.pending_request { + Ok(vec![Action::SendSetupRequest(req.clone())]) + } else { + Ok(vec![]) + } + } + SessionEvent::Terminate(reason) => { + self.reset(); + Ok(vec![Action::SessionClosed(CloseReason::Terminated(reason))]) + } + SessionEvent::TerminationReceived(term) => { + self.reset(); + Ok(vec![Action::SessionClosed(CloseReason::Terminated( + term.reason, + ))]) + } + _ => Ok(vec![]), + } + } + + fn handle_active(&mut self, event: SessionEvent) -> Result, BfError> { + match event { + SessionEvent::InstanceElapsed => { + if self.role == SensingRole::Initiator && !self.sbp_client { + match self.next_instance_record() { + Some(instance) => Ok(vec![Action::TriggerInstance(instance)]), + None => Ok(vec![]), + } + } else { + Ok(vec![]) + } + } + SessionEvent::MeasurementCaptured { + instance_id, + payload, + } => { + payload.validate()?; + let (setup_id, params) = match &self.setup { + Some((id, p)) => (*id, p.clone()), + None => return Ok(vec![]), + }; + let mean = payload.mean_amplitude(); + let should_report = match params.reporting { + ReportingConfig::EveryInstance => true, + ReportingConfig::ThresholdBased(threshold) => match self.last_reported_mean { + None => true, + Some(previous) => threshold.exceeds(previous, mean), + }, + }; + if !should_report { + return Ok(vec![]); + } + self.last_reported_mean = Some(mean); + Ok(vec![Action::SendReport(SensingMeasurementReport { + setup_id, + instance_id, + payload, + })]) + } + SessionEvent::ReportReceived(report) => { + report.validate()?; + let expected = match self.setup_id() { + Some(id) => id, + None => return Ok(vec![]), + }; + if report.setup_id != expected { + return Err(BfError::SetupIdMismatch { + expected: expected.value(), + got: report.setup_id.value(), + }); + } + self.missed_instances = 0; + Ok(vec![Action::DeliverReport(report)]) + } + SessionEvent::Timeout => { + self.missed_instances = self.missed_instances.saturating_add(1); + if self.missed_instances >= self.config.max_missed_instances { + self.state = SessionState::Terminating; + Ok(self.termination_actions(TerminationReason::Timeout)) + } else { + Ok(vec![]) + } + } + SessionEvent::Terminate(reason) => { + self.state = SessionState::Terminating; + Ok(self.termination_actions(reason)) + } + SessionEvent::TerminationReceived(term) => { + self.reset(); + Ok(vec![Action::SessionClosed(CloseReason::Terminated( + term.reason, + ))]) + } + _ => Ok(vec![]), + } + } + + fn handle_terminating(&mut self, event: SessionEvent) -> Result, BfError> { + match event { + SessionEvent::TerminationReceived(term) => { + self.reset(); + Ok(vec![Action::SessionClosed(CloseReason::Terminated( + term.reason, + ))]) + } + // No peer echo is required: a quiescence tick completes teardown. + SessionEvent::Timeout => { + self.reset(); + Ok(vec![Action::SessionClosed(CloseReason::Completed)]) + } + _ => Ok(vec![]), + } + } + + fn termination_actions(&self, reason: TerminationReason) -> Vec { + match self.setup_id() { + Some(setup_id) => vec![Action::SendTermination(SensingSessionTermination { + setup_id, + reason, + })], + None => vec![], + } + } + + fn next_instance_record(&mut self) -> Option { + let (setup_id, params) = match &self.setup { + Some((id, p)) => (*id, p.clone()), + None => return None, + }; + let n = self.instance_counter; + self.instance_counter = self.instance_counter.wrapping_add(1); + Some(SensingMeasurementInstance { + setup_id, + instance_id: MeasurementInstanceId::new((n % 256) as u8), + timestamp_us: u64::from(n) * u64::from(params.period_ms) * 1_000, + }) + } + + fn reset(&mut self) { + self.state = SessionState::Idle; + self.pending_request = None; + self.setup = None; + self.sbp_client = false; + self.setup_timeouts = 0; + self.missed_instances = 0; + self.instance_counter = 0; + self.last_reported_mean = None; + } +} diff --git a/v2/crates/wifi-densepose-hardware/src/ieee80211bf/table.rs b/v2/crates/wifi-densepose-hardware/src/ieee80211bf/table.rs new file mode 100644 index 00000000..8b9967c4 --- /dev/null +++ b/v2/crates/wifi-densepose-hardware/src/ieee80211bf/table.rs @@ -0,0 +1,79 @@ +//! Responder-side setup registry for the 802.11bf sensing model — enforces +//! the setup-ID-collision and capacity rejection paths a single session +//! cannot see on its own (ADR-153 acceptance: duplicate setup ID rejected). + +use std::collections::BTreeMap; + +use super::messages::{SensingMeasurementSetupRequest, SensingMeasurementSetupResponse}; +use super::session::{Action, SensingSession, SessionConfig, SessionEvent, SessionState}; +use super::types::{BfError, MeasurementSetupId, SetupStatus}; + +/// Responder-side registry of sensing sessions keyed by setup ID. +/// +/// Enforces the setup-ID-collision and capacity rejection paths the single +/// session cannot see on its own. +#[derive(Debug)] +pub struct SessionTable { + config: SessionConfig, + sessions: BTreeMap, +} + +impl SessionTable { + pub fn new(config: SessionConfig) -> Self { + Self { + config, + sessions: BTreeMap::new(), + } + } + + /// Number of setups not in Idle. + pub fn active_setups(&self) -> usize { + self.sessions + .values() + .filter(|s| s.state() != SessionState::Idle) + .count() + } + + pub fn session(&self, setup_id: MeasurementSetupId) -> Option<&SensingSession> { + self.sessions.get(&setup_id.value()) + } + + /// Route an inbound setup request, rejecting setup-ID collisions and + /// capacity overruns before delegating to a responder session. + pub fn handle_setup_request( + &mut self, + req: SensingMeasurementSetupRequest, + ) -> Result, BfError> { + let reject = |setup_id, status| { + Ok(vec![Action::SendSetupResponse( + SensingMeasurementSetupResponse { setup_id, status }, + )]) + }; + if let Some(existing) = self.sessions.get(&req.setup_id.value()) { + if existing.state() != SessionState::Idle { + return reject(req.setup_id, SetupStatus::RejectedSetupIdCollision); + } + } + if self.active_setups() >= self.config.capabilities.max_active_setups as usize { + return reject(req.setup_id, SetupStatus::RejectedCapacity); + } + let key = req.setup_id.value(); + let mut session = SensingSession::new_responder(self.config.clone()); + let actions = session.handle(SessionEvent::SetupRequestReceived(req))?; + self.sessions.insert(key, session); + Ok(actions) + } + + /// Route any other event to the session owning `setup_id` (no-op if the + /// setup is unknown — stray frames are ignored, not errors). + pub fn handle_for( + &mut self, + setup_id: MeasurementSetupId, + event: SessionEvent, + ) -> Result, BfError> { + match self.sessions.get_mut(&setup_id.value()) { + Some(session) => session.handle(event), + None => Ok(vec![]), + } + } +} diff --git a/v2/crates/wifi-densepose-hardware/src/ieee80211bf/tests.rs b/v2/crates/wifi-densepose-hardware/src/ieee80211bf/tests.rs new file mode 100644 index 00000000..4585c9ed --- /dev/null +++ b/v2/crates/wifi-densepose-hardware/src/ieee80211bf/tests.rs @@ -0,0 +1,262 @@ +//! ADR-153 acceptance tests — types (serde round trips, boundary +//! validation), the SimTransport double, and the ESP32 CSI bridge. +//! FSM/timeout/threshold/SBP coverage lives in [`super::tests_fsm`]. +//! All tests are hardware-free (simulation only). + +use super::messages::*; +use super::testutil::{csi_frame, params, payload, setup_request}; +use super::transport::{ + OpportunisticCsiBridge, SensingFrame, SensingTransport, SimTransport, TransportError, +}; +use super::types::*; + +// ---------- serde round trips ---------- + +#[test] +fn serde_round_trips_setup_instance_report_sbp_termination() { + let req = setup_request(7); + let json = serde_json::to_string(&req).unwrap(); + assert_eq!( + serde_json::from_str::(&json).unwrap(), + req + ); + + let resp = SensingMeasurementSetupResponse { + setup_id: req.setup_id, + status: SetupStatus::Accepted, + }; + let json = serde_json::to_string(&resp).unwrap(); + assert_eq!( + serde_json::from_str::(&json).unwrap(), + resp + ); + + let instance = SensingMeasurementInstance { + setup_id: req.setup_id, + instance_id: MeasurementInstanceId::new(3), + timestamp_us: 300_000, + }; + let json = serde_json::to_string(&instance).unwrap(); + assert_eq!( + serde_json::from_str::(&json).unwrap(), + instance + ); + + let report = SensingMeasurementReport { + setup_id: req.setup_id, + instance_id: MeasurementInstanceId::new(3), + payload: payload(42.0), + }; + let json = serde_json::to_string(&report).unwrap(); + assert_eq!( + serde_json::from_str::(&json).unwrap(), + report + ); + + let sbp = SbpRequest { + profile: SpecProfile::VendorExtension("acme-presensing".into()), + proxy_setup_id: req.setup_id, + params: params(), + }; + let json = serde_json::to_string(&sbp).unwrap(); + assert_eq!(serde_json::from_str::(&json).unwrap(), sbp); + + let sbp_resp = SbpResponse { + proxy_setup_id: req.setup_id, + status: SbpStatus::Accepted, + }; + let json = serde_json::to_string(&sbp_resp).unwrap(); + assert_eq!( + serde_json::from_str::(&json).unwrap(), + sbp_resp + ); + + let term = SensingSessionTermination { + setup_id: req.setup_id, + reason: TerminationReason::InitiatorRequested, + }; + let json = serde_json::to_string(&term).unwrap(); + assert_eq!( + serde_json::from_str::(&json).unwrap(), + term + ); +} + +#[test] +fn serde_rejects_out_of_range_setup_id() { + assert!(serde_json::from_str::("200").is_err()); + assert!(serde_json::from_str::("127").is_ok()); +} + +// ---------- validation, no panics ---------- + +#[test] +fn setup_id_construction_never_panics_and_bounds_hold() { + for v in 0u8..=255 { + let result = MeasurementSetupId::new(v); + assert_eq!(result.is_ok(), v <= MAX_SETUP_ID); + } +} + +#[test] +fn params_validation_rejects_malformed() { + let mut p = params(); + p.period_ms = MIN_PERIOD_MS - 1; + assert!(matches!(p.validate(), Err(BfError::InvalidPeriod { .. }))); + p = params(); + p.period_ms = MAX_PERIOD_MS + 1; + assert!(matches!(p.validate(), Err(BfError::InvalidPeriod { .. }))); + p = params(); + p.burst_instances = 0; + assert!(matches!( + p.validate(), + Err(BfError::InvalidBurstInstances { .. }) + )); + p = params(); + p.burst_instances = MAX_BURST_INSTANCES + 1; + assert!(matches!( + p.validate(), + Err(BfError::InvalidBurstInstances { .. }) + )); + p = params(); + p.initiator_role = TransceiverRole::Receiver; // no transmitter anywhere + assert!(matches!( + p.validate(), + Err(BfError::InvalidTransceiverRoles) + )); + p = params(); + p.consent = ConsentMode::Disabled; + assert!(matches!( + p.validate(), + Err(BfError::SensingDisabledByPolicy) + )); + assert!(ThresholdParams::new(101).is_err()); + assert!(ThresholdParams::new(100).is_ok()); +} + +#[test] +fn payload_validation_rejects_adversarial_values_without_panic() { + let adversarial = [ + CsiReportPayload { + n_subcarriers: 0, + amplitudes: vec![], + phases: vec![], + }, + CsiReportPayload { + n_subcarriers: u16::MAX, + amplitudes: vec![1.0; 4], + phases: vec![0.0; 4], + }, + CsiReportPayload { + n_subcarriers: 4, + amplitudes: vec![1.0; 3], + phases: vec![0.0; 4], + }, + CsiReportPayload { + n_subcarriers: 2, + amplitudes: vec![f32::NAN, 1.0], + phases: vec![0.0; 2], + }, + CsiReportPayload { + n_subcarriers: 2, + amplitudes: vec![1.0, f32::INFINITY], + phases: vec![0.0; 2], + }, + CsiReportPayload { + n_subcarriers: 2, + amplitudes: vec![-1.0, 1.0], + phases: vec![0.0; 2], + }, + CsiReportPayload { + n_subcarriers: 2, + amplitudes: vec![1.0; 2], + phases: vec![f32::NEG_INFINITY, 0.0], + }, + ]; + for p in adversarial { + assert!(p.validate().is_err()); + } + assert!(payload(5.0).validate().is_ok()); +} + +#[test] +fn spec_profile_compatibility() { + let published = SpecProfile::Ieee80211Bf2025; + assert!(published.accepts(&SpecProfile::DraftCompatible)); + assert!(published.accepts(&SpecProfile::Ieee80211Bf2025)); + assert!(!published.accepts(&SpecProfile::VendorExtension("x".into()))); + let vendor = SpecProfile::VendorExtension("x".into()); + assert!(vendor.accepts(&SpecProfile::VendorExtension("x".into()))); + assert!(!vendor.accepts(&SpecProfile::VendorExtension("y".into()))); +} + +// ---------- bridge: ESP32 CSI → standardized report ---------- + +#[test] +fn bridge_maps_csi_batches_to_measurement_reports() { + let setup_id = MeasurementSetupId::new(1).unwrap(); + let mut bridge = OpportunisticCsiBridge::new(setup_id, 4).unwrap(); + assert!(OpportunisticCsiBridge::new(setup_id, 0).is_err()); + + // 3 frames: no report yet. 4th completes the instance batch. + for _ in 0..3 { + assert!(bridge.ingest(&csi_frame(8, 30, 40)).is_none()); + } + let report = bridge + .ingest(&csi_frame(8, 30, 40)) + .expect("batch complete"); + assert_eq!(report.setup_id, setup_id); + assert_eq!(report.instance_id.value(), 0); + assert_eq!(report.payload.n_subcarriers, 8); + assert!(report.payload.validate().is_ok()); + // |30 + 40i| = 50 on every subcarrier of every frame. + assert!(report + .payload + .amplitudes + .iter() + .all(|a| (a - 50.0).abs() < 1e-3)); + + // Invalid (all-zero) frames are skipped and do not advance the batch. + for _ in 0..10 { + assert!(bridge.ingest(&csi_frame(8, 0, 0)).is_none()); + } + // A mid-batch subcarrier-shape change restarts the batch on the new shape. + assert!(bridge.ingest(&csi_frame(8, 10, 0)).is_none()); + assert!(bridge.ingest(&csi_frame(4, 10, 0)).is_none()); // restart at n=4 + for _ in 0..2 { + assert!(bridge.ingest(&csi_frame(4, 10, 0)).is_none()); + } + let report = bridge.ingest(&csi_frame(4, 10, 0)).expect("second batch"); + assert_eq!(report.instance_id.value(), 1); // instance counter advanced + assert_eq!(report.payload.n_subcarriers, 4); +} + +// ---------- transport ---------- + +#[test] +fn sim_transport_scripted_responses_and_failures() { + let mut t = SimTransport::new(); + let resp = SensingMeasurementSetupResponse { + setup_id: MeasurementSetupId::new(7).unwrap(), + status: SetupStatus::Accepted, + }; + t.script_response(SensingFrame::SetupResponse(resp)); + assert!(t.poll_frame().is_none()); + t.send_setup_request(setup_request(7)).unwrap(); + assert_eq!(t.poll_frame(), Some(SensingFrame::SetupResponse(resp))); + assert_eq!(t.sent().len(), 1); + + let mut tiny = SimTransport::with_capacity(1); + tiny.send_setup_request(setup_request(1)).unwrap(); + assert_eq!( + tiny.send_setup_request(setup_request(2)), + Err(TransportError::QueueFull { capacity: 1 }) + ); + + let mut down = SimTransport::new(); + down.set_link_down(true); + assert_eq!( + down.send_setup_request(setup_request(1)), + Err(TransportError::LinkDown) + ); +} diff --git a/v2/crates/wifi-densepose-hardware/src/ieee80211bf/tests_fsm.rs b/v2/crates/wifi-densepose-hardware/src/ieee80211bf/tests_fsm.rs new file mode 100644 index 00000000..443d5397 --- /dev/null +++ b/v2/crates/wifi-densepose-hardware/src/ieee80211bf/tests_fsm.rs @@ -0,0 +1,441 @@ +//! ADR-153 acceptance tests — session FSM full cycle, rejection paths, +//! timeout handling, threshold-based reporting, SBP flows, and adversarial +//! no-panic coverage. Type/serde/transport/bridge tests live in +//! [`super::tests`]. All tests are hardware-free (simulation only). + +use super::messages::*; +use super::session::{ + Action, CloseReason, SensingSession, SessionConfig, SessionEvent, SessionState, +}; +use super::table::SessionTable; +use super::testutil::{dispatch, ferry, params, payload, pump, setup_request}; +use super::transport::{SensingFrame, SimTransport}; +use super::types::*; +use crate::csi_frame::Bandwidth; + +// ---------- FSM: full cycle ---------- + +#[test] +fn fsm_full_cycle_setup_measure_report_terminate() { + let cfg = SessionConfig::default(); + let mut initiator = SensingSession::new_initiator(cfg.clone()); + let mut responder = SensingSession::new_responder(cfg); + let mut wire_i = SimTransport::new(); + let mut wire_r = SimTransport::new(); + + // Idle → SetupNegotiating + dispatch( + &mut initiator, + SessionEvent::StartSetup(setup_request(7)), + &mut wire_i, + ); + assert_eq!(initiator.state(), SessionState::SetupNegotiating); + + // Responder accepts → Active + ferry(&mut wire_i, &mut wire_r); + pump(&mut responder, &mut wire_r); + assert_eq!(responder.state(), SessionState::Active); + + // Initiator sees Accepted → Active + first instance trigger on the wire + ferry(&mut wire_r, &mut wire_i); + pump(&mut initiator, &mut wire_i); + assert_eq!(initiator.state(), SessionState::Active); + assert!(wire_i + .sent() + .iter() + .any(|f| matches!(f, SensingFrame::InstanceTrigger(i) if i.setup_id.value() == 7))); + + // Responder captures a measurement → report on the wire + wire_i.drain_sent(); + let actions = dispatch( + &mut responder, + SessionEvent::MeasurementCaptured { + instance_id: MeasurementInstanceId::new(0), + payload: payload(10.0), + }, + &mut wire_r, + ); + assert!(actions.iter().any(|a| matches!(a, Action::SendReport(_)))); + + // Initiator delivers the report to its consumer + ferry(&mut wire_r, &mut wire_i); + let actions = pump(&mut initiator, &mut wire_i); + assert!(actions + .iter() + .any(|a| matches!(a, Action::DeliverReport(_)))); + + // Active → Terminating → Idle (peer notified, quiescence completes) + wire_i.drain_sent(); + dispatch( + &mut initiator, + SessionEvent::Terminate(TerminationReason::InitiatorRequested), + &mut wire_i, + ); + assert_eq!(initiator.state(), SessionState::Terminating); + ferry(&mut wire_i, &mut wire_r); + let actions = pump(&mut responder, &mut wire_r); + assert!(actions.iter().any(|a| matches!( + a, + Action::SessionClosed(CloseReason::Terminated( + TerminationReason::InitiatorRequested + )) + ))); + assert_eq!(responder.state(), SessionState::Idle); + let actions = initiator.handle(SessionEvent::Timeout).unwrap(); + assert!(actions + .iter() + .any(|a| matches!(a, Action::SessionClosed(CloseReason::Completed)))); + assert_eq!(initiator.state(), SessionState::Idle); +} + +// ---------- FSM: rejection paths ---------- + +#[test] +fn responder_rejects_unsupported_bandwidth_and_initiator_resets() { + let mut cfg = SessionConfig::default(); + cfg.capabilities = SensingCapabilities::esp32_opportunistic(); // max 40 MHz + let mut responder = SensingSession::new_responder(cfg); + let mut initiator = SensingSession::new_initiator(SessionConfig::default()); + + let mut req = setup_request(3); + req.params.bandwidth = Bandwidth::Bw80; + initiator + .handle(SessionEvent::StartSetup(req.clone())) + .unwrap(); + + let actions = responder + .handle(SessionEvent::SetupRequestReceived(req)) + .unwrap(); + let resp = match &actions[..] { + [Action::SendSetupResponse(r)] => *r, + other => panic!("expected single rejection response, got {other:?}"), + }; + assert_eq!(resp.status, SetupStatus::RejectedUnsupportedParams); + assert_eq!(responder.state(), SessionState::Idle); + + let actions = initiator + .handle(SessionEvent::SetupResponseReceived(resp)) + .unwrap(); + assert!(actions.iter().any(|a| matches!( + a, + Action::SessionClosed(CloseReason::SetupRejected( + SetupStatus::RejectedUnsupportedParams + )) + ))); + assert_eq!(initiator.state(), SessionState::Idle); +} + +#[test] +fn invalid_period_rejected_on_both_sides() { + let mut req = setup_request(4); + req.params.period_ms = 1; // below MIN_PERIOD_MS + let mut initiator = SensingSession::new_initiator(SessionConfig::default()); + assert!(matches!( + initiator.handle(SessionEvent::StartSetup(req.clone())), + Err(BfError::InvalidPeriod { period_ms: 1 }) + )); + assert_eq!(initiator.state(), SessionState::Idle); + + let mut responder = SensingSession::new_responder(SessionConfig::default()); + let actions = responder + .handle(SessionEvent::SetupRequestReceived(req)) + .unwrap(); + assert!(matches!( + actions[..], + [Action::SendSetupResponse(SensingMeasurementSetupResponse { + status: SetupStatus::RejectedUnsupportedParams, + .. + })] + )); +} + +#[test] +fn duplicate_setup_id_rejected_by_session_table() { + let mut table = SessionTable::new(SessionConfig::default()); + let actions = table.handle_setup_request(setup_request(9)).unwrap(); + assert!(matches!( + actions[..], + [Action::SendSetupResponse(SensingMeasurementSetupResponse { + status: SetupStatus::Accepted, + .. + })] + )); + let actions = table.handle_setup_request(setup_request(9)).unwrap(); + assert!(matches!( + actions[..], + [Action::SendSetupResponse(SensingMeasurementSetupResponse { + status: SetupStatus::RejectedSetupIdCollision, + .. + })] + )); + assert_eq!(table.active_setups(), 1); +} + +#[test] +fn capacity_and_policy_and_profile_rejections() { + // Capacity + let mut cfg = SessionConfig::default(); + cfg.capabilities.max_active_setups = 1; + let mut table = SessionTable::new(cfg); + table.handle_setup_request(setup_request(1)).unwrap(); + let actions = table.handle_setup_request(setup_request(2)).unwrap(); + assert!(matches!( + actions[..], + [Action::SendSetupResponse(SensingMeasurementSetupResponse { + status: SetupStatus::RejectedCapacity, + .. + })] + )); + + // Consent policy + let mut responder = SensingSession::new_responder(SessionConfig::default()); + let mut req = setup_request(5); + req.params.consent = ConsentMode::Disabled; + let actions = responder + .handle(SessionEvent::SetupRequestReceived(req)) + .unwrap(); + assert!(matches!( + actions[..], + [Action::SendSetupResponse(SensingMeasurementSetupResponse { + status: SetupStatus::RejectedByPolicy, + .. + })] + )); + + // Incompatible profile + let mut cfg = SessionConfig::default(); + cfg.profile = SpecProfile::VendorExtension("acme".into()); + let mut responder = SensingSession::new_responder(cfg); + let actions = responder + .handle(SessionEvent::SetupRequestReceived(setup_request(6))) + .unwrap(); + assert!(matches!( + actions[..], + [Action::SendSetupResponse(SensingMeasurementSetupResponse { + status: SetupStatus::RejectedIncompatibleProfile, + .. + })] + )); +} + +// ---------- FSM: timeouts ---------- + +#[test] +fn negotiation_timeout_returns_typed_error_and_resets_to_idle() { + let mut initiator = SensingSession::new_initiator(SessionConfig::default()); // 3 timeouts + initiator + .handle(SessionEvent::StartSetup(setup_request(7))) + .unwrap(); + + // First two timeouts re-send the pending request. + for _ in 0..2 { + let actions = initiator.handle(SessionEvent::Timeout).unwrap(); + assert!(matches!(actions[..], [Action::SendSetupRequest(_)])); + assert_eq!(initiator.state(), SessionState::SetupNegotiating); + } + // Third gives up: typed error + Idle. + assert_eq!( + initiator.handle(SessionEvent::Timeout), + Err(BfError::NegotiationTimeout { + setup_id: 7, + attempts: 3 + }) + ); + assert_eq!(initiator.state(), SessionState::Idle); +} + +#[test] +fn active_missed_instance_timeouts_terminate_session() { + let mut responder = SensingSession::new_responder(SessionConfig::default()); // 5 missed max + responder + .handle(SessionEvent::SetupRequestReceived(setup_request(2))) + .unwrap(); + assert_eq!(responder.state(), SessionState::Active); + for _ in 0..4 { + assert!(responder.handle(SessionEvent::Timeout).unwrap().is_empty()); + } + let actions = responder.handle(SessionEvent::Timeout).unwrap(); + assert!(matches!( + actions[..], + [Action::SendTermination(SensingSessionTermination { + reason: TerminationReason::Timeout, + .. + })] + )); + assert_eq!(responder.state(), SessionState::Terminating); + let actions = responder.handle(SessionEvent::Timeout).unwrap(); + assert!(matches!( + actions[..], + [Action::SessionClosed(CloseReason::Completed)] + )); + assert_eq!(responder.state(), SessionState::Idle); +} + +// ---------- threshold-based reporting ---------- + +#[test] +fn threshold_report_emitted_only_when_threshold_crossed() { + let mut responder = SensingSession::new_responder(SessionConfig::default()); + let mut req = setup_request(8); + req.params.reporting = ReportingConfig::ThresholdBased(ThresholdParams::new(20).unwrap()); + responder + .handle(SessionEvent::SetupRequestReceived(req)) + .unwrap(); + + let capture = |mean: f32| SessionEvent::MeasurementCaptured { + instance_id: MeasurementInstanceId::new(0), + payload: payload(mean), + }; + // First measurement always reported (establishes the baseline). + let actions = responder.handle(capture(100.0)).unwrap(); + assert!(matches!(actions[..], [Action::SendReport(_)])); + // +10% — below threshold, suppressed; baseline stays at 100. + assert!(responder.handle(capture(110.0)).unwrap().is_empty()); + // +19% vs the *reported* baseline — still suppressed. + assert!(responder.handle(capture(119.0)).unwrap().is_empty()); + // +50% — crossed, reported, baseline moves to 150. + let actions = responder.handle(capture(150.0)).unwrap(); + assert!(matches!(actions[..], [Action::SendReport(_)])); + // 150 → 125 is ~16.7% — suppressed against the new baseline. + assert!(responder.handle(capture(125.0)).unwrap().is_empty()); +} + +// ---------- SBP ---------- + +#[test] +fn sbp_proxy_request_maps_to_standard_responder_path() { + // Proxy AP: accepts the SBP request and initiates an ordinary setup + // toward the sensing responder — no direct sensor coupling. + let mut proxy = SensingSession::new_responder(SessionConfig::default()); + let sbp = SbpRequest { + profile: SpecProfile::Ieee80211Bf2025, + proxy_setup_id: MeasurementSetupId::new(11).unwrap(), + params: params(), + }; + let actions = proxy.handle(SessionEvent::SbpRequestReceived(sbp)).unwrap(); + let forwarded = match &actions[..] { + [Action::SendSbpResponse(SbpResponse { + status: SbpStatus::Accepted, + .. + }), Action::SendSetupRequest(req)] => req.clone(), + other => panic!("expected SBP accept + setup request, got {other:?}"), + }; + assert_eq!(proxy.state(), SessionState::SetupNegotiating); + assert_eq!(forwarded.setup_id.value(), 11); + + // The forwarded request drives a *normal* responder session. + let mut responder = SensingSession::new_responder(SessionConfig::default()); + let actions = responder + .handle(SessionEvent::SetupRequestReceived(forwarded)) + .unwrap(); + let resp = match &actions[..] { + [Action::SendSetupResponse(r)] => *r, + other => panic!("expected accept, got {other:?}"), + }; + assert_eq!(resp.status, SetupStatus::Accepted); + proxy + .handle(SessionEvent::SetupResponseReceived(resp)) + .unwrap(); + assert_eq!(proxy.state(), SessionState::Active); +} + +#[test] +fn sbp_client_flow_and_rejections() { + let mut client = SensingSession::new_initiator(SessionConfig::default()); + let sbp = SbpRequest { + profile: SpecProfile::Ieee80211Bf2025, + proxy_setup_id: MeasurementSetupId::new(12).unwrap(), + params: params(), + }; + let actions = client.handle(SessionEvent::StartSbp(sbp.clone())).unwrap(); + assert!(matches!(actions[..], [Action::SendSbpRequest(_)])); + let accept = SbpResponse { + proxy_setup_id: sbp.proxy_setup_id, + status: SbpStatus::Accepted, + }; + client + .handle(SessionEvent::SbpResponseReceived(accept)) + .unwrap(); + assert_eq!(client.state(), SessionState::Active); + // Proxied report is delivered to the local consumer. + let report = SensingMeasurementReport { + setup_id: sbp.proxy_setup_id, + instance_id: MeasurementInstanceId::new(0), + payload: payload(1.0), + }; + let actions = client.handle(SessionEvent::ReportReceived(report)).unwrap(); + assert!(matches!(actions[..], [Action::DeliverReport(_)])); + + // A proxy without SBP capability rejects. + let mut cfg = SessionConfig::default(); + cfg.capabilities.sensing_by_proxy = false; + let mut no_sbp = SensingSession::new_responder(cfg); + let actions = no_sbp + .handle(SessionEvent::SbpRequestReceived(sbp)) + .unwrap(); + assert!(matches!( + actions[..], + [Action::SendSbpResponse(SbpResponse { + status: SbpStatus::RejectedNotSupported, + .. + })] + )); + assert_eq!(no_sbp.state(), SessionState::Idle); +} + +// ---------- adversarial: no panics anywhere ---------- + +#[test] +fn malformed_and_out_of_state_events_never_panic() { + let junk_payload = CsiReportPayload { + n_subcarriers: 3, + amplitudes: vec![f32::NAN, -5.0, f32::INFINITY], + phases: vec![f32::NAN], + }; + let bad_report = SensingMeasurementReport { + setup_id: MeasurementSetupId::new(99).unwrap(), + instance_id: MeasurementInstanceId::new(255), + payload: junk_payload.clone(), + }; + let events: Vec = vec![ + SessionEvent::StartSetup(setup_request(0)), + SessionEvent::StartSbp(SbpRequest { + profile: SpecProfile::DraftCompatible, + proxy_setup_id: MeasurementSetupId::new(0).unwrap(), + params: params(), + }), + SessionEvent::SetupRequestReceived(setup_request(127)), + SessionEvent::SetupResponseReceived(SensingMeasurementSetupResponse { + setup_id: MeasurementSetupId::new(50).unwrap(), + status: SetupStatus::RejectedCapacity, + }), + SessionEvent::SbpResponseReceived(SbpResponse { + proxy_setup_id: MeasurementSetupId::new(50).unwrap(), + status: SbpStatus::RejectedByPolicy, + }), + SessionEvent::InstanceElapsed, + SessionEvent::MeasurementCaptured { + instance_id: MeasurementInstanceId::new(0), + payload: junk_payload, + }, + SessionEvent::ReportReceived(bad_report), + SessionEvent::Timeout, + SessionEvent::Terminate(TerminationReason::PolicyChange), + SessionEvent::TerminationReceived(SensingSessionTermination { + setup_id: MeasurementSetupId::new(1).unwrap(), + reason: TerminationReason::Timeout, + }), + ]; + // Drive both roles through every event repeatedly from whatever state + // each lands in; typed errors are fine, panics are not. + for session in [ + &mut SensingSession::new_initiator(SessionConfig::default()), + &mut SensingSession::new_responder(SessionConfig::default()), + ] { + for _ in 0..4 { + for event in &events { + let _ = session.handle(event.clone()); + } + } + } +} diff --git a/v2/crates/wifi-densepose-hardware/src/ieee80211bf/testutil.rs b/v2/crates/wifi-densepose-hardware/src/ieee80211bf/testutil.rs new file mode 100644 index 00000000..083889ec --- /dev/null +++ b/v2/crates/wifi-densepose-hardware/src/ieee80211bf/testutil.rs @@ -0,0 +1,101 @@ +//! Shared helpers for the ADR-153 acceptance tests (hardware-free). + +use chrono::Utc; + +use super::messages::{CsiReportPayload, SensingMeasurementSetupRequest}; +use super::session::{Action, SensingSession, SessionEvent}; +use super::transport::{action_to_frame, frame_to_event, SensingTransport, SimTransport}; +use super::types::{ + ConsentMode, MeasurementSetupId, MeasurementSetupParams, ReportingConfig, SpecProfile, + TransceiverRole, +}; +use crate::csi_frame::{ + Adr018Flags, AntennaConfig, Bandwidth, CsiFrame, CsiMetadata, PpduType, SubcarrierData, +}; + +pub(super) fn params() -> MeasurementSetupParams { + MeasurementSetupParams { + bandwidth: Bandwidth::Bw20, + period_ms: 100, + burst_instances: 4, + reporting: ReportingConfig::EveryInstance, + initiator_role: TransceiverRole::Transmitter, + responder_role: TransceiverRole::Receiver, + consent: ConsentMode::ExplicitConsent, + } +} + +pub(super) fn setup_request(id: u8) -> SensingMeasurementSetupRequest { + SensingMeasurementSetupRequest { + profile: SpecProfile::Ieee80211Bf2025, + setup_id: MeasurementSetupId::new(id).unwrap(), + params: params(), + } +} + +pub(super) fn payload(mean: f32) -> CsiReportPayload { + CsiReportPayload { + n_subcarriers: 4, + amplitudes: vec![mean; 4], + phases: vec![0.25; 4], + } +} + +pub(super) fn csi_frame(n: usize, i: i16, q: i16) -> CsiFrame { + CsiFrame { + metadata: CsiMetadata { + timestamp: Utc::now(), + node_id: 1, + n_antennas: 1, + n_subcarriers: n as u16, + channel_freq_mhz: 2437, + rssi_dbm: -50, + noise_floor_dbm: -95, + bandwidth: Bandwidth::Bw20, + antenna_config: AntennaConfig::default(), + sequence: 0, + ppdu_type: PpduType::HtLegacy, + adr018_flags: Adr018Flags::default(), + }, + subcarriers: (0..n) + .map(|k| SubcarrierData { + i, + q, + index: k as i16, + }) + .collect(), + } +} + +/// Drive a session, forwarding wire-bound actions onto a transport. +pub(super) fn dispatch( + s: &mut SensingSession, + event: SessionEvent, + out: &mut SimTransport, +) -> Vec { + let actions = s.handle(event).expect("handle must not error"); + for a in &actions { + if let Some(f) = action_to_frame(a) { + out.send_frame(f).expect("send must not error"); + } + } + actions +} + +pub(super) fn ferry(from: &mut SimTransport, to: &mut SimTransport) { + for f in from.drain_sent() { + to.push_inbound(f); + } +} + +/// Consume inbound frames on `wire`, sending any resulting outbound frames +/// back onto the same transport's sent log. +pub(super) fn pump(s: &mut SensingSession, wire: &mut SimTransport) -> Vec { + let mut all = Vec::new(); + while let Some(frame) = wire.poll_frame() { + if let Some(event) = frame_to_event(frame) { + all.extend(dispatch(s, event, wire)); + } + } + all +} diff --git a/v2/crates/wifi-densepose-hardware/src/ieee80211bf/transport.rs b/v2/crates/wifi-densepose-hardware/src/ieee80211bf/transport.rs new file mode 100644 index 00000000..b57a3652 --- /dev/null +++ b/v2/crates/wifi-densepose-hardware/src/ieee80211bf/transport.rs @@ -0,0 +1,310 @@ +//! Transport abstraction for the 802.11bf forward-compatibility model. +//! +//! [`SensingTransport`] is the seam where a real chipset binding will land +//! when commodity silicon implements IEEE 802.11bf-2025 (none does today — +//! ADR-152 F4, ADR-153). Until then: +//! +//! - [`SimTransport`] is a scriptable in-memory test double for protocol +//! tests in CI (no hardware). +//! - [`OpportunisticCsiBridge`] maps today's opportunistic ESP32 CSI +//! extraction (ADR-018 frames parsed by [`crate::Esp32CsiParser`] and +//! delivered by [`crate::aggregator::Esp32Aggregator`]) onto the +//! standardized report path: one measurement instance ≈ one batch of +//! [`CsiFrame`]s. +//! +//! **Replaceability benchmark (ADR-153):** consumers must depend only on +//! `SensingTransport` plus the report types in [`super::types`] — a future +//! chipset adapter replaces `OpportunisticCsiBridge` without touching them. + +use std::collections::VecDeque; + +use thiserror::Error; + +use super::messages::{ + CsiReportPayload, SbpRequest, SbpResponse, SensingMeasurementInstance, + SensingMeasurementReport, SensingMeasurementSetupRequest, SensingMeasurementSetupResponse, + SensingSessionTermination, +}; +use super::session::Action; +use super::types::{BfError, MeasurementInstanceId, MeasurementSetupId, MAX_REPORT_SUBCARRIERS}; +use crate::csi_frame::CsiFrame; + +/// Frames exchanged between sensing endpoints. This is a *logical* frame +/// set — no OTA encoding is defined until silicon exists to bind to. +#[derive(Debug, Clone, PartialEq)] +pub enum SensingFrame { + SetupRequest(SensingMeasurementSetupRequest), + SetupResponse(SensingMeasurementSetupResponse), + InstanceTrigger(SensingMeasurementInstance), + Report(SensingMeasurementReport), + SbpRequest(SbpRequest), + SbpResponse(SbpResponse), + Termination(SensingSessionTermination), +} + +/// Errors surfaced by a sensing transport. +#[derive(Debug, Clone, PartialEq, Error)] +pub enum TransportError { + #[error("transport link down")] + LinkDown, + #[error("transport queue full (capacity {capacity})")] + QueueFull { capacity: usize }, +} + +/// Frame-exchange abstraction for sensing endpoints. +/// +/// The required surface is deliberately tiny (`send_frame`/`poll_frame`); +/// the named helpers are convenience wrappers so call sites read like the +/// standard's procedures. +pub trait SensingTransport { + /// Queue one logical frame toward the peer. + fn send_frame(&mut self, frame: SensingFrame) -> Result<(), TransportError>; + + /// Pop the next inbound frame, if any. + fn poll_frame(&mut self) -> Option; + + fn send_setup_request( + &mut self, + req: SensingMeasurementSetupRequest, + ) -> Result<(), TransportError> { + self.send_frame(SensingFrame::SetupRequest(req)) + } + + fn send_setup_response( + &mut self, + resp: SensingMeasurementSetupResponse, + ) -> Result<(), TransportError> { + self.send_frame(SensingFrame::SetupResponse(resp)) + } + + fn trigger_measurement_instance( + &mut self, + instance: SensingMeasurementInstance, + ) -> Result<(), TransportError> { + self.send_frame(SensingFrame::InstanceTrigger(instance)) + } + + fn send_report(&mut self, report: SensingMeasurementReport) -> Result<(), TransportError> { + self.send_frame(SensingFrame::Report(report)) + } + + fn send_termination( + &mut self, + termination: SensingSessionTermination, + ) -> Result<(), TransportError> { + self.send_frame(SensingFrame::Termination(termination)) + } +} + +/// Map a session [`Action`] to the frame it puts on the wire, if any. +/// `DeliverReport`/`SessionClosed` are local-consumer actions and map to `None`. +pub fn action_to_frame(action: &Action) -> Option { + match action { + Action::SendSetupRequest(req) => Some(SensingFrame::SetupRequest(req.clone())), + Action::SendSetupResponse(resp) => Some(SensingFrame::SetupResponse(*resp)), + Action::SendSbpRequest(req) => Some(SensingFrame::SbpRequest(req.clone())), + Action::SendSbpResponse(resp) => Some(SensingFrame::SbpResponse(*resp)), + Action::TriggerInstance(instance) => Some(SensingFrame::InstanceTrigger(*instance)), + Action::SendReport(report) => Some(SensingFrame::Report(report.clone())), + Action::SendTermination(term) => Some(SensingFrame::Termination(*term)), + Action::DeliverReport(_) | Action::SessionClosed(_) => None, + } +} + +/// Map an inbound frame to the session event it raises on the receiver. +/// +/// `InstanceTrigger` maps to `None`: a sensing receiver pairs the trigger +/// with locally captured CSI and raises `MeasurementCaptured` itself (see +/// [`OpportunisticCsiBridge`]). +pub fn frame_to_event(frame: SensingFrame) -> Option { + use super::session::SessionEvent as E; + match frame { + SensingFrame::SetupRequest(req) => Some(E::SetupRequestReceived(req)), + SensingFrame::SetupResponse(resp) => Some(E::SetupResponseReceived(resp)), + SensingFrame::Report(report) => Some(E::ReportReceived(report)), + SensingFrame::SbpRequest(req) => Some(E::SbpRequestReceived(req)), + SensingFrame::SbpResponse(resp) => Some(E::SbpResponseReceived(resp)), + SensingFrame::Termination(term) => Some(E::TerminationReceived(term)), + SensingFrame::InstanceTrigger(_) => None, + } +} + +/// In-memory scriptable transport test double. +/// +/// Every successful `send_frame` is recorded in [`SimTransport::sent`]; if a +/// scripted response is queued, it is moved to the inbound queue so the next +/// `poll_frame` returns it — letting tests script a peer without one. +#[derive(Debug, Default)] +pub struct SimTransport { + sent: Vec, + inbound: VecDeque, + scripted: VecDeque, + link_down: bool, + capacity: usize, +} + +impl SimTransport { + pub fn new() -> Self { + Self { + capacity: 1024, + ..Default::default() + } + } + + pub fn with_capacity(capacity: usize) -> Self { + Self { + capacity, + ..Default::default() + } + } + + /// Frames sent so far, in order. + pub fn sent(&self) -> &[SensingFrame] { + &self.sent + } + + /// Drain the sent log (useful when ferrying frames between two doubles). + pub fn drain_sent(&mut self) -> Vec { + std::mem::take(&mut self.sent) + } + + /// Queue a frame as if the peer transmitted it. + pub fn push_inbound(&mut self, frame: SensingFrame) { + self.inbound.push_back(frame); + } + + /// Script a response: the next successful send moves it to the inbound + /// queue (one scripted frame consumed per send). + pub fn script_response(&mut self, frame: SensingFrame) { + self.scripted.push_back(frame); + } + + pub fn set_link_down(&mut self, down: bool) { + self.link_down = down; + } +} + +impl SensingTransport for SimTransport { + fn send_frame(&mut self, frame: SensingFrame) -> Result<(), TransportError> { + if self.link_down { + return Err(TransportError::LinkDown); + } + if self.sent.len() >= self.capacity { + return Err(TransportError::QueueFull { + capacity: self.capacity, + }); + } + self.sent.push(frame); + if let Some(response) = self.scripted.pop_front() { + self.inbound.push_back(response); + } + Ok(()) + } + + fn poll_frame(&mut self) -> Option { + self.inbound.pop_front() + } +} + +/// Adapter mapping today's opportunistic ESP32 CSI extraction onto the +/// standardized sensing report path. +/// +/// A "measurement instance" is approximated by one batch of `batch_size` +/// ADR-018 [`CsiFrame`]s from a node (as produced by +/// [`crate::aggregator::Esp32Aggregator`]'s mpsc channel). Amplitudes are +/// averaged arithmetically; phases via the circular mean (consistent with +/// the RuvSense `phase_align` treatment of LO phase). Invalid frames +/// ([`CsiFrame::is_valid`] false) are skipped; a mid-batch subcarrier-shape +/// change (node reconfiguration) restarts the batch on the new shape. +/// +/// This is the *interim backend*: when 802.11bf silicon exists, a chipset +/// adapter producing the same [`SensingMeasurementReport`]s replaces this +/// bridge with no change to consumers (ADR-153 replaceability benchmark). +#[derive(Debug)] +pub struct OpportunisticCsiBridge { + setup_id: MeasurementSetupId, + batch_size: usize, + instance_counter: u32, + amp_accum: Vec, + phase_cos_accum: Vec, + phase_sin_accum: Vec, + frames_in_batch: usize, +} + +impl OpportunisticCsiBridge { + pub fn new(setup_id: MeasurementSetupId, batch_size: usize) -> Result { + if batch_size == 0 { + return Err(BfError::InvalidBatchSize { got: 0 }); + } + Ok(Self { + setup_id, + batch_size, + instance_counter: 0, + amp_accum: Vec::new(), + phase_cos_accum: Vec::new(), + phase_sin_accum: Vec::new(), + frames_in_batch: 0, + }) + } + + pub fn setup_id(&self) -> MeasurementSetupId { + self.setup_id + } + + pub fn batch_size(&self) -> usize { + self.batch_size + } + + /// Feed one parsed CSI frame; returns a standardized measurement report + /// when a batch completes. Never panics on malformed frames. + pub fn ingest(&mut self, frame: &CsiFrame) -> Option { + if !frame.is_valid() || frame.subcarrier_count() > MAX_REPORT_SUBCARRIERS as usize { + return None; + } + let (amplitudes, phases) = frame.to_amplitude_phase(); + if self.frames_in_batch == 0 || amplitudes.len() != self.amp_accum.len() { + // Fresh batch (or node reconfigured mid-batch — restart on the + // new subcarrier shape, dropping the partial batch). + self.amp_accum = vec![0.0; amplitudes.len()]; + self.phase_cos_accum = vec![0.0; amplitudes.len()]; + self.phase_sin_accum = vec![0.0; amplitudes.len()]; + self.frames_in_batch = 0; + } + for (i, (a, p)) in amplitudes.iter().zip(phases.iter()).enumerate() { + self.amp_accum[i] += a; + self.phase_cos_accum[i] += p.cos(); + self.phase_sin_accum[i] += p.sin(); + } + self.frames_in_batch += 1; + if self.frames_in_batch < self.batch_size { + return None; + } + + let scale = self.frames_in_batch as f64; + let payload = CsiReportPayload { + n_subcarriers: self.amp_accum.len() as u16, + amplitudes: self.amp_accum.iter().map(|a| (a / scale) as f32).collect(), + phases: self + .phase_sin_accum + .iter() + .zip(self.phase_cos_accum.iter()) + .map(|(s, c)| s.atan2(*c) as f32) + .collect(), + }; + self.amp_accum.clear(); + self.phase_cos_accum.clear(); + self.phase_sin_accum.clear(); + self.frames_in_batch = 0; + + let n = self.instance_counter; + self.instance_counter = self.instance_counter.wrapping_add(1); + let report = SensingMeasurementReport { + setup_id: self.setup_id, + instance_id: MeasurementInstanceId::new((n % 256) as u8), + payload, + }; + // Boundary check before handing to consumers; drop instead of panic. + report.validate().ok()?; + Some(report) + } +} diff --git a/v2/crates/wifi-densepose-hardware/src/ieee80211bf/types.rs b/v2/crates/wifi-densepose-hardware/src/ieee80211bf/types.rs new file mode 100644 index 00000000..15e216bd --- /dev/null +++ b/v2/crates/wifi-densepose-hardware/src/ieee80211bf/types.rs @@ -0,0 +1,375 @@ +//! Typed structures for IEEE 802.11bf-2025 WLAN sensing procedures. +//! +//! Sub-7 GHz focus; DMG (>45 GHz) types are stubbed minimally. Concept names +//! follow the standard's procedure vocabulary descriptively — "Sensing +//! Measurement Setup", "Sensing Measurement Instance", "Sensing Measurement +//! Report", "Sensing by Proxy (SBP)", session termination — without claiming +//! clause-level conformance. See [`crate::ieee80211bf`] module docs and +//! ADR-153 for framing; ADR-152 §1.1 F4 for the standards-body evidence. + +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use crate::csi_frame::Bandwidth; + +/// Largest measurement setup identifier accepted by this model (7-bit space; +/// chosen conservatively — the standard encodes the Measurement Setup ID in a +/// compact identifier field). +pub const MAX_SETUP_ID: u8 = 127; +/// Minimum measurement-instance periodicity accepted by this model. +pub const MIN_PERIOD_MS: u32 = 10; +/// Maximum measurement-instance periodicity accepted by this model (1 hour). +pub const MAX_PERIOD_MS: u32 = 3_600_000; +/// Maximum measurement instances per burst accepted by this model. +pub const MAX_BURST_INSTANCES: u8 = 64; +/// Maximum subcarriers in a CSI-variant report payload (matches the 160 MHz +/// usable-subcarrier count, [`Bandwidth::Bw160`]). +pub const MAX_REPORT_SUBCARRIERS: u16 = 484; + +/// Errors produced by validation at the protocol-model boundary. +/// +/// Adversarial or malformed input must surface as one of these — never a +/// panic (crate rule: input validation at system boundaries). +#[derive(Debug, Clone, PartialEq, Error)] +pub enum BfError { + /// Measurement setup ID outside the accepted identifier space. + #[error("invalid measurement setup ID {value} (valid 0..={MAX_SETUP_ID})")] + InvalidSetupId { value: u8 }, + /// Measurement periodicity outside the accepted range. + #[error("measurement period {period_ms} ms out of range ({MIN_PERIOD_MS}..={MAX_PERIOD_MS})")] + InvalidPeriod { period_ms: u32 }, + /// Instances-per-burst outside the accepted range. + #[error("burst instance count {count} out of range (1..={MAX_BURST_INSTANCES})")] + InvalidBurstInstances { count: u8 }, + /// Threshold-based reporting parameter outside 0..=100 percent. + #[error("reporting threshold {value}% out of range (0..=100)")] + InvalidThreshold { value: u8 }, + /// The initiator/responder transceiver roles leave the measurement with + /// no sensing transmitter or no sensing receiver. + #[error("transceiver roles leave no sensing transmitter/receiver pair")] + InvalidTransceiverRoles, + /// Setup carries [`ConsentMode::Disabled`] — sensing must not start. + #[error("sensing disabled by consent policy")] + SensingDisabledByPolicy, + /// Report payload declares zero subcarriers. + #[error("report payload empty")] + EmptyPayload, + /// Report payload claims more subcarriers than this model supports. + #[error("report payload claims {count} subcarriers (max {MAX_REPORT_SUBCARRIERS})")] + PayloadTooLarge { count: u16 }, + /// Declared subcarrier count and vector lengths disagree. + #[error( + "report payload length mismatch: declared {declared}, amplitudes {amplitudes}, phases {phases}" + )] + PayloadLengthMismatch { + declared: usize, + amplitudes: usize, + phases: usize, + }, + /// A payload value is NaN/infinite, or an amplitude is negative. + #[error("report payload value at index {index} is not finite (or negative amplitude)")] + PayloadValueInvalid { index: usize }, + /// A frame referenced a setup ID that does not match the session. + #[error("setup ID mismatch: session {expected}, frame {got}")] + SetupIdMismatch { expected: u8, got: u8 }, + /// Sensing measurement setup negotiation timed out (session resets to Idle). + #[error("negotiation timed out for setup {setup_id} after {attempts} attempts")] + NegotiationTimeout { setup_id: u8, attempts: u8 }, + /// A local command (`StartSetup`/`StartSbp`) was issued in a state or + /// role that cannot accept it. + #[error("command not valid in state {state}")] + InvalidStateForCommand { state: &'static str }, + /// CSI bridge batch size must be at least one frame. + #[error("invalid CSI batch size {got} (must be >= 1)")] + InvalidBatchSize { got: usize }, +} + +/// Version gate for every negotiated surface (ADR-153). +/// +/// Vendors will expose partial or renamed capabilities before full +/// IEEE 802.11bf-2025 conformance; tagging setups and capability +/// advertisements with a profile keeps that drift explicit. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum SpecProfile { + /// Pre-publication draft semantics (D-series compatible behavior). + DraftCompatible, + /// Published standard semantics (IEEE 802.11bf-2025, published 2025-09-26). + Ieee80211Bf2025, + /// Vendor-specific extension or renamed capability set. + VendorExtension(String), +} + +impl SpecProfile { + /// Whether a peer advertising `self` accepts a setup tagged `requested`. + /// + /// Published-standard peers accept draft-compatible requests; vendor + /// extensions must match exactly. + pub fn accepts(&self, requested: &SpecProfile) -> bool { + self == requested + || matches!( + (self, requested), + (SpecProfile::Ieee80211Bf2025, SpecProfile::DraftCompatible) + ) + } +} + +/// Consent/governance mode carried by every sensing measurement setup +/// (ADR-153: sensing is presence inference, not just radio telemetry). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum ConsentMode { + /// Lab/bench use only; not a deployment consent basis. + LabOnly, + /// Sensed persons gave explicit consent. + ExplicitConsent, + /// Enterprise-managed policy authorizes sensing. + ManagedEnterprisePolicy, + /// Sensing administratively disabled — setups must be rejected. + Disabled, +} + +/// WLAN sensing procedure role: sensing initiator or sensing responder. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum SensingRole { + Initiator, + Responder, +} + +/// Per-measurement-instance role: sensing transmitter, sensing receiver, +/// or both (a STA may act as either within a measurement instance). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum TransceiverRole { + Transmitter, + Receiver, + TransmitterReceiver, +} + +impl TransceiverRole { + pub fn is_transmitter(self) -> bool { + matches!(self, Self::Transmitter | Self::TransmitterReceiver) + } + pub fn is_receiver(self) -> bool { + matches!(self, Self::Receiver | Self::TransmitterReceiver) + } +} + +/// Identifier of a sensing measurement setup ("Measurement Setup ID"). +/// +/// Validated newtype: construction and deserialization both reject values +/// above [`MAX_SETUP_ID`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(try_from = "u8", into = "u8")] +pub struct MeasurementSetupId(u8); + +impl MeasurementSetupId { + pub fn new(value: u8) -> Result { + if value > MAX_SETUP_ID { + Err(BfError::InvalidSetupId { value }) + } else { + Ok(Self(value)) + } + } + pub fn value(self) -> u8 { + self.0 + } +} + +impl TryFrom for MeasurementSetupId { + type Error = BfError; + fn try_from(value: u8) -> Result { + Self::new(value) + } +} + +impl From for u8 { + fn from(id: MeasurementSetupId) -> u8 { + id.0 + } +} + +/// Identifier of a sensing measurement instance within a setup +/// ("Measurement Instance ID"). Wraps modulo 256. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct MeasurementInstanceId(u8); + +impl MeasurementInstanceId { + pub fn new(value: u8) -> Self { + Self(value) + } + pub fn value(self) -> u8 { + self.0 + } + pub fn wrapping_next(self) -> Self { + Self(self.0.wrapping_add(1)) + } +} + +/// Channel width of a bandwidth variant in MHz (capability comparisons). +pub fn bandwidth_mhz(bw: Bandwidth) -> u16 { + match bw { + Bandwidth::Bw20 => 20, + Bandwidth::Bw40 => 40, + Bandwidth::Bw80 => 80, + Bandwidth::Bw160 => 160, + } +} + +/// Threshold-based reporting parameters: a report is generated only when the +/// measurement changes by at least `delta_percent` relative to the last +/// reported measurement (normalized-change trigger). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct ThresholdParams { + delta_percent: u8, +} + +impl ThresholdParams { + pub fn new(delta_percent: u8) -> Result { + if delta_percent > 100 { + Err(BfError::InvalidThreshold { + value: delta_percent, + }) + } else { + Ok(Self { delta_percent }) + } + } + pub fn delta_percent(self) -> u8 { + self.delta_percent + } + /// Whether the change from `previous` to `current` crosses the threshold. + pub fn exceeds(self, previous: f64, current: f64) -> bool { + let denom = previous.abs().max(f64::EPSILON); + ((current - previous).abs() / denom) * 100.0 >= self.delta_percent as f64 + } +} + +/// Reporting discipline negotiated in the sensing measurement setup. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum ReportingConfig { + /// Report every measurement instance. + EveryInstance, + /// Threshold-based reporting (report only on significant change). + ThresholdBased(ThresholdParams), +} + +/// Parameters of a sensing measurement setup ("Sensing Measurement Setup +/// element" parameters, sub-7 GHz). Consent metadata is **required**. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct MeasurementSetupParams { + /// Sounding bandwidth. + pub bandwidth: Bandwidth, + /// Periodicity of measurement instances, in milliseconds. + pub period_ms: u32, + /// Measurement instances per burst. + pub burst_instances: u8, + /// Reporting discipline (per-instance or threshold-based). + pub reporting: ReportingConfig, + /// Transceiver role the initiator takes during measurement instances. + pub initiator_role: TransceiverRole, + /// Transceiver role the responder takes during measurement instances. + pub responder_role: TransceiverRole, + /// Required governance metadata (ADR-153 privacy requirement). + pub consent: ConsentMode, +} + +impl MeasurementSetupParams { + /// Boundary validation: range checks plus role/consent coherence. + pub fn validate(&self) -> Result<(), BfError> { + if self.period_ms < MIN_PERIOD_MS || self.period_ms > MAX_PERIOD_MS { + return Err(BfError::InvalidPeriod { + period_ms: self.period_ms, + }); + } + if self.burst_instances == 0 || self.burst_instances > MAX_BURST_INSTANCES { + return Err(BfError::InvalidBurstInstances { + count: self.burst_instances, + }); + } + let has_tx = self.initiator_role.is_transmitter() || self.responder_role.is_transmitter(); + let has_rx = self.initiator_role.is_receiver() || self.responder_role.is_receiver(); + if !has_tx || !has_rx { + return Err(BfError::InvalidTransceiverRoles); + } + if self.consent == ConsentMode::Disabled { + return Err(BfError::SensingDisabledByPolicy); + } + Ok(()) + } +} + +/// Capability advertisement for capability negotiation (ADR-153): no +/// hardcoded ESP32 assumptions in the future-silicon path. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +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, +} + +impl SensingCapabilities { + /// Permissive capability set for simulation and tests. + pub fn sim_full() -> Self { + Self { + sub_7_ghz: true, + dmg: false, + edmg: false, + csi_report: true, + threshold_reporting: true, + sensing_by_proxy: true, + max_bandwidth_mhz: 160, + max_period_ms: MAX_PERIOD_MS, + max_active_setups: 8, + } + } + + /// What today's opportunistic ESP32 CSI extraction (ADR-018/ADR-028) can + /// honor when mapped through [`crate::ieee80211bf::transport::OpportunisticCsiBridge`]. + pub fn esp32_opportunistic() -> Self { + Self { + sub_7_ghz: true, + dmg: false, + edmg: false, + csi_report: true, + threshold_reporting: true, + sensing_by_proxy: false, + max_bandwidth_mhz: 40, + max_period_ms: 60_000, + max_active_setups: 4, + } + } + + /// Evaluate setup parameters against this capability set; `Err` carries + /// the protocol-level rejection status to return to the peer. + pub fn evaluate(&self, params: &MeasurementSetupParams) -> Result<(), SetupStatus> { + if !self.sub_7_ghz || !self.csi_report { + return Err(SetupStatus::RejectedUnsupportedParams); + } + if bandwidth_mhz(params.bandwidth) > self.max_bandwidth_mhz { + return Err(SetupStatus::RejectedUnsupportedParams); + } + if params.period_ms > self.max_period_ms { + return Err(SetupStatus::RejectedUnsupportedParams); + } + if matches!(params.reporting, ReportingConfig::ThresholdBased(_)) + && !self.threshold_reporting + { + return Err(SetupStatus::RejectedUnsupportedParams); + } + Ok(()) + } +} + +/// Status carried by a sensing measurement setup response. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum SetupStatus { + Accepted, + RejectedUnsupportedParams, + RejectedSetupIdCollision, + RejectedIncompatibleProfile, + RejectedByPolicy, + RejectedCapacity, +} diff --git a/v2/crates/wifi-densepose-hardware/src/lib.rs b/v2/crates/wifi-densepose-hardware/src/lib.rs index 63c45d1b..0c414170 100644 --- a/v2/crates/wifi-densepose-hardware/src/lib.rs +++ b/v2/crates/wifi-densepose-hardware/src/lib.rs @@ -40,6 +40,12 @@ mod csi_frame; mod error; pub mod esp32; mod esp32_parser; +// ADR-153: IEEE 802.11bf-2025 forward-compatibility protocol model +// (sensing setup / measurement instance / report / SBP / termination). +// Simulation-tested; no commodity silicon implements the standard yet — +// the OpportunisticCsiBridge maps today's ESP32 CSI extraction onto the +// standardized report path until an OTA binding exists. +pub mod ieee80211bf; pub mod sync_packet; // ADR-081: Rust mirror of the firmware radio abstraction layer (L1) and diff --git a/v2/crates/wifi-densepose-train/src/error.rs b/v2/crates/wifi-densepose-train/src/error.rs index 05e3651d..2e44805d 100644 --- a/v2/crates/wifi-densepose-train/src/error.rs +++ b/v2/crates/wifi-densepose-train/src/error.rs @@ -11,7 +11,8 @@ //! TrainError (top-level) //! ├── ConfigError (config validation / file loading) //! ├── DatasetError (data loading, I/O, format) -//! └── SubcarrierError (frequency-axis resampling) +//! ├── SubcarrierError (frequency-axis resampling) +//! └── MaeError (MAE patchify / masking — ADR-152 §2.3) //! ``` use std::path::PathBuf; @@ -44,6 +45,10 @@ pub enum TrainError { #[error("Dataset error: {0}")] Dataset(#[from] DatasetError), + /// A MAE pretraining patchify / masking error (ADR-152 §2.3). + #[error("MAE pretraining error: {0}")] + Mae(#[from] MaeError), + /// JSON (de)serialization error. #[error("JSON error: {0}")] Json(#[from] serde_json::Error), @@ -373,3 +378,73 @@ impl SubcarrierError { SubcarrierError::NumericalError(msg.into()) } } + +// --------------------------------------------------------------------------- +// MaeError +// --------------------------------------------------------------------------- + +/// Errors produced by the MAE pretraining patchify / masking functions +/// ([`crate::mae`], ADR-152 §2.3). +#[derive(Debug, Error)] +pub enum MaeError { + /// The flat window buffer does not match the declared `time × subc` shape. + #[error( + "Window length {actual} does not match time × subcarriers = \ + {time} × {subc} = {expected}" + )] + WindowShapeMismatch { + /// Declared time dimension. + time: usize, + /// Declared subcarrier dimension. + subc: usize, + /// Expected buffer length (`time * subc`). + expected: usize, + /// Actual buffer length. + actual: usize, + }, + + /// A patch dimension is larger than the window along that axis. + #[error("Patch {axis} extent {patch} exceeds window {axis} extent {window}")] + PatchExceedsWindow { + /// Axis name (`"time"` or `"subcarrier"`). + axis: &'static str, + /// Patch extent along the axis. + patch: usize, + /// Window extent along the axis. + window: usize, + }, + + /// The window is not an exact multiple of the patch extent along an axis. + /// + /// Patchification never silently truncates; crop the window to `crop` + /// (the largest divisible extent) or change the patch size. + #[error( + "Window {axis} extent {window} is not divisible by patch {axis} extent \ + {patch} (remainder {remainder}); crop the window to {crop} or change \ + the patch size" + )] + NotDivisible { + /// Axis name (`"time"` or `"subcarrier"`). + axis: &'static str, + /// Window extent along the axis. + window: usize, + /// Patch extent along the axis. + patch: usize, + /// `window % patch`. + remainder: usize, + /// Largest divisible extent (`window - remainder`). + crop: usize, + }, + + /// A NaN or ±inf CSI value was found; corrupted input must be cleaned + /// upstream, never masked over. + #[error("Non-finite CSI value {value} at (t={row}, sc={col})")] + NonFiniteValue { + /// Time index of the offending value. + row: usize, + /// Subcarrier index of the offending value. + col: usize, + /// The non-finite value itself. + value: f32, + }, +} diff --git a/v2/crates/wifi-densepose-train/src/lib.rs b/v2/crates/wifi-densepose-train/src/lib.rs index 0b72852c..6c9fef6d 100644 --- a/v2/crates/wifi-densepose-train/src/lib.rs +++ b/v2/crates/wifi-densepose-train/src/lib.rs @@ -49,11 +49,13 @@ pub mod domain; pub mod error; pub mod eval; pub mod geometry; +pub mod mae; pub mod rapid_adapt; pub mod ruview_metrics; pub mod signal_features; pub mod subcarrier; pub mod virtual_aug; +pub mod wiflow_std; // The following modules use `tch` (PyTorch Rust bindings) for GPU-accelerated // training and are only compiled when the `tch-backend` feature is enabled. @@ -78,7 +80,7 @@ pub use config::TrainingConfig; pub use dataset::{ CsiDataset, CsiSample, DataLoader, MmFiDataset, SyntheticConfig, SyntheticCsiDataset, }; -pub use error::{ConfigError, DatasetError, SubcarrierError, TrainError}; +pub use error::{ConfigError, DatasetError, MaeError, SubcarrierError, TrainError}; // TrainResult is the generic Result alias from error.rs; the concrete // TrainResult struct from trainer.rs is accessed via trainer::TrainResult. pub use error::TrainResult as TrainResultAlias; @@ -86,6 +88,14 @@ pub use subcarrier::{ compute_interp_weights, interpolate_subcarriers, select_subcarriers_by_variance, }; +// ADR-152 §2.3 — UNSW MAE pretraining recipe re-exports. +pub use mae::{patchify, random_mask, unpatchify, MaePretrainConfig, MaskIndices, PatchGrid}; + +// ADR-152 §2.2 — WiFlow-STD (DY2434) spatio-temporal-decoupled pose model. +pub use wiflow_std::WiFlowStdConfig; +#[cfg(feature = "tch-backend")] +pub use wiflow_std::WiFlowStdModel; + // MERIDIAN (ADR-027) re-exports. pub use domain::{AdversarialSchedule, DomainClassifier, DomainFactorizer, GradientReversalLayer}; pub use eval::CrossDomainEvaluator; diff --git a/v2/crates/wifi-densepose-train/src/mae.rs b/v2/crates/wifi-densepose-train/src/mae.rs new file mode 100644 index 00000000..dd026d65 --- /dev/null +++ b/v2/crates/wifi-densepose-train/src/mae.rs @@ -0,0 +1,379 @@ +//! Masked-autoencoder (MAE) pretraining recipe for the ADR-150 RF foundation +//! encoder — ADR-152 §2.3 (amends ADR-150 §2.3). +//! +//! Implements the *measured* tokenization recipe from the UNSW MAE pretraining +//! study (arXiv [2511.18792](https://arxiv.org/abs/2511.18792), Nov 2025), the +//! largest heterogeneous CSI pretraining run to date (1,320,892 samples, 14 +//! public datasets, 4 devices, 2.4/5/6 GHz, 20–160 MHz): +//! +//! - **80% masking ratio** over the patch grid. +//! - **Small (30, 3) patches** — 30 time steps × 3 subcarriers — measured +//! **+4.7%** over (40, 5) patches by preserving fine temporal dynamics. +//! - Encoder capacity stays **ViT-Small-class (~15M params)**: ViT-Base adds +//! only +0.4–0.9% over ViT-Small in-study, corroborating ADR-150's own +//! finding that capacity hurts cross-subject transfer. +//! - Unseen-domain performance scales **log-linearly with pretraining data, +//! unsaturated at 1.3M samples** — data aggregation outranks architecture +//! work (ADR-152 §2.3). +//! +//! This module provides the GPU-free half of the recipe: configuration, +//! patchification, and deterministic random masking. The (future, ADR-150) +//! encoder consumes [`PatchGrid`] + [`MaskIndices`] to compute the masked +//! reconstruction loss (`L_masked_csi` in ADR-150 §2.3's loss stack). +//! +//! ## Axis convention +//! +//! A CSI window is `time × subcarriers`, row-major (`index = t * subc + sc`), +//! matching the crate's `[T, …, n_sc]` dataset layout (time first, subcarriers +//! last) and the UNSW "(30 time steps, 3 subcarriers)" patch framing. Patches +//! are indexed row-major over the patch grid (`p = pt * n_patches_subc + ps`), +//! and values within a patch are row-major time-major +//! (`local = lt * patch_subc + lsc`). +//! +//! ## Divisibility policy: error, never truncate +//! +//! Window dimensions **must** be exact multiples of the patch dimensions. +//! Non-divisible shapes return [`MaeError::NotDivisible`] instead of silently +//! truncating trailing samples (this crate never silently drops data). The +//! error names the largest divisible crop; use +//! [`MaePretrainConfig::cropped_window_shape`] to compute it and crop +//! explicitly before calling [`patchify`]. +//! +//! ## Example +//! +//! ```rust +//! use wifi_densepose_train::mae::MaePretrainConfig; +//! +//! let cfg = MaePretrainConfig::default(); // 0.80 masking, (30, 3) patches +//! cfg.validate().expect("default recipe is valid"); +//! +//! // 90 frames × 54 subcarriers → a 3 × 18 grid of (30, 3) patches. +//! let window = vec![0.25_f32; 90 * 54]; +//! let (grid, mask) = cfg.mask_window(&window, 90, 54).unwrap(); +//! assert_eq!(grid.n_patches(), 54); +//! assert_eq!(mask.masked.len(), 43); // round(0.80 * 54) +//! assert_eq!(mask.visible.len(), 11); +//! ``` + +use serde::{Deserialize, Serialize}; + +use crate::error::{ConfigError, MaeError}; +use crate::virtual_aug::Xorshift64; + +// --------------------------------------------------------------------------- +// MaePretrainConfig +// --------------------------------------------------------------------------- + +/// Hyper-parameters for masked-CSI pretraining (ADR-152 §2.3). +/// +/// Defaults are the measured-optimal UNSW recipe (arXiv 2511.18792); change +/// them only with benchmark evidence. Serializable so the recipe is recorded +/// in checkpoint metadata alongside [`crate::config::TrainingConfig`]. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct MaePretrainConfig { + /// Fraction of patches hidden from the encoder, in `(0, 1)`. + /// + /// Default: **0.80** (UNSW measured optimum). + pub mask_ratio: f64, + + /// Patch extent along the time axis, in frames. Default: **30**. + pub patch_time: usize, + + /// Patch extent along the subcarrier axis. Default: **3**. + pub patch_subc: usize, + + /// Base seed for the deterministic mask sampler. Default: **42**. + /// + /// For per-sample masks derive a child seed (e.g. + /// `seed ^ sample_idx as u64`) and pass it to [`random_mask`]; reusing one + /// seed yields the identical mask for every sample. + pub seed: u64, +} + +impl Default for MaePretrainConfig { + fn default() -> Self { + MaePretrainConfig { + mask_ratio: 0.80, + patch_time: 30, + patch_subc: 3, + seed: 42, + } + } +} + +impl MaePretrainConfig { + /// Validate the shape-independent fields. + /// + /// # Validated invariants + /// + /// - `mask_ratio` must be strictly inside `(0, 1)` and finite. + /// - `patch_time` and `patch_subc` must be at least 1. + pub fn validate(&self) -> Result<(), ConfigError> { + if !self.mask_ratio.is_finite() || self.mask_ratio <= 0.0 || self.mask_ratio >= 1.0 { + return Err(ConfigError::invalid_value( + "mask_ratio", + format!("must be in (0.0, 1.0), got {}", self.mask_ratio), + )); + } + if self.patch_time == 0 { + return Err(ConfigError::invalid_value("patch_time", "must be >= 1")); + } + if self.patch_subc == 0 { + return Err(ConfigError::invalid_value("patch_subc", "must be >= 1")); + } + Ok(()) + } + + /// Check this recipe against a concrete `time × subc` window shape. + /// + /// Errors if a patch dimension exceeds the window or if either axis is + /// not an exact multiple of the patch extent (divisibility policy above). + pub fn validate_for_window(&self, time: usize, subc: usize) -> Result<(), MaeError> { + check_axis("time", time, self.patch_time)?; + check_axis("subcarrier", subc, self.patch_subc)?; + Ok(()) + } + + /// Largest `(time, subc)` crop of the given window that is exactly + /// divisible by the patch dimensions. Either component may be 0 when the + /// window is smaller than one patch. + #[must_use] + pub fn cropped_window_shape(&self, time: usize, subc: usize) -> (usize, usize) { + ( + (time / self.patch_time) * self.patch_time, + (subc / self.patch_subc) * self.patch_subc, + ) + } + + /// Number of patches a `time × subc` window yields under this recipe. + pub fn num_patches(&self, time: usize, subc: usize) -> Result { + self.validate_for_window(time, subc)?; + Ok((time / self.patch_time) * (subc / self.patch_subc)) + } + + /// Exact number of masked patches for a grid of `n_patches`: + /// `round(mask_ratio * n_patches)`, clamped to `[0, n_patches]`. + #[must_use] + pub fn num_masked(&self, n_patches: usize) -> usize { + ((self.mask_ratio * n_patches as f64).round() as usize).min(n_patches) + } + + /// Patchify `window` and draw the deterministic random mask in one step, + /// using `self.seed`. See [`patchify`] and [`random_mask`]. + pub fn mask_window( + &self, + window: &[f32], + time: usize, + subc: usize, + ) -> Result<(PatchGrid, MaskIndices), MaeError> { + let grid = patchify(window, time, subc, self)?; + let mask = random_mask(grid.n_patches(), self.mask_ratio, self.seed); + Ok((grid, mask)) + } +} + +// --------------------------------------------------------------------------- +// PatchGrid / MaskIndices +// --------------------------------------------------------------------------- + +/// A CSI window decomposed into non-overlapping `patch_time × patch_subc` +/// patches (see the module-level axis convention). +#[derive(Debug, Clone, PartialEq)] +pub struct PatchGrid { + /// Patch extent along the time axis. + pub patch_time: usize, + /// Patch extent along the subcarrier axis. + pub patch_subc: usize, + /// Number of patch rows (`time / patch_time`). + pub n_patches_time: usize, + /// Number of patch columns (`subc / patch_subc`). + pub n_patches_subc: usize, + /// Flattened patches, row-major over the grid; each inner `Vec` is one + /// patch of length `patch_time * patch_subc`, row-major time-major. + pub patches: Vec>, +} + +impl PatchGrid { + /// Total number of patches in the grid. + #[must_use] + pub fn n_patches(&self) -> usize { + self.n_patches_time * self.n_patches_subc + } + + /// Number of scalar values per patch. + #[must_use] + pub fn patch_len(&self) -> usize { + self.patch_time * self.patch_subc + } + + /// Window shape `(time, subc)` this grid reconstructs to. + #[must_use] + pub fn window_shape(&self) -> (usize, usize) { + ( + self.n_patches_time * self.patch_time, + self.n_patches_subc * self.patch_subc, + ) + } +} + +/// Sorted, disjoint patch-index sets produced by [`random_mask`]. Together +/// they cover `0..n_patches` exactly. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MaskIndices { + /// Indices of patches hidden from the encoder (`round(ratio * n)` of them). + pub masked: Vec, + /// Indices of patches the encoder sees. + pub visible: Vec, +} + +// --------------------------------------------------------------------------- +// patchify / unpatchify +// --------------------------------------------------------------------------- + +/// Decompose a row-major `time × subc` CSI window into the patch grid defined +/// by `cfg`. +/// +/// # Errors +/// +/// - [`MaeError::WindowShapeMismatch`] if `window.len() != time * subc`. +/// - [`MaeError::PatchExceedsWindow`] / [`MaeError::NotDivisible`] per the +/// module-level divisibility policy. +/// - [`MaeError::NonFiniteValue`] on the first NaN/±inf encountered — +/// corrupted CSI must be cleaned upstream, never masked over (cf. the +/// WiFlow-STD NaN-poisoning incident, ADR-152 §2.2). +pub fn patchify( + window: &[f32], + time: usize, + subc: usize, + cfg: &MaePretrainConfig, +) -> Result { + let expected = time * subc; + if window.len() != expected { + return Err(MaeError::WindowShapeMismatch { + time, + subc, + expected, + actual: window.len(), + }); + } + cfg.validate_for_window(time, subc)?; + if let Some(idx) = window.iter().position(|v| !v.is_finite()) { + return Err(MaeError::NonFiniteValue { + row: idx / subc, + col: idx % subc, + value: window[idx], + }); + } + + let n_patches_time = time / cfg.patch_time; + let n_patches_subc = subc / cfg.patch_subc; + let mut patches = Vec::with_capacity(n_patches_time * n_patches_subc); + for pt in 0..n_patches_time { + for ps in 0..n_patches_subc { + let mut patch = Vec::with_capacity(cfg.patch_time * cfg.patch_subc); + for lt in 0..cfg.patch_time { + let t = pt * cfg.patch_time + lt; + let row_start = t * subc + ps * cfg.patch_subc; + patch.extend_from_slice(&window[row_start..row_start + cfg.patch_subc]); + } + patches.push(patch); + } + } + + Ok(PatchGrid { + patch_time: cfg.patch_time, + patch_subc: cfg.patch_subc, + n_patches_time, + n_patches_subc, + patches, + }) +} + +/// Reassemble the full row-major `time × subc` window from a [`PatchGrid`]. +/// Exact inverse of [`patchify`]. +#[must_use] +pub fn unpatchify(grid: &PatchGrid) -> Vec { + unpatchify_select(grid, None, 0.0) +} + +/// Reassemble the window keeping only the patches listed in `visible`; +/// every other patch's region is filled with `fill` (the standard MAE +/// "visible tokens + mask token" view of the input). +#[must_use] +pub fn unpatchify_visible(grid: &PatchGrid, visible: &[usize], fill: f32) -> Vec { + unpatchify_select(grid, Some(visible), fill) +} + +fn unpatchify_select(grid: &PatchGrid, keep: Option<&[usize]>, fill: f32) -> Vec { + let (time, subc) = grid.window_shape(); + let mut window = vec![fill; time * subc]; + for (p, patch) in grid.patches.iter().enumerate() { + if let Some(keep) = keep { + if !keep.contains(&p) { + continue; + } + } + let pt = p / grid.n_patches_subc; + let ps = p % grid.n_patches_subc; + for lt in 0..grid.patch_time { + let t = pt * grid.patch_time + lt; + let row_start = t * subc + ps * grid.patch_subc; + let local_start = lt * grid.patch_subc; + window[row_start..row_start + grid.patch_subc] + .copy_from_slice(&patch[local_start..local_start + grid.patch_subc]); + } + } + window +} + +// --------------------------------------------------------------------------- +// random_mask +// --------------------------------------------------------------------------- + +/// Draw a deterministic random mask over `n_patches` patches. +/// +/// Exactly `round(mask_ratio * n_patches)` patches (clamped to +/// `[0, n_patches]`) are masked, chosen by a seeded Fisher–Yates shuffle +/// ([`Xorshift64`]), so the same `(n_patches, mask_ratio, seed)` triple always +/// yields the same mask. Both index lists are sorted ascending, disjoint, and +/// together cover `0..n_patches`. +#[must_use] +pub fn random_mask(n_patches: usize, mask_ratio: f64, seed: u64) -> MaskIndices { + let n_masked = ((mask_ratio * n_patches as f64).round() as usize).min(n_patches); + let mut order: Vec = (0..n_patches).collect(); + let mut rng = Xorshift64::new(seed); + for i in (1..n_patches).rev() { + let j = (rng.next_u64() % (i as u64 + 1)) as usize; + order.swap(i, j); + } + let mut masked: Vec = order[..n_masked].to_vec(); + let mut visible: Vec = order[n_masked..].to_vec(); + masked.sort_unstable(); + visible.sort_unstable(); + MaskIndices { masked, visible } +} + +// --------------------------------------------------------------------------- +// helpers +// --------------------------------------------------------------------------- + +fn check_axis(axis: &'static str, window: usize, patch: usize) -> Result<(), MaeError> { + if patch > window { + return Err(MaeError::PatchExceedsWindow { + axis, + patch, + window, + }); + } + let remainder = window % patch; + if remainder != 0 { + return Err(MaeError::NotDivisible { + axis, + window, + patch, + remainder, + crop: window - remainder, + }); + } + Ok(()) +} diff --git a/v2/crates/wifi-densepose-train/src/wiflow_std/config.rs b/v2/crates/wifi-densepose-train/src/wiflow_std/config.rs new file mode 100644 index 00000000..ed4e020b --- /dev/null +++ b/v2/crates/wifi-densepose-train/src/wiflow_std/config.rs @@ -0,0 +1,476 @@ +//! Configuration and pure-Rust shape/parameter math for WiFlow-STD +//! (ADR-152 §2.2). See the [module docs](crate::wiflow_std) for provenance. +//! +//! Everything here compiles without the `tch-backend` feature so the +//! architecture's invariants (parameter count, output shapes, divisibility +//! constraints) are unit-testable under `--no-default-features`. The +//! 15-keypoint default must yield exactly **2,225,042** parameters — the +//! count verified against the upstream reference (`RESULTS.md`). + +use serde::{Deserialize, Serialize}; + +use crate::error::ConfigError; + +/// TCN kernel size — fixed at 3 in the reference architecture. +pub const TCN_KERNEL: usize = 3; + +/// Dropout used inside the 2-D conv blocks (`Dropout2d`). The reference +/// hardcodes 0.3 in `convnet.py` (the model-level `dropout` argument is only +/// forwarded to the TCN), so it is a constant here rather than a config field. +pub const CONV_BLOCK_DROPOUT: f64 = 0.3; + +// --------------------------------------------------------------------------- +// WiFlowStdConfig +// --------------------------------------------------------------------------- + +/// Hyper-parameters for the WiFlow-STD pose model (ADR-152 §2.2). +/// +/// Defaults reproduce the verified upstream architecture exactly (2,225,042 +/// parameters, 15 keypoints). For RuView's ESP32 17-keypoint eval set +/// (ADR-152 §2.2(b)) use [`WiFlowStdConfig::for_keypoints`]`(17)` — the +/// keypoint count only changes the final adaptive pooling, not the parameter +/// count, so retrained 15-keypoint weights remain shape-compatible. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct WiFlowStdConfig { + /// CSI input feature dimension (subcarriers × antenna paths flattened). + /// Must be divisible by [`Self::tcn_groups`]. Default: **540**. + pub subcarriers: usize, + + /// Temporal window length in CSI frames. Default: **20**. + pub window: usize, + + /// Output channels of each TCN level (dilation doubles per level: + /// 1, 2, 4, 8, …). Every entry must be divisible by [`Self::tcn_groups`]. + /// Default: **[540, 440, 340, 240]** — the `models/` code values, *not* + /// upstream `config.py`'s stale `[480, 360, 240]`. + pub tcn_channels: Vec, + + /// Group count for the depthwise-grouped TCN convolutions. The reference + /// hardcodes **20**; exposed so non-540 subcarrier layouts can keep the + /// divisibility invariant. Default: **20**. + pub tcn_groups: usize, + + /// Output channels of the 2-D conv encoder blocks. The first entry is + /// also `ConvBlock1`'s output; each subsequent block downsamples the + /// subcarrier axis by 2. Default: **[8, 16, 32, 64]**. + pub conv_channels: Vec, + + /// Attention head groups for the dual axial attention. Must divide the + /// last entry of [`Self::conv_channels`]. Default: **8**. + pub attention_groups: usize, + + /// Number of 2-D keypoints produced. Default: **15** (upstream skeleton); + /// use **17** for RuView's COCO-skeleton ESP32 eval set. + pub keypoints: usize, + + /// Elementwise dropout probability inside the TCN blocks, in `[0, 1)`. + /// Default: **0.5** (the value used by our verified retraining run). + pub dropout: f64, +} + +impl Default for WiFlowStdConfig { + fn default() -> Self { + WiFlowStdConfig { + subcarriers: 540, + window: 20, + tcn_channels: vec![540, 440, 340, 240], + tcn_groups: 20, + conv_channels: vec![8, 16, 32, 64], + attention_groups: 8, + keypoints: 15, + dropout: 0.5, + } + } +} + +impl WiFlowStdConfig { + /// Default architecture with a different keypoint count (e.g. 17 for the + /// ESP32 COCO-skeleton eval set, ADR-152 §2.2(b)). + pub fn for_keypoints(keypoints: usize) -> Self { + WiFlowStdConfig { + keypoints, + ..Self::default() + } + } + + /// Validate all architectural invariants. + /// + /// # Errors + /// + /// Returns [`ConfigError::InvalidValue`] naming the offending field. + pub fn validate(&self) -> Result<(), ConfigError> { + if self.subcarriers == 0 { + return Err(ConfigError::invalid_value("subcarriers", "must be >= 1")); + } + if self.window == 0 { + return Err(ConfigError::invalid_value("window", "must be >= 1")); + } + if self.tcn_groups == 0 { + return Err(ConfigError::invalid_value("tcn_groups", "must be >= 1")); + } + if self.subcarriers % self.tcn_groups != 0 { + return Err(ConfigError::invalid_value( + "subcarriers", + format!( + "{} is not divisible by tcn_groups={} (grouped conv requirement)", + self.subcarriers, self.tcn_groups + ), + )); + } + if self.tcn_channels.is_empty() { + return Err(ConfigError::invalid_value( + "tcn_channels", + "must contain at least one level", + )); + } + for (i, &c) in self.tcn_channels.iter().enumerate() { + if c == 0 || c % self.tcn_groups != 0 { + return Err(ConfigError::invalid_value( + "tcn_channels", + format!( + "level {i} has {c} channels; must be > 0 and divisible by tcn_groups={}", + self.tcn_groups + ), + )); + } + } + if self.conv_channels.is_empty() { + return Err(ConfigError::invalid_value( + "conv_channels", + "must contain at least one block", + )); + } + if self.conv_channels.iter().any(|&c| c == 0) { + return Err(ConfigError::invalid_value( + "conv_channels", + "all blocks must have > 0 channels", + )); + } + let c_last = *self.conv_channels.last().expect("non-empty checked above"); + if self.attention_groups == 0 || c_last % self.attention_groups != 0 { + return Err(ConfigError::invalid_value( + "attention_groups", + format!( + "{} must be >= 1 and divide the last conv channel count {c_last}", + self.attention_groups + ), + )); + } + if c_last < 2 || c_last % 2 != 0 { + return Err(ConfigError::invalid_value( + "conv_channels", + format!("last block has {c_last} channels; decoder needs an even count >= 2"), + )); + } + if self.keypoints == 0 { + return Err(ConfigError::invalid_value("keypoints", "must be >= 1")); + } + if !self.dropout.is_finite() || !(0.0..1.0).contains(&self.dropout) { + return Err(ConfigError::invalid_value( + "dropout", + format!("{} is outside [0, 1)", self.dropout), + )); + } + Ok(()) + } + + // ----------------------------------------------------------------------- + // Shape inference + // ----------------------------------------------------------------------- + + /// Channel count produced by the TCN stack (last TCN level). This is the + /// *width* of the image-like tensor fed to the 2-D encoder. + pub fn tcn_output_channels(&self) -> usize { + *self.tcn_channels.last().unwrap_or(&0) + } + + /// Width of the encoder feature map after the strided conv blocks. + /// + /// `ConvBlock1` preserves width; each `AsymmetricConvBlock` applies a + /// `(1, 3)` kernel with stride `(1, 2)` and padding `(0, 1)`: + /// `w → (w - 1) / 2 + 1`. Default: 240 → 120 → 60 → 30 → **15**. + pub fn feature_width(&self) -> usize { + let mut w = self.tcn_output_channels(); + for _ in &self.conv_channels { + w = (w.saturating_sub(1)) / 2 + 1; + } + w + } + + /// Output tensor shape `(batch, keypoints, 2)`. The adaptive average pool + /// maps the feature height to `keypoints` regardless of its size, so the + /// keypoint count is free (15 and 17 share identical weights). + pub fn output_shape(&self, batch: usize) -> (usize, usize, usize) { + (batch, self.keypoints, 2) + } + + // ----------------------------------------------------------------------- + // Parameter-count formula + // ----------------------------------------------------------------------- + + /// Total trainable parameter count, derived layer-by-layer from the + /// architecture (BatchNorm weight+bias counted; running stats are buffers + /// and excluded, matching PyTorch's `numel` convention). + /// + /// Pins the port against the verified reference: the 15-keypoint default + /// must equal **2,225,042** (`RESULTS.md` artifact verification). + pub fn param_count(&self) -> usize { + let mut total = 0; + + // TCN stack. + let mut c_in = self.subcarriers; + for &c_out in &self.tcn_channels { + total += tcn_block_params(c_in, c_out, TCN_KERNEL, self.tcn_groups); + c_in = c_out; + } + + // ConvBlock1 (1 → conv_channels[0]) + asymmetric blocks. Both block + // kinds have identical parameter shapes (stride changes nothing). + let mut c_in = 1; + total += conv_block_params(c_in, self.conv_channels[0]); + c_in = self.conv_channels[0]; + for &c_out in &self.conv_channels { + total += conv_block_params(c_in, c_out); + c_in = c_out; + } + + // Dual axial attention: width axis + height axis, both c_in → c_in. + total += 2 * axial_attention_params(c_in, self.attention_groups); + + // Decoder: 3×3 conv (c → c/2) + BN + 1×1 conv (c/2 → 2) + BN. + total += decoder_params(c_in); + + total + } +} + +// --------------------------------------------------------------------------- +// Per-component parameter formulas +// --------------------------------------------------------------------------- + +/// One `InnerGroupedTemporalBlock`: two (depthwise-grouped conv → BN → +/// pointwise conv → BN) stages plus a 1×1 + BN residual projection when the +/// channel count changes. All convs are bias-free. +fn tcn_block_params(c_in: usize, c_out: usize, k: usize, groups: usize) -> usize { + let grouped1 = c_in * (c_in / groups) * k; // depthwise-grouped, c_in → c_in + let bn1g = 2 * c_in; + let pw1 = c_out * c_in; // pointwise 1×1 + let bn1p = 2 * c_out; + let grouped2 = c_out * (c_out / groups) * k; + let bn2g = 2 * c_out; + let pw2 = c_out * c_out; + let bn2p = 2 * c_out; + let downsample = if c_in != c_out { + c_in * c_out + 2 * c_out + } else { + 0 + }; + grouped1 + bn1g + pw1 + bn1p + grouped2 + bn2g + pw2 + bn2p + downsample +} + +/// One `ConvBlock1` / `AsymmetricConvBlock`: three (1, 3) convs **with bias** +/// + BN each, plus a bias-free 1×1 + BN residual projection. +fn conv_block_params(c_in: usize, c_out: usize) -> usize { + let conv1 = c_out * c_in * 3 + c_out; + let conv_rest = 2 * (c_out * c_out * 3 + c_out); + let bns = 3 * 2 * c_out; + let downsample = c_in * c_out + 2 * c_out; + conv1 + conv_rest + bns + downsample +} + +/// One `AxialAttention` axis: bias-free 1×1 qkv conv (c → 3c), BN over the +/// 3c qkv channels, BN over the `groups` similarity maps, BN over the output. +fn axial_attention_params(c: usize, groups: usize) -> usize { + let qkv = c * 3 * c; + let bn_qkv = 2 * (3 * c); + let bn_similarity = 2 * groups; + let bn_output = 2 * c; + qkv + bn_qkv + bn_similarity + bn_output +} + +/// Decoder: `Conv2d(c → c/2, 3×3, bias)` + BN + `Conv2d(c/2 → 2, 1×1, bias)` +/// + BN. +fn decoder_params(c: usize) -> usize { + let mid = c / 2; + let conv1 = mid * c * 9 + mid; + let bn1 = 2 * mid; + let conv2 = 2 * mid + 2; + let bn2 = 2 * 2; + conv1 + bn1 + conv2 + bn2 +} + +// --------------------------------------------------------------------------- +// Tests (pure Rust — run under --no-default-features) +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + /// Reference parameter count verified against the upstream checkpoint + /// and `torchinfo` (benchmarks/wiflow-std/RESULTS.md, 2026-06-10). + const REFERENCE_PARAMS: usize = 2_225_042; + + #[test] + fn default_config_is_valid() { + WiFlowStdConfig::default() + .validate() + .expect("default config must validate"); + } + + #[test] + fn default_param_count_matches_verified_reference() { + assert_eq!(WiFlowStdConfig::default().param_count(), REFERENCE_PARAMS); + } + + #[test] + fn param_count_is_independent_of_keypoints() { + // The keypoint count only changes the parameter-free adaptive pool, + // so 15- and 17-keypoint variants share identical weights. + let kp17 = WiFlowStdConfig::for_keypoints(17); + kp17.validate().expect("17-keypoint config must validate"); + assert_eq!(kp17.param_count(), REFERENCE_PARAMS); + } + + #[test] + fn per_component_breakdown_matches_hand_calculation() { + // TCN levels (hand-verified against the reference layer shapes). + assert_eq!(tcn_block_params(540, 540, 3, 20), 675_000); + assert_eq!(tcn_block_params(540, 440, 3, 20), 746_180); + assert_eq!(tcn_block_params(440, 340, 3, 20), 464_780); + assert_eq!(tcn_block_params(340, 240, 3, 20), 249_380); + // Conv encoder. + assert_eq!(conv_block_params(1, 8), 504); + assert_eq!(conv_block_params(8, 8), 728); + assert_eq!(conv_block_params(8, 16), 2_224); + assert_eq!(conv_block_params(16, 32), 8_544); + assert_eq!(conv_block_params(32, 64), 33_472); + // Attention + decoder. + assert_eq!(axial_attention_params(64, 8), 12_816); + assert_eq!(decoder_params(64), 18_598); + } + + #[test] + fn output_shape_default_and_esp32() { + assert_eq!(WiFlowStdConfig::default().output_shape(4), (4, 15, 2)); + assert_eq!( + WiFlowStdConfig::for_keypoints(17).output_shape(1), + (1, 17, 2) + ); + } + + #[test] + fn feature_width_default_is_15() { + // 240 → 120 → 60 → 30 → 15 (four stride-(1,2) blocks). + assert_eq!(WiFlowStdConfig::default().feature_width(), 15); + } + + #[test] + fn tcn_output_channels_default_is_240() { + assert_eq!(WiFlowStdConfig::default().tcn_output_channels(), 240); + } + + #[test] + fn rejects_subcarriers_not_divisible_by_groups() { + let cfg = WiFlowStdConfig { + subcarriers: 541, + ..Default::default() + }; + assert!(cfg.validate().is_err()); + } + + #[test] + fn rejects_zero_dimensions() { + for cfg in [ + WiFlowStdConfig { + subcarriers: 0, + ..Default::default() + }, + WiFlowStdConfig { + window: 0, + ..Default::default() + }, + WiFlowStdConfig { + keypoints: 0, + ..Default::default() + }, + WiFlowStdConfig { + tcn_groups: 0, + ..Default::default() + }, + ] { + assert!(cfg.validate().is_err(), "expected rejection: {cfg:?}"); + } + } + + #[test] + fn rejects_empty_or_indivisible_tcn_channels() { + let empty = WiFlowStdConfig { + tcn_channels: vec![], + ..Default::default() + }; + assert!(empty.validate().is_err()); + + let indivisible = WiFlowStdConfig { + tcn_channels: vec![540, 441], + ..Default::default() + }; + assert!(indivisible.validate().is_err()); + } + + #[test] + fn rejects_bad_conv_channels() { + let empty = WiFlowStdConfig { + conv_channels: vec![], + ..Default::default() + }; + assert!(empty.validate().is_err()); + + let zero = WiFlowStdConfig { + conv_channels: vec![8, 0, 64], + ..Default::default() + }; + assert!(zero.validate().is_err()); + + // Odd last channel breaks the c → c/2 decoder split. + let odd_last = WiFlowStdConfig { + conv_channels: vec![8, 16, 33], + attention_groups: 1, + ..Default::default() + }; + assert!(odd_last.validate().is_err()); + } + + #[test] + fn rejects_attention_group_mismatch() { + let cfg = WiFlowStdConfig { + attention_groups: 7, // 64 % 7 != 0 + ..Default::default() + }; + assert!(cfg.validate().is_err()); + let zero = WiFlowStdConfig { + attention_groups: 0, + ..Default::default() + }; + assert!(zero.validate().is_err()); + } + + #[test] + fn rejects_out_of_range_dropout() { + for d in [1.0, 1.5, -0.1, f64::NAN] { + let cfg = WiFlowStdConfig { + dropout: d, + ..Default::default() + }; + assert!(cfg.validate().is_err(), "dropout {d} must be rejected"); + } + } + + #[test] + fn serde_roundtrip_preserves_config() { + let cfg = WiFlowStdConfig::for_keypoints(17); + let json = serde_json::to_string(&cfg).expect("serialize"); + let back: WiFlowStdConfig = serde_json::from_str(&json).expect("deserialize"); + assert_eq!(back, cfg); + } +} diff --git a/v2/crates/wifi-densepose-train/src/wiflow_std/layers.rs b/v2/crates/wifi-densepose-train/src/wiflow_std/layers.rs new file mode 100644 index 00000000..d363f3f1 --- /dev/null +++ b/v2/crates/wifi-densepose-train/src/wiflow_std/layers.rs @@ -0,0 +1,314 @@ +//! Building-block layers for the WiFlow-STD model (tch backend, ADR-152 §2.2): +//! grouped causal TCN blocks, asymmetric residual conv blocks, and dual axial +//! attention. Internal to [`super::model`]; see the module docs for provenance. + +use tch::{nn, nn::Module, Tensor}; + +use super::config::{CONV_BLOCK_DROPOUT, TCN_KERNEL}; + +// --------------------------------------------------------------------------- +// GroupedTemporalBlock (TCN level) +// --------------------------------------------------------------------------- + +/// One TCN level: two (depthwise-grouped causal conv → BN → SiLU → pointwise +/// conv → BN → SiLU → dropout) stages with a residual connection (1×1 + BN +/// projection when channels change) and a final SiLU. +/// +/// Causality: each grouped conv pads by `(k-1)·dilation` and the trailing +/// padding is chomped off afterwards, exactly like the reference `Chomp1d`. +pub(super) struct GroupedTemporalBlock { + conv1_group: nn::Conv1D, + bn1_group: nn::BatchNorm, + conv1_pw: nn::Conv1D, + bn1_pw: nn::BatchNorm, + conv2_group: nn::Conv1D, + bn2_group: nn::BatchNorm, + conv2_pw: nn::Conv1D, + bn2_pw: nn::BatchNorm, + downsample: Option<(nn::Conv1D, nn::BatchNorm)>, + dropout: f64, +} + +impl GroupedTemporalBlock { + pub(super) fn new( + vs: nn::Path, + c_in: i64, + c_out: i64, + dilation: i64, + groups: i64, + dropout: f64, + ) -> Self { + let k = TCN_KERNEL as i64; + let padding = (k - 1) * dilation; + let grouped_cfg = |groups| nn::ConvConfig { + padding, + dilation, + groups, + bias: false, + ..Default::default() + }; + let pointwise_cfg = nn::ConvConfig { + bias: false, + ..Default::default() + }; + + let conv1_group = nn::conv1d(&vs / "conv1_group", c_in, c_in, k, grouped_cfg(groups)); + let bn1_group = nn::batch_norm1d(&vs / "bn1_group", c_in, Default::default()); + let conv1_pw = nn::conv1d(&vs / "conv1_pw", c_in, c_out, 1, pointwise_cfg); + let bn1_pw = nn::batch_norm1d(&vs / "bn1_pw", c_out, Default::default()); + + let conv2_group = nn::conv1d(&vs / "conv2_group", c_out, c_out, k, grouped_cfg(groups)); + let bn2_group = nn::batch_norm1d(&vs / "bn2_group", c_out, Default::default()); + let conv2_pw = nn::conv1d(&vs / "conv2_pw", c_out, c_out, 1, pointwise_cfg); + let bn2_pw = nn::batch_norm1d(&vs / "bn2_pw", c_out, Default::default()); + + let downsample = (c_in != c_out).then(|| { + ( + nn::conv1d(&vs / "ds_conv", c_in, c_out, 1, pointwise_cfg), + nn::batch_norm1d(&vs / "ds_bn", c_out, Default::default()), + ) + }); + + GroupedTemporalBlock { + conv1_group, + bn1_group, + conv1_pw, + bn1_pw, + conv2_group, + bn2_group, + conv2_pw, + bn2_pw, + downsample, + dropout, + } + } + + pub(super) fn forward_t(&self, x: &Tensor, train: bool) -> Tensor { + let res = match &self.downsample { + Some((conv, bn)) => conv.forward(x).apply_t(bn, train), + None => x.shallow_clone(), + }; + let t = x.size()[2]; + + // Stage 1: grouped causal conv (chomp trailing padding) + pointwise. + let out = self + .conv1_group + .forward(x) + .narrow(2, 0, t) // Chomp1d + .apply_t(&self.bn1_group, train) + .silu() + .apply(&self.conv1_pw) + .apply_t(&self.bn1_pw, train) + .silu() + .dropout(self.dropout, train); + + // Stage 2. + let out = self + .conv2_group + .forward(&out) + .narrow(2, 0, t) // Chomp1d + .apply_t(&self.bn2_group, train) + .silu() + .apply(&self.conv2_pw) + .apply_t(&self.bn2_pw, train) + .silu() + .dropout(self.dropout, train); + + (out + res).silu() + } +} + +// --------------------------------------------------------------------------- +// ConvBlock (ConvBlock1 / AsymmetricConvBlock) +// --------------------------------------------------------------------------- + +/// Asymmetric residual conv block: three `(1, 3)` convs (only the subcarrier +/// axis is convolved) with BN, SiLU and channel dropout, plus a 1×1 + BN +/// residual projection. `stride_w == 1` reproduces the reference `ConvBlock1`, +/// `stride_w == 2` the downsampling `AsymmetricConvBlock`. +pub(super) struct ConvBlock { + conv1: nn::Conv2D, + bn1: nn::BatchNorm, + conv2: nn::Conv2D, + bn2: nn::BatchNorm, + conv3: nn::Conv2D, + bn3: nn::BatchNorm, + ds_conv: nn::Conv2D, + ds_bn: nn::BatchNorm, +} + +impl ConvBlock { + pub(super) fn new(vs: nn::Path, c_in: i64, c_out: i64, stride_w: i64) -> Self { + let asym = |stride_w| nn::ConvConfigND::<[i64; 2]> { + stride: [1, stride_w], + padding: [0, 1], + ..Default::default() + }; + let conv1 = nn::conv(&vs / "conv1", c_in, c_out, [1, 3], asym(stride_w)); + let bn1 = nn::batch_norm2d(&vs / "bn1", c_out, Default::default()); + let conv2 = nn::conv(&vs / "conv2", c_out, c_out, [1, 3], asym(1)); + let bn2 = nn::batch_norm2d(&vs / "bn2", c_out, Default::default()); + let conv3 = nn::conv(&vs / "conv3", c_out, c_out, [1, 3], asym(1)); + let bn3 = nn::batch_norm2d(&vs / "bn3", c_out, Default::default()); + + let ds_conv = nn::conv( + &vs / "ds_conv", + c_in, + c_out, + [1, 1], + nn::ConvConfigND::<[i64; 2]> { + stride: [1, stride_w], + bias: false, + ..Default::default() + }, + ); + let ds_bn = nn::batch_norm2d(&vs / "ds_bn", c_out, Default::default()); + + ConvBlock { + conv1, + bn1, + conv2, + bn2, + conv3, + bn3, + ds_conv, + ds_bn, + } + } + + pub(super) fn forward_t(&self, x: &Tensor, train: bool) -> Tensor { + let identity = self.ds_conv.forward(x).apply_t(&self.ds_bn, train); + let out = x + .apply(&self.conv1) + .apply_t(&self.bn1, train) + .silu() + .feature_dropout(CONV_BLOCK_DROPOUT, train) // Dropout2d + .apply(&self.conv2) + .apply_t(&self.bn2, train) + .silu() + .feature_dropout(CONV_BLOCK_DROPOUT, train) + .apply(&self.conv3) + .apply_t(&self.bn3, train); + (out + identity).silu() + } +} + +// --------------------------------------------------------------------------- +// Axial attention +// --------------------------------------------------------------------------- + +/// Single-axis self-attention with BN-normalised qkv, BN-normalised +/// similarity logits and BN-normalised output. `width == true` attends along +/// the last (W) axis, otherwise along the H axis; the other spatial axis is +/// folded into the batch. +pub(super) struct AxialAttention { + qkv: nn::Conv1D, + bn_qkv: nn::BatchNorm, + bn_similarity: nn::BatchNorm, + bn_output: nn::BatchNorm, + out_planes: i64, + groups: i64, + width: bool, +} + +impl AxialAttention { + pub(super) fn new(vs: nn::Path, planes: i64, groups: i64, width: bool) -> Self { + // Reference init: N(0, sqrt(1 / in_planes)). + let qkv = nn::conv1d( + &vs / "qkv", + planes, + planes * 3, + 1, + nn::ConvConfig { + bias: false, + ws_init: nn::Init::Randn { + mean: 0.0, + stdev: (1.0 / planes as f64).sqrt(), + }, + ..Default::default() + }, + ); + let bn_qkv = nn::batch_norm1d(&vs / "bn_qkv", planes * 3, Default::default()); + let bn_similarity = nn::batch_norm2d(&vs / "bn_similarity", groups, Default::default()); + let bn_output = nn::batch_norm1d(&vs / "bn_output", planes, Default::default()); + + AxialAttention { + qkv, + bn_qkv, + bn_similarity, + bn_output, + out_planes: planes, + groups, + width, + } + } + + pub(super) fn forward_t(&self, x: &Tensor, train: bool) -> Tensor { + // Fold the non-attended spatial axis into the batch: + // width: [B,C,H,W] → [B,H,C,W]; height: [B,C,H,W] → [B,W,C,H]. + let x = if self.width { + x.permute([0, 2, 1, 3]) + } else { + x.permute([0, 3, 1, 2]) + }; + let (n, outer, c, axis) = { + let s = x.size(); + (s[0], s[1], s[2], s[3]) + }; + let flat = x.contiguous().view([n * outer, c, axis]); + + // BN-normalised qkv: [N', 3·C, axis] → grouped q, k, v. + let gp = self.out_planes / self.groups; // group planes + let qkv = flat.apply(&self.qkv).apply_t(&self.bn_qkv, train).reshape([ + n * outer, + 3, + self.groups, + gp, + axis, + ]); + let q = qkv.select(1, 0); // [N', g, gp, axis] + let k = qkv.select(1, 1); + let v = qkv.select(1, 2); + + // similarity[b,g,i,j] = Σ_c q[b,g,c,i]·k[b,g,c,j], BN over the g maps. + let logits = q.transpose(2, 3).matmul(&k); // [N', g, axis, axis] + let similarity = logits + .apply_t(&self.bn_similarity, train) + .softmax(-1, logits.kind()); + + // out[b,g,c,i] = Σ_j similarity[b,g,i,j]·v[b,g,c,j]. + let sv = v.matmul(&similarity.transpose(2, 3)); // [N', g, gp, axis] + let out = sv + .reshape([n * outer, self.out_planes, axis]) + .apply_t(&self.bn_output, train) + .view([n, outer, self.out_planes, axis]); + + // Restore [B, C, H, W]. + if self.width { + out.permute([0, 2, 1, 3]) + } else { + out.permute([0, 2, 3, 1]) + } + } +} + +/// Width-axis then height-axis axial attention (the reference +/// `DualAxialAttention`, stride 1). +pub(super) struct DualAxialAttention { + width_axis: AxialAttention, + height_axis: AxialAttention, +} + +impl DualAxialAttention { + pub(super) fn new(vs: nn::Path, planes: i64, groups: i64) -> Self { + DualAxialAttention { + width_axis: AxialAttention::new(&vs / "width", planes, groups, true), + height_axis: AxialAttention::new(&vs / "height", planes, groups, false), + } + } + + pub(super) fn forward_t(&self, x: &Tensor, train: bool) -> Tensor { + let x = self.width_axis.forward_t(x, train); + self.height_axis.forward_t(&x, train) + } +} diff --git a/v2/crates/wifi-densepose-train/src/wiflow_std/mod.rs b/v2/crates/wifi-densepose-train/src/wiflow_std/mod.rs new file mode 100644 index 00000000..e927d73a --- /dev/null +++ b/v2/crates/wifi-densepose-train/src/wiflow_std/mod.rs @@ -0,0 +1,69 @@ +//! WiFlow-STD — spatio-temporal-decoupled CSI pose estimation (ADR-152 §2.2). +//! +//! Native Rust port of the **WiFlow-STD** architecture by DY2434 +//! (, +//! Apache-2.0), reimplemented idiomatically from the vendored read-only +//! reference in `benchmarks/wiflow-std/upstream/models/`. +//! +//! ## Evidence grade (ADR-152 §2.2 citation rule) +//! +//! Per `benchmarks/wiflow-std/RESULTS.md`, the upstream accuracy claims are +//! **MEASURED-EQUIVALENT**: our retraining of the reference implementation on +//! the released dataset reproduced **~96% PCK@20** (96.09% full test / 96.61% +//! corruption-free; published claim 97.25%). The *shipped* upstream checkpoint +//! was REFUTED (0.08% PCK@20 — keypoint-convention mismatch), and the released +//! dataset/code required repairs before training converged. Cite this port as +//! "~96% PCK@20 (our reproduction)" — **not comparable** to RuView's +//! 17-keypoint ESP32 numbers (different hardware, subjects, split, skeleton). +//! +//! ## Name collision +//! +//! WiFlow-STD (this module) is the *external* DY2434 architecture. It is +//! **distinct from RuView's internal WiFlow** camera-free pose pipeline; the +//! `_std` suffix (Spatio-Temporal Decoupling) disambiguates the two. +//! +//! ## Architecture +//! +//! ```text +//! CSI window [B, 540 sub, 20 t] +//! │ TCN stack: 4 × grouped TemporalBlock (groups=20, k=3, dilation 1/2/4/8, +//! │ depthwise-grouped + pointwise convs, causal Chomp1d padding) +//! ▼ channels 540 → 540 → 440 → 340 → 240 +//! [B, 240, 20] ── transpose+unsqueeze ──► [B, 1, 20, 240] (image-like) +//! │ ConvBlock1 (1→8, asymmetric 1×3 kernels, no downsampling) +//! │ 4 × AsymmetricConvBlock (8→8→16→32→64, stride (1,2) on subcarrier axis) +//! ▼ +//! [B, 64, 20, 15] ── permute ──► [B, 64, 15, 20] +//! │ DualAxialAttention (64 ch, 8 groups, width- then height-axial +//! │ self-attention with BN-normalised qkv and BN-normalised similarity) +//! │ Decoder convs 64 → 32 → 2 (3×3 then 1×1, BN + SiLU) +//! ▼ +//! [B, 2, 15, 20] ── adaptive avg-pool (K, 1) ──► [B, K, 2] keypoints +//! ``` +//! +//! 2,225,042 parameters / ~0.055 GFLOPs at the 15-keypoint default +//! (both verified against the reference — see `RESULTS.md`). +//! +//! Note: upstream `config.py` lists `TCN_CHANNELS = [480, 360, 240]`, but the +//! released checkpoint and `models/` code use `[540, 440, 340, 240]`. This +//! port follows the `models/` code, which we verified loads the released +//! weights after key remapping. +//! +//! ## Feature gating +//! +//! [`WiFlowStdConfig`] (validation, parameter-count formula, output-shape +//! inference) is pure Rust and always available. [`model::WiFlowStdModel`] +//! (the tch / LibTorch forward pass) requires the `tch-backend` feature, +//! matching [`crate::model`]'s gating. + +pub mod config; + +#[cfg(feature = "tch-backend")] +mod layers; +#[cfg(feature = "tch-backend")] +pub mod model; + +pub use config::WiFlowStdConfig; + +#[cfg(feature = "tch-backend")] +pub use model::WiFlowStdModel; diff --git a/v2/crates/wifi-densepose-train/src/wiflow_std/model.rs b/v2/crates/wifi-densepose-train/src/wiflow_std/model.rs new file mode 100644 index 00000000..e20da273 --- /dev/null +++ b/v2/crates/wifi-densepose-train/src/wiflow_std/model.rs @@ -0,0 +1,292 @@ +//! WiFlow-STD forward pass (tch-rs / LibTorch backend, ADR-152 §2.2). +//! +//! Idiomatic reimplementation of the DY2434 reference (Apache-2.0); see the +//! [module docs](crate::wiflow_std) for provenance and the evidence grade. +//! Weights are initialised from scratch (tch defaults; the axial-attention +//! qkv conv mirrors the reference's `N(0, sqrt(1/in_planes))` init). Loading +//! the retrained PyTorch checkpoint is a follow-up (key remap + `vs.load`). + +use tch::{nn, Device, Tensor}; + +use super::config::WiFlowStdConfig; +use super::layers::{ConvBlock, DualAxialAttention, GroupedTemporalBlock}; +use crate::error::TrainError; + +// --------------------------------------------------------------------------- +// WiFlowStdModel +// --------------------------------------------------------------------------- + +/// WiFlow-STD pose model: TCN temporal encoder → asymmetric 2-D conv encoder +/// → dual axial attention → conv decoder → adaptive pool to `(K, 2)` keypoints. +/// +/// Input: `[B, subcarriers, window]` CSI amplitudes. +/// Output: `[B, keypoints, 2]` normalised 2-D keypoint coordinates. +pub struct WiFlowStdModel { + vs: nn::VarStore, + tcn: Vec, + conv_in: ConvBlock, + conv_blocks: Vec, + attention: DualAxialAttention, + dec_conv1: nn::Conv2D, + dec_bn1: nn::BatchNorm, + dec_conv2: nn::Conv2D, + dec_bn2: nn::BatchNorm, + /// Active model configuration. + pub config: WiFlowStdConfig, +} + +impl WiFlowStdModel { + /// Build a new model with randomly-initialised weights on `device`. + /// + /// Call `tch::manual_seed(seed)` before this for reproducibility. + /// + /// # Errors + /// + /// Returns [`TrainError::Config`] if `config.validate()` fails. + pub fn new(config: &WiFlowStdConfig, device: Device) -> Result { + config.validate()?; + + let vs = nn::VarStore::new(device); + let root = vs.root(); + + // TCN stack: dilation doubles per level, causal padding. + let mut tcn = Vec::with_capacity(config.tcn_channels.len()); + let mut c_in = config.subcarriers as i64; + for (i, &c_out) in config.tcn_channels.iter().enumerate() { + let dilation = 1_i64 << i; + tcn.push(GroupedTemporalBlock::new( + &root / format!("tcn{i}"), + c_in, + c_out as i64, + dilation, + config.tcn_groups as i64, + config.dropout, + )); + c_in = c_out as i64; + } + + // 2-D conv encoder: ConvBlock1 (stride 1) + strided asymmetric blocks. + let c0 = config.conv_channels[0] as i64; + let conv_in = ConvBlock::new(&root / "conv_in", 1, c0, 1); + let mut conv_blocks = Vec::with_capacity(config.conv_channels.len()); + let mut c_in = c0; + for (i, &c_out) in config.conv_channels.iter().enumerate() { + conv_blocks.push(ConvBlock::new( + &root / format!("conv{i}"), + c_in, + c_out as i64, + 2, + )); + c_in = c_out as i64; + } + + let attention = + DualAxialAttention::new(&root / "attention", c_in, config.attention_groups as i64); + + // Decoder: c → c/2 (3×3) → 2 (1×1), BN + SiLU after each conv. + let mid = c_in / 2; + let dec_conv1 = nn::conv2d( + &root / "dec_conv1", + c_in, + mid, + 3, + nn::ConvConfig { + padding: 1, + ..Default::default() + }, + ); + let dec_bn1 = nn::batch_norm2d(&root / "dec_bn1", mid, Default::default()); + let dec_conv2 = nn::conv2d(&root / "dec_conv2", mid, 2, 1, Default::default()); + let dec_bn2 = nn::batch_norm2d(&root / "dec_bn2", 2, Default::default()); + + Ok(WiFlowStdModel { + vs, + tcn, + conv_in, + conv_blocks, + attention, + dec_conv1, + dec_bn1, + dec_conv2, + dec_bn2, + config: config.clone(), + }) + } + + /// Forward pass in training mode (dropout active, BN in train mode). + /// + /// `csi`: `[B, subcarriers, window]` → `[B, keypoints, 2]`. + pub fn forward_t(&self, csi: &Tensor) -> Tensor { + self.forward_impl(csi, true) + } + + /// Forward pass without gradient tracking (inference mode). + pub fn forward_inference(&self, csi: &Tensor) -> Tensor { + tch::no_grad(|| self.forward_impl(csi, false)) + } + + /// Save model weights (tch `.pt` / safetensors format). + /// + /// # Errors + /// + /// Returns [`TrainError::TrainingStep`] if the file cannot be written. + pub fn save(&self, path: &std::path::Path) -> Result<(), TrainError> { + self.vs + .save(path) + .map_err(|e| TrainError::training_step(format!("save failed: {e}"))) + } + + /// Load model weights from a file. + /// + /// # Errors + /// + /// Returns [`TrainError::TrainingStep`] if the file cannot be read or the + /// weights are incompatible with this architecture. + pub fn load(&mut self, path: &std::path::Path) -> Result<(), TrainError> { + self.vs + .load(path) + .map_err(|e| TrainError::training_step(format!("load failed: {e}"))) + } + + /// Reference to the internal `VarStore` (e.g. to build an optimiser). + pub fn var_store(&self) -> &nn::VarStore { + &self.vs + } + + /// Mutable access to the internal `VarStore`. + pub fn var_store_mut(&mut self) -> &mut nn::VarStore { + &mut self.vs + } + + /// Total number of trainable scalar parameters. Must equal + /// [`WiFlowStdConfig::param_count`] (2,225,042 at the default config). + pub fn num_parameters(&self) -> i64 { + self.vs + .trainable_variables() + .iter() + .map(|t| t.numel() as i64) + .sum() + } + + fn forward_impl(&self, csi: &Tensor, train: bool) -> Tensor { + // TCN: [B, subcarriers, T] → [B, c_tcn, T]. + let mut h = csi.shallow_clone(); + for block in &self.tcn { + h = block.forward_t(&h, train); + } + + // Image-like reshape: [B, c_tcn, T] → [B, 1, T, c_tcn]. + let h = h.transpose(1, 2).unsqueeze(1); + + // 2-D conv encoder: [B, 1, T, S] → [B, C, T, S']. + let mut h = self.conv_in.forward_t(&h, train); + for block in &self.conv_blocks { + h = block.forward_t(&h, train); + } + + // Swap to [B, C, S', T] for the axial attention + decoder. + let h = h.permute([0, 1, 3, 2]); + let h = self.attention.forward_t(&h, train); + + // Decoder: [B, C, S', T] → [B, 2, S', T]. + let h = h + .apply(&self.dec_conv1) + .apply_t(&self.dec_bn1, train) + .silu() + .apply(&self.dec_conv2) + .apply_t(&self.dec_bn2, train) + .silu(); + + // [B, 2, S', T] → pool (K, 1) → [B, 2, K] → [B, K, 2]. + let k = self.config.keypoints as i64; + h.adaptive_avg_pool2d([k, 1]) + .squeeze_dim(-1) + .transpose(1, 2) + } +} + +// --------------------------------------------------------------------------- +// Tests (require the tch-backend feature + LibTorch) +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use tch::Kind; + + fn random_csi(cfg: &WiFlowStdConfig, batch: i64) -> Tensor { + Tensor::rand( + [batch, cfg.subcarriers as i64, cfg.window as i64], + (Kind::Float, Device::Cpu), + ) + } + + #[test] + fn param_count_matches_pure_rust_formula() { + tch::manual_seed(0); + let cfg = WiFlowStdConfig::default(); + let model = WiFlowStdModel::new(&cfg, Device::Cpu).expect("default config builds"); + // Pins the tch graph against the verified reference (2,225,042). + assert_eq!(model.num_parameters(), cfg.param_count() as i64); + assert_eq!(model.num_parameters(), 2_225_042); + } + + #[test] + fn forward_output_shape_15_keypoints() { + tch::manual_seed(0); + let cfg = WiFlowStdConfig::default(); + let model = WiFlowStdModel::new(&cfg, Device::Cpu).expect("build"); + let out = model.forward_t(&random_csi(&cfg, 2)); + assert_eq!(out.size(), &[2, 15, 2]); + } + + #[test] + fn forward_output_shape_17_keypoints_esp32() { + tch::manual_seed(0); + let cfg = WiFlowStdConfig::for_keypoints(17); + let model = WiFlowStdModel::new(&cfg, Device::Cpu).expect("build"); + let out = model.forward_inference(&random_csi(&cfg, 1)); + assert_eq!(out.size(), &[1, 17, 2]); + } + + #[test] + fn inference_outputs_are_finite_and_deterministic() { + tch::manual_seed(7); + let cfg = WiFlowStdConfig::default(); + let model = WiFlowStdModel::new(&cfg, Device::Cpu).expect("build"); + let csi = random_csi(&cfg, 1); + let a = model.forward_inference(&csi); + let b = model.forward_inference(&csi); + assert!( + bool::try_from(a.isfinite().all()).unwrap(), + "non-finite output" + ); + assert!( + bool::try_from(a.eq_tensor(&b).all()).unwrap(), + "inference must be deterministic (dropout disabled)" + ); + } + + #[test] + fn invalid_config_is_rejected() { + let cfg = WiFlowStdConfig { + subcarriers: 541, // not divisible by tcn_groups + ..Default::default() + }; + assert!(WiFlowStdModel::new(&cfg, Device::Cpu).is_err()); + } + + #[test] + fn save_and_load_roundtrip() { + use tempfile::tempdir; + tch::manual_seed(42); + let cfg = WiFlowStdConfig::default(); + let mut model = WiFlowStdModel::new(&cfg, Device::Cpu).expect("build"); + let tmp = tempdir().expect("tempdir"); + let path = tmp.path().join("wiflow_std.pt"); + model.save(&path).expect("save"); + model.load(&path).expect("load"); + let out = model.forward_inference(&random_csi(&cfg, 1)); + assert_eq!(out.size(), &[1, 15, 2]); + } +} diff --git a/v2/crates/wifi-densepose-train/tests/test_mae.rs b/v2/crates/wifi-densepose-train/tests/test_mae.rs new file mode 100644 index 00000000..01702144 --- /dev/null +++ b/v2/crates/wifi-densepose-train/tests/test_mae.rs @@ -0,0 +1,281 @@ +//! Integration + property tests for [`wifi_densepose_train::mae`] +//! (ADR-152 §2.3 — UNSW MAE pretraining recipe). +//! +//! All deterministic tests use fixed seeds; property tests use `proptest` +//! with its default deterministic-replay machinery. + +use proptest::prelude::*; +use wifi_densepose_train::mae::{ + patchify, random_mask, unpatchify, unpatchify_visible, MaePretrainConfig, +}; +use wifi_densepose_train::MaeError; + +/// Deterministic test window: value = t * 1000 + sc (every cell unique). +fn window(time: usize, subc: usize) -> Vec { + (0..time * subc) + .map(|i| ((i / subc) * 1000 + i % subc) as f32) + .collect() +} + +// --------------------------------------------------------------------------- +// Config defaults + validation +// --------------------------------------------------------------------------- + +#[test] +fn default_config_matches_unsw_recipe() { + let cfg = MaePretrainConfig::default(); + assert!((cfg.mask_ratio - 0.80).abs() < 1e-12); + assert_eq!(cfg.patch_time, 30); + assert_eq!(cfg.patch_subc, 3); + assert_eq!(cfg.seed, 42); + cfg.validate().expect("default recipe is valid"); +} + +#[test] +fn config_json_round_trip() { + let cfg = MaePretrainConfig::default(); + let json = serde_json::to_string(&cfg).unwrap(); + let back: MaePretrainConfig = serde_json::from_str(&json).unwrap(); + assert_eq!(back, cfg); +} + +#[test] +fn invalid_mask_ratio_rejected() { + for ratio in [0.0, 1.0, -0.1, 1.5, f64::NAN] { + let cfg = MaePretrainConfig { + mask_ratio: ratio, + ..MaePretrainConfig::default() + }; + assert!(cfg.validate().is_err(), "ratio {ratio} should be invalid"); + } +} + +#[test] +fn zero_patch_dims_rejected() { + let cfg = MaePretrainConfig { + patch_time: 0, + ..MaePretrainConfig::default() + }; + assert!(cfg.validate().is_err()); + let cfg = MaePretrainConfig { + patch_subc: 0, + ..MaePretrainConfig::default() + }; + assert!(cfg.validate().is_err()); +} + +// --------------------------------------------------------------------------- +// Divisibility policy: error, never truncate +// --------------------------------------------------------------------------- + +#[test] +fn non_divisible_window_errors_with_crop_hint() { + let cfg = MaePretrainConfig::default(); // (30, 3) + // Default TrainingConfig window 100 × 56 is NOT divisible by (30, 3). + let err = cfg.validate_for_window(100, 56).unwrap_err(); + match err { + MaeError::NotDivisible { + axis, + window, + patch, + remainder, + crop, + } => { + assert_eq!(axis, "time"); + assert_eq!(window, 100); + assert_eq!(patch, 30); + assert_eq!(remainder, 10); + assert_eq!(crop, 90); + } + other => panic!("expected NotDivisible, got {other:?}"), + } + assert_eq!(cfg.cropped_window_shape(100, 56), (90, 54)); + // The hinted crop validates cleanly. + cfg.validate_for_window(90, 54).expect("crop is divisible"); + assert_eq!(cfg.num_patches(90, 54).unwrap(), 3 * 18); +} + +#[test] +fn patch_larger_than_window_errors() { + let cfg = MaePretrainConfig::default(); + let err = cfg.validate_for_window(20, 3).unwrap_err(); + assert!(matches!( + err, + MaeError::PatchExceedsWindow { axis: "time", .. } + )); +} + +#[test] +fn window_length_mismatch_errors() { + let cfg = MaePretrainConfig::default(); + let buf = vec![0.0_f32; 89 * 54]; // declared 90 × 54 + let err = patchify(&buf, 90, 54, &cfg).unwrap_err(); + assert!(matches!(err, MaeError::WindowShapeMismatch { .. })); +} + +// --------------------------------------------------------------------------- +// NaN handling +// --------------------------------------------------------------------------- + +#[test] +fn nan_and_inf_input_rejected_with_location() { + let cfg = MaePretrainConfig::default(); + let mut buf = window(90, 54); + buf[2 * 54 + 7] = f32::NAN; + match patchify(&buf, 90, 54, &cfg).unwrap_err() { + MaeError::NonFiniteValue { row, col, .. } => { + assert_eq!((row, col), (2, 7)); + } + other => panic!("expected NonFiniteValue, got {other:?}"), + } + buf[2 * 54 + 7] = f32::INFINITY; + assert!(matches!( + patchify(&buf, 90, 54, &cfg), + Err(MaeError::NonFiniteValue { .. }) + )); +} + +#[test] +fn finite_input_is_nan_free_after_round_trip() { + let cfg = MaePretrainConfig::default(); + let buf = window(90, 54); + let grid = patchify(&buf, 90, 54, &cfg).unwrap(); + assert!(grid.patches.iter().flatten().all(|v| v.is_finite())); + assert!(unpatchify(&grid).iter().all(|v| v.is_finite())); +} + +// --------------------------------------------------------------------------- +// Patchify / unpatchify round trip +// --------------------------------------------------------------------------- + +#[test] +fn patchify_unpatchify_identity_default_recipe() { + let cfg = MaePretrainConfig::default(); + let buf = window(90, 54); + let grid = patchify(&buf, 90, 54, &cfg).unwrap(); + assert_eq!(grid.n_patches(), 54); + assert_eq!(grid.patch_len(), 90); + assert_eq!(grid.window_shape(), (90, 54)); + assert_eq!(unpatchify(&grid), buf); +} + +#[test] +fn patch_layout_is_time_major() { + // 4 × 4 window, (2, 2) patches → patch 0 is rows 0–1 × cols 0–1. + let cfg = MaePretrainConfig { + patch_time: 2, + patch_subc: 2, + ..MaePretrainConfig::default() + }; + let buf = window(4, 4); + let grid = patchify(&buf, 4, 4, &cfg).unwrap(); + assert_eq!(grid.patches[0], vec![0.0, 1.0, 1000.0, 1001.0]); + // Patch index 1 is the next subcarrier block on the same time rows. + assert_eq!(grid.patches[1], vec![2.0, 3.0, 1002.0, 1003.0]); + // Patch index n_patches_subc starts the second time row of patches. + assert_eq!(grid.patches[2], vec![2000.0, 2001.0, 3000.0, 3001.0]); +} + +#[test] +fn unpatchify_visible_restores_visible_and_fills_masked() { + let cfg = MaePretrainConfig::default(); + let buf = window(90, 54); + let (grid, mask) = cfg.mask_window(&buf, 90, 54).unwrap(); + let fill = -1.0_f32; + let recon = unpatchify_visible(&grid, &mask.visible, fill); + + // Visible patch regions are identical to the input; masked regions = fill. + let full = unpatchify(&grid); + assert_eq!(full, buf); + let mut n_fill = 0usize; + for (i, (&r, &orig)) in recon.iter().zip(buf.iter()).enumerate() { + if r == fill && orig != fill { + n_fill += 1; + } else { + assert_eq!(r, orig, "visible value at flat index {i} must round-trip"); + } + } + assert_eq!(n_fill, mask.masked.len() * grid.patch_len()); +} + +// --------------------------------------------------------------------------- +// Random mask: exact count, determinism, disjointness +// --------------------------------------------------------------------------- + +#[test] +fn mask_count_is_exact_for_default_recipe() { + // 54 patches @ 0.80 → round(43.2) = 43 masked, 11 visible. + let cfg = MaePretrainConfig::default(); + assert_eq!(cfg.num_masked(54), 43); + let mask = random_mask(54, cfg.mask_ratio, cfg.seed); + assert_eq!(mask.masked.len(), 43); + assert_eq!(mask.visible.len(), 11); +} + +#[test] +fn same_seed_same_mask_different_seed_differs() { + let a = random_mask(100, 0.80, 7); + let b = random_mask(100, 0.80, 7); + assert_eq!(a, b, "same (n, ratio, seed) must reproduce the mask"); + + let c = random_mask(100, 0.80, 8); + assert_ne!(a.masked, c.masked, "different seeds must differ"); +} + +proptest! { + /// Exact count, sortedness, range, disjointness, and full coverage hold + /// for arbitrary grid sizes, ratios, and seeds. + #[test] + fn prop_mask_invariants( + n in 1usize..600, + ratio in 0.01f64..0.99, + seed in any::(), + ) { + let mask = random_mask(n, ratio, seed); + let expected_masked = ((ratio * n as f64).round() as usize).min(n); + prop_assert_eq!(mask.masked.len(), expected_masked); + prop_assert_eq!(mask.masked.len() + mask.visible.len(), n); + + // In range, sorted, strictly increasing (no duplicates). + for set in [&mask.masked, &mask.visible] { + for w in set.windows(2) { + prop_assert!(w[0] < w[1]); + } + if let Some(&last) = set.last() { + prop_assert!(last < n); + } + } + // Disjoint + complete: merged sets are exactly 0..n. + let mut all: Vec = mask.masked.iter().chain(&mask.visible).copied().collect(); + all.sort_unstable(); + prop_assert_eq!(all, (0..n).collect::>()); + } + + /// Determinism by seed for arbitrary inputs. + #[test] + fn prop_mask_deterministic(n in 1usize..400, seed in any::()) { + prop_assert_eq!(random_mask(n, 0.80, seed), random_mask(n, 0.80, seed)); + } + + /// Round-trip identity for arbitrary divisible window/patch geometries. + #[test] + fn prop_patchify_round_trip( + pt in 1usize..8, + ps in 1usize..8, + nt in 1usize..6, + ns in 1usize..6, + seed in any::(), + ) { + let (time, subc) = (pt * nt, ps * ns); + let cfg = MaePretrainConfig { + patch_time: pt, + patch_subc: ps, + seed, + ..MaePretrainConfig::default() + }; + let buf = window(time, subc); + let grid = patchify(&buf, time, subc, &cfg).unwrap(); + prop_assert_eq!(grid.n_patches(), nt * ns); + prop_assert_eq!(unpatchify(&grid), buf); + } +} diff --git a/vendor/ruvector b/vendor/ruvector index e3834760..a083bd77 160000 --- a/vendor/ruvector +++ b/vendor/ruvector @@ -1 +1 @@ -Subproject commit e3834760148f99373eab2bc008da280d57d80333 +Subproject commit a083bd77fa2f4879595daa68686ed5b2132d981a