mirror of
https://github.com/ruvnet/RuView
synced 2026-07-15 16:13:18 +00:00
feat: ADR-152 Rust integrations + ADR-153 802.11bf protocol model
- calibration: GeometryEmbedding — 32-slot permutation-invariant NodeGeometry featurization for future LoRA-head conditioning (ADR-152 §2.1.2); derived SpecialistBank::geometry_embedding() accessor; 59 tests - train: MaePretrainConfig + patchify/random-mask with UNSW measured recipe (80% masking, (30,3) patches; ADR-152 §2.3, arXiv 2511.18792); strict no-truncate/no-NaN policy; proptest properties - train: WiFlowStdModel — tch-gated port of the verified ~96%-PCK@20 WiFlow-STD architecture (ADR-152 §2.2 beyond-SOTA); ungated param formula pinned to 2,225,042; 15/17-keypoint support; 239 crate tests - hardware: ieee80211bf forward-compatibility protocol model (ADR-153): SpecProfile gates, SensingCapabilities negotiation, required ConsentMode, session FSM, SensingTransport + SimTransport + OpportunisticCsiBridge; full acceptance checklist covered; 156+4 tests - deps: ruvector bumps per ADR-152 §2.6 survey (mincut/solver 2.0.6, attention 2.1.0, gnn 2.2.0); vendor/ruvector synced to a083bd77f - docs: ADR-153 accepted; ADR-152 §2.2 status, §2.4 amendment, §2.6 added Workspace: 162 test suites green (--no-default-features); Python proof PASS. Known pre-existing flake: homecore-api env_empty_falls_back_to_defaults (unserialized env-var mutation) — untouched, follow-up. Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
<https://standards.ieee.org/ieee/802.11bf/11574/>). 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: <https://standards.ieee.org/ieee/802.11bf/11574/>
|
||||
Generated
+12
-12
@@ -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",
|
||||
|
||||
+6
-5
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<String>) {
|
||||
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<String>) {
|
||||
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<String>) {
|
||||
let zs: Vec<f32> = (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);
|
||||
|
||||
@@ -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::<f32>() / n as f32;
|
||||
let variance =
|
||||
series.iter().map(|v| (v - mean) * (v - mean)).sum::<f32>() / n as f32;
|
||||
let variance = series.iter().map(|v| (v - mean) * (v - mean)).sum::<f32>() / n as f32;
|
||||
let motion = if n > 1 {
|
||||
series.windows(2).map(|w| (w[1] - w[0]).abs()).sum::<f32>() / (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<f32> = (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<f32> = (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]
|
||||
|
||||
@@ -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<Option<[f32; 3]>> = sorted.iter().map(|g| valid_position(g)).collect();
|
||||
let orientations: Vec<Option<(f32, f32)>> =
|
||||
sorted.iter().map(|g| valid_orientation(g)).collect();
|
||||
let measured = measured_pairs(&sorted);
|
||||
let node_has_dist = |id: u8| measured.keys().any(|&(a, b)| a == id || b == id);
|
||||
let has_dist: Vec<bool> = sorted.iter().map(|g| node_has_dist(g.node_id)).collect();
|
||||
|
||||
// Slots 0–3: node count + measurement-presence fractions.
|
||||
let nf = n as f32;
|
||||
v[0] = (nf / NODE_SLOTS as f32).min(2.0);
|
||||
v[1] = positions.iter().flatten().count() as f32 / nf;
|
||||
v[2] = orientations.iter().flatten().count() as f32 / nf;
|
||||
v[3] = has_dist.iter().filter(|&&d| d).count() as f32 / nf;
|
||||
|
||||
// Slots 4–9: centroid + per-axis std of the known positions.
|
||||
let known: Vec<[f32; 3]> = positions.iter().flatten().copied().collect();
|
||||
if !known.is_empty() {
|
||||
let kf = known.len() as f32;
|
||||
let mut centroid = [0.0f32; 3];
|
||||
for p in &known {
|
||||
for (c, x) in centroid.iter_mut().zip(p) {
|
||||
*c += x / kf;
|
||||
}
|
||||
}
|
||||
for axis in 0..3 {
|
||||
v[4 + axis] = clamp_m(centroid[axis]);
|
||||
let mut var = 0.0;
|
||||
for p in &known {
|
||||
var += (p[axis] - centroid[axis]).powi(2) / kf;
|
||||
}
|
||||
v[7 + axis] = clamp_m(var.max(0.0).sqrt());
|
||||
}
|
||||
|
||||
// Slots 10–12: pairwise position distance stats.
|
||||
let mut dists = Vec::new();
|
||||
for i in 0..known.len() {
|
||||
for j in (i + 1)..known.len() {
|
||||
dists.push(euclidean(&known[i], &known[j]));
|
||||
}
|
||||
}
|
||||
write_min_mean_max(&mut v, 10, &dists);
|
||||
|
||||
// Slots 21–23: geometric diversity from the position covariance
|
||||
// eigenstructure (see module docs for why over polygon area).
|
||||
let (l1, l2, l3) = covariance_eigenvalues(&known, ¢roid);
|
||||
if l1 > f32::EPSILON {
|
||||
v[21] = (l2 / l1).clamp(0.0, 1.0);
|
||||
v[22] = (l3 / l1).clamp(0.0, 1.0);
|
||||
}
|
||||
v[23] = clamp_m(l1.max(0.0).sqrt());
|
||||
}
|
||||
|
||||
// Slots 13–16: inter-node distances — measured first, position fallback.
|
||||
let mut inter = Vec::new();
|
||||
for i in 0..n {
|
||||
for j in (i + 1)..n {
|
||||
let key = pair_key(sorted[i].node_id, sorted[j].node_id);
|
||||
if let Some(&d) = measured.get(&key) {
|
||||
inter.push(d);
|
||||
} else if let (Some(a), Some(b)) = (&positions[i], &positions[j]) {
|
||||
inter.push(euclidean(a, b));
|
||||
}
|
||||
}
|
||||
}
|
||||
write_min_mean_max(&mut v, 13, &inter);
|
||||
let possible_pairs = n * n.saturating_sub(1) / 2;
|
||||
if possible_pairs > 0 {
|
||||
v[16] = (measured.len() as f32 / possible_pairs as f32).clamp(0.0, 1.0);
|
||||
}
|
||||
|
||||
// Slots 17–20: orientation statistics (circular mean of azimuth).
|
||||
let known_orient: Vec<(f32, f32)> = orientations.iter().flatten().copied().collect();
|
||||
if !known_orient.is_empty() {
|
||||
let of = known_orient.len() as f32;
|
||||
let c = known_orient.iter().map(|(az, _)| az.cos()).sum::<f32>() / of;
|
||||
let s = known_orient.iter().map(|(az, _)| az.sin()).sum::<f32>() / of;
|
||||
v[17] = c.clamp(-1.0, 1.0);
|
||||
v[18] = s.clamp(-1.0, 1.0);
|
||||
v[19] = (c * c + s * s).sqrt().clamp(0.0, 1.0);
|
||||
let el = known_orient.iter().map(|(_, e)| e).sum::<f32>() / of;
|
||||
v[20] = el.clamp(-std::f32::consts::FRAC_PI_2, std::f32::consts::FRAC_PI_2);
|
||||
}
|
||||
|
||||
// Slots 24–31: per-node measurement flags (first NODE_SLOTS by id).
|
||||
for i in 0..n.min(NODE_SLOTS) {
|
||||
v[24 + i] = 0.25
|
||||
+ 0.25 * f32::from(positions[i].is_some() as u8)
|
||||
+ 0.25 * f32::from(orientations[i].is_some() as u8)
|
||||
+ 0.25 * f32::from(has_dist[i] as u8);
|
||||
}
|
||||
|
||||
// The finite invariant must hold whatever happened above.
|
||||
for x in &mut v {
|
||||
if !x.is_finite() {
|
||||
*x = 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
Self {
|
||||
schema_version: Self::SCHEMA_VERSION,
|
||||
values: v,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A position whose components are all finite and room-scale, else `None`.
|
||||
fn valid_position(g: &NodeGeometry) -> Option<[f32; 3]> {
|
||||
let p = g.position?;
|
||||
let ok = |c: f32| c.is_finite() && c.abs() <= MAX_COORD_M;
|
||||
(ok(p.x_m) && ok(p.y_m) && ok(p.z_m)).then_some([p.x_m, p.y_m, p.z_m])
|
||||
}
|
||||
|
||||
/// An orientation whose angles are both finite, else `None`.
|
||||
fn valid_orientation(g: &NodeGeometry) -> Option<(f32, f32)> {
|
||||
let o = g.orientation?;
|
||||
let ok = o.azimuth_rad.is_finite() && o.elevation_rad.is_finite();
|
||||
ok.then_some((o.azimuth_rad, o.elevation_rad))
|
||||
}
|
||||
|
||||
/// Canonical unordered pair key.
|
||||
fn pair_key(a: u8, b: u8) -> (u8, u8) {
|
||||
(a.min(b), a.max(b))
|
||||
}
|
||||
|
||||
/// Valid measured distances between *enrolled* nodes, deduplicated to
|
||||
/// unordered pairs (both directions recorded → averaged); distances to
|
||||
/// non-enrolled node ids are ignored.
|
||||
fn measured_pairs(sorted: &[&NodeGeometry]) -> BTreeMap<(u8, u8), f32> {
|
||||
let ids: Vec<u8> = sorted.iter().map(|g| g.node_id).collect();
|
||||
let mut sums: BTreeMap<(u8, u8), (f32, u32)> = BTreeMap::new();
|
||||
for g in sorted {
|
||||
for (&other, &d) in &g.distances_m {
|
||||
let pair_ok = other != g.node_id && ids.contains(&other);
|
||||
if pair_ok && d.is_finite() && d > 0.0 && d <= MAX_COORD_M {
|
||||
let e = sums.entry(pair_key(g.node_id, other)).or_insert((0.0, 0));
|
||||
e.0 += d;
|
||||
e.1 += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
sums.into_iter()
|
||||
.map(|(k, (sum, n))| (k, sum / n as f32))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn euclidean(a: &[f32; 3], b: &[f32; 3]) -> f32 {
|
||||
let mut d2 = 0.0;
|
||||
for k in 0..3 {
|
||||
d2 += (a[k] - b[k]).powi(2);
|
||||
}
|
||||
d2.sqrt()
|
||||
}
|
||||
|
||||
/// Write min/mean/max of a sample into slots `base..base+3` (left at zero
|
||||
/// when the sample is empty), clamped to the meters range.
|
||||
fn write_min_mean_max(v: &mut [f32; GeometryEmbedding::DIM], base: usize, xs: &[f32]) {
|
||||
if xs.is_empty() {
|
||||
return;
|
||||
}
|
||||
let (mut min, mut max, mut sum) = (f32::INFINITY, f32::NEG_INFINITY, 0.0);
|
||||
for &x in xs {
|
||||
min = min.min(x);
|
||||
max = max.max(x);
|
||||
sum += x;
|
||||
}
|
||||
v[base] = clamp_m(min);
|
||||
v[base + 1] = clamp_m(sum / xs.len() as f32);
|
||||
v[base + 2] = clamp_m(max);
|
||||
}
|
||||
|
||||
/// Clamp a meters-valued slot into ±[`MAX_COORD_M`], mapping non-finite to 0.
|
||||
fn clamp_m(x: f32) -> f32 {
|
||||
if x.is_finite() {
|
||||
x.clamp(-MAX_COORD_M, MAX_COORD_M)
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Eigenvalues `λ1 ≥ λ2 ≥ λ3 ≥ 0` of the 3×3 position covariance, via the
|
||||
/// closed-form trigonometric solution for symmetric matrices (no linear-
|
||||
/// algebra dependency; f64 internally for conditioning).
|
||||
fn covariance_eigenvalues(points: &[[f32; 3]], centroid: &[f32; 3]) -> (f32, f32, f32) {
|
||||
let nf = points.len() as f64;
|
||||
// Upper triangle of the symmetric covariance: (xx, yy, zz, xy, xz, yz).
|
||||
const IJ: [(usize, usize); 6] = [(0, 0), (1, 1), (2, 2), (0, 1), (0, 2), (1, 2)];
|
||||
let mut m = [0.0f64; 6];
|
||||
for p in points {
|
||||
let d: [f64; 3] = std::array::from_fn(|i| (p[i] - centroid[i]) as f64);
|
||||
for (k, &(i, j)) in IJ.iter().enumerate() {
|
||||
m[k] += d[i] * d[j] / nf;
|
||||
}
|
||||
}
|
||||
let (a, b, c, d, e, f) = (m[0], m[1], m[2], m[3], m[4], m[5]);
|
||||
let p1 = d * d + e * e + f * f;
|
||||
let q = (a + b + c) / 3.0;
|
||||
let p2 = (a - q).powi(2) + (b - q).powi(2) + (c - q).powi(2) + 2.0 * p1;
|
||||
let p = (p2 / 6.0).sqrt();
|
||||
let (l1, l2, l3) = if p < 1e-12 {
|
||||
(q, q, q) // (Near-)isotropic: all eigenvalues equal — diagonal incl.
|
||||
} else {
|
||||
// r = det((M - qI)/p) / 2, clamped into acos' domain.
|
||||
let (ba, bb, bc) = ((a - q) / p, (b - q) / p, (c - q) / p);
|
||||
let (bd, be, bf) = (d / p, e / p, f / p);
|
||||
let det = ba * (bb * bc - bf * bf) - bd * (bd * bc - bf * be) + be * (bd * bf - bb * be);
|
||||
let phi = (det / 2.0).clamp(-1.0, 1.0).acos() / 3.0;
|
||||
let e1 = q + 2.0 * p * phi.cos();
|
||||
let e3 = q + 2.0 * p * (phi + 2.0 * std::f64::consts::PI / 3.0).cos();
|
||||
(e1, 3.0 * q - e1 - e3, e3)
|
||||
};
|
||||
// PSD matrix: tiny negatives are numerical noise — clamp.
|
||||
(l1.max(0.0) as f32, l2.max(0.0) as f32, l3.max(0.0) as f32)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A fully-measured node at `(x, y, 1)` with boresight toward +Y.
|
||||
fn node(id: u8, x: f32, y: f32) -> NodeGeometry {
|
||||
NodeGeometry::new(id, "tape-measure")
|
||||
.with_position(x, y, 1.0)
|
||||
.with_orientation(std::f32::consts::FRAC_PI_2, 0.1)
|
||||
}
|
||||
|
||||
/// 3 nodes on a 3-4-5 triangle; the (1,2) edge also measured by tape.
|
||||
fn full_layout() -> Vec<NodeGeometry> {
|
||||
vec![
|
||||
node(1, 0.0, 0.0).with_distance(2, 3.0),
|
||||
node(2, 3.0, 0.0).with_distance(1, 3.0),
|
||||
node(3, 0.0, 4.0),
|
||||
]
|
||||
}
|
||||
|
||||
fn assert_all_finite(e: &GeometryEmbedding) {
|
||||
for (i, x) in e.values.iter().enumerate() {
|
||||
assert!(x.is_finite(), "slot {i} is not finite: {x}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dimension_stable_and_empty_input_is_all_zero() {
|
||||
assert_eq!(GeometryEmbedding::DIM, 32);
|
||||
let full = GeometryEmbedding::from_nodes(&full_layout());
|
||||
assert_eq!(full.as_slice().len(), GeometryEmbedding::DIM);
|
||||
let empty = GeometryEmbedding::from_nodes(&[]);
|
||||
assert_eq!(empty, GeometryEmbedding::default(), "all-zero");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_unknown_layout_degrades_gracefully() {
|
||||
let nodes = vec![NodeGeometry::unknown(1), NodeGeometry::unknown(2)];
|
||||
let e = GeometryEmbedding::from_nodes(&nodes);
|
||||
assert_all_finite(&e);
|
||||
assert!((e.values[0] - 2.0 / 8.0).abs() < 1e-6, "node count slot");
|
||||
// No measurements: presence fractions and all stats at zero …
|
||||
for slot in 1..24 {
|
||||
assert_eq!(e.values[slot], 0.0, "slot {slot} should be 0");
|
||||
}
|
||||
// … but the per-node existence flags still say two nodes were there.
|
||||
assert_eq!(&e.values[24..27], &[0.25, 0.25, 0.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_node_has_no_pairwise_stats() {
|
||||
let n = NodeGeometry::new(5, "t")
|
||||
.with_position(1.0, 2.0, 1.5)
|
||||
.with_orientation(0.0, 0.0);
|
||||
let e = GeometryEmbedding::from_nodes(&[n]);
|
||||
assert_all_finite(&e);
|
||||
assert_eq!(&e.values[4..7], &[1.0, 2.0, 1.5], "centroid = the node");
|
||||
assert_eq!(&e.values[7..10], &[0.0, 0.0, 0.0], "no spread");
|
||||
assert_eq!(&e.values[10..17], &[0.0; 7], "no pairs");
|
||||
assert_eq!(e.values[17], 1.0, "cos(0)");
|
||||
assert_eq!(e.values[19], 1.0, "single boresight is fully concentrated");
|
||||
assert_eq!(e.values[24], 0.75, "position + orientation, no distances");
|
||||
}
|
||||
|
||||
/// Full-measurement layout: every slot family lands where the geometry
|
||||
/// says it should, and shuffling node order changes nothing.
|
||||
#[test]
|
||||
fn full_layout_statistics_and_permutation_invariance() {
|
||||
let nodes = full_layout();
|
||||
let e = GeometryEmbedding::from_nodes(&nodes);
|
||||
assert!((e.values[1] - 1.0).abs() < 1e-6, "all positioned");
|
||||
assert!((e.values[2] - 1.0).abs() < 1e-6, "all oriented");
|
||||
// 3-4-5 triangle: position-pair distances {3, 4, 5}.
|
||||
assert!((e.values[10] - 3.0).abs() < 1e-5, "min dist");
|
||||
assert!((e.values[11] - 4.0).abs() < 1e-5, "mean dist");
|
||||
assert!((e.values[12] - 5.0).abs() < 1e-5, "max dist");
|
||||
// Inter-node stats: pair (1,2) measured, (1,3)/(2,3) from positions.
|
||||
assert!((e.values[14] - 4.0).abs() < 1e-5, "mean inter-node dist");
|
||||
assert!((e.values[16] - 1.0 / 3.0).abs() < 1e-6, "1 of 3 measured");
|
||||
// Parallel boresights: fully concentrated, pointing +Y.
|
||||
assert!(e.values[17].abs() < 1e-6, "cos(π/2)");
|
||||
assert!((e.values[18] - 1.0).abs() < 1e-5, "sin(π/2)");
|
||||
assert!((e.values[19] - 1.0).abs() < 1e-5, "concentration");
|
||||
assert!((e.values[20] - 0.1).abs() < 1e-5, "mean elevation");
|
||||
// Coplanar triangle: λ1 ≈ 4.32, λ2 ≈ 1.23 (3-4-5 covariance), λ3 = 0.
|
||||
assert!((e.values[21] - 0.286).abs() < 0.01, "λ2/λ1 planar");
|
||||
assert!(e.values[22] < 1e-5, "λ3/λ1 ≈ 0 — coplanar nodes");
|
||||
assert!(e.values[23] > 0.5, "dominant spread is meter-scale");
|
||||
// Node 3 (rank 2) recorded no distances; nodes 1, 2 did.
|
||||
assert_eq!(&e.values[24..27], &[1.0, 1.0, 0.75]);
|
||||
|
||||
let mut shuffled = nodes;
|
||||
shuffled.rotate_left(1);
|
||||
shuffled.swap(0, 1);
|
||||
assert_eq!(e, GeometryEmbedding::from_nodes(&shuffled));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn measured_distance_overrides_position_distance() {
|
||||
// Positions say 3 m apart, the tape measure said 2.5 m: measured wins.
|
||||
let nodes = vec![
|
||||
NodeGeometry::new(1, "t")
|
||||
.with_position(0.0, 0.0, 1.0)
|
||||
.with_distance(2, 2.5),
|
||||
NodeGeometry::new(2, "t").with_position(3.0, 0.0, 1.0),
|
||||
];
|
||||
let e = GeometryEmbedding::from_nodes(&nodes);
|
||||
assert!((e.values[10] - 3.0).abs() < 1e-5, "position pair stat raw");
|
||||
assert!((e.values[14] - 2.5).abs() < 1e-5, "measured wins");
|
||||
assert!((e.values[16] - 1.0).abs() < 1e-6, "full pair coverage");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adversarial_inputs_never_produce_nan() {
|
||||
let nodes = vec![
|
||||
NodeGeometry::new(1, "garbage")
|
||||
.with_position(f32::NAN, f32::INFINITY, -0.0)
|
||||
.with_orientation(f32::NAN, f32::NEG_INFINITY)
|
||||
.with_distance(2, f32::NAN)
|
||||
.with_distance(3, -5.0)
|
||||
.with_distance(1, 1.0), // self-distance: ignored
|
||||
NodeGeometry::new(2, "garbage")
|
||||
.with_position(1e30, 1e30, 1e30)
|
||||
.with_distance(99, 4.0), // unknown node: ignored
|
||||
NodeGeometry::new(3, "garbage").with_position(2.0, 0.0, 1.0),
|
||||
];
|
||||
let e = GeometryEmbedding::from_nodes(&nodes);
|
||||
assert_all_finite(&e);
|
||||
// Only node 3's position survived sanitization.
|
||||
assert!((e.values[1] - 1.0 / 3.0).abs() < 1e-6);
|
||||
assert_eq!(e.values[2], 0.0, "no valid orientations");
|
||||
assert_eq!(e.values[16], 0.0, "no valid measured pairs");
|
||||
assert!(e.values.iter().all(|x| x.abs() <= MAX_COORD_M), "bounded");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn more_than_eight_nodes_still_aggregates() {
|
||||
let nodes: Vec<NodeGeometry> = (0..12)
|
||||
.map(|i| NodeGeometry::new(i, "plan").with_position(i as f32, 0.0, 1.0))
|
||||
.collect();
|
||||
let e = GeometryEmbedding::from_nodes(&nodes);
|
||||
assert!((e.values[0] - 12.0 / 8.0).abs() < 1e-6);
|
||||
// All 8 flag slots filled (positions known, ranks 0..8 by node_id).
|
||||
assert!(e.values[24..32].iter().all(|&f| f == 0.5));
|
||||
// Collinear nodes: zero planar/volume diversity, meter-scale spread.
|
||||
assert!(e.values[21] < 1e-5);
|
||||
assert!(e.values[22] < 1e-5);
|
||||
assert!(e.values[23] > 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serde_roundtrip_and_schema_default() {
|
||||
let e = GeometryEmbedding::from_nodes(&full_layout());
|
||||
let json = serde_json::to_string(&e).unwrap();
|
||||
let back: GeometryEmbedding = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back, e);
|
||||
assert_eq!(back.schema_version, GeometryEmbedding::SCHEMA_VERSION);
|
||||
// JSON written by a pre-versioning producer (no version field)
|
||||
// defaults to the current schema — the NodeGeometry pattern.
|
||||
let vals = serde_json::to_string(&e.values).unwrap();
|
||||
let bare = format!("{{\"values\":{vals}}}");
|
||||
let from_bare: GeometryEmbedding = serde_json::from_str(&bare).unwrap();
|
||||
assert_eq!(from_bare.schema_version, 1);
|
||||
assert_eq!(from_bare.values, e.values);
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
@@ -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<String>) {
|
||||
pub fn add_node(
|
||||
&mut self,
|
||||
node_id: u8,
|
||||
bank: SpecialistBank,
|
||||
current_baseline_id: impl Into<String>,
|
||||
) {
|
||||
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<SpecialistReading> {
|
||||
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 },
|
||||
|
||||
@@ -123,9 +123,7 @@ impl Specialist for PresenceSpecialist {
|
||||
fn infer(&self, f: &Features) -> Option<SpecialistReading> {
|
||||
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<SpecialistReading> {
|
||||
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<SpecialistReading> {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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<f32>,
|
||||
pub phases: Vec<f32>,
|
||||
}
|
||||
|
||||
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::<f64>() / 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,
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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<SensingMeasurementSetupRequest>,
|
||||
/// 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<f64>,
|
||||
}
|
||||
|
||||
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<MeasurementSetupId> {
|
||||
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<Vec<Action>, 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<Vec<Action>, 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<Action> {
|
||||
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<Vec<Action>, 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<Vec<Action>, 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<Vec<Action>, 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<Action> {
|
||||
match self.setup_id() {
|
||||
Some(setup_id) => vec![Action::SendTermination(SensingSessionTermination {
|
||||
setup_id,
|
||||
reason,
|
||||
})],
|
||||
None => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
fn next_instance_record(&mut self) -> Option<SensingMeasurementInstance> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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<u8, SensingSession>,
|
||||
}
|
||||
|
||||
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<Vec<Action>, 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<Vec<Action>, BfError> {
|
||||
match self.sessions.get_mut(&setup_id.value()) {
|
||||
Some(session) => session.handle(event),
|
||||
None => Ok(vec![]),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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::<SensingMeasurementSetupRequest>(&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::<SensingMeasurementSetupResponse>(&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::<SensingMeasurementInstance>(&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::<SensingMeasurementReport>(&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::<SbpRequest>(&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::<SbpResponse>(&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::<SensingSessionTermination>(&json).unwrap(),
|
||||
term
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serde_rejects_out_of_range_setup_id() {
|
||||
assert!(serde_json::from_str::<MeasurementSetupId>("200").is_err());
|
||||
assert!(serde_json::from_str::<MeasurementSetupId>("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)
|
||||
);
|
||||
}
|
||||
@@ -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<SessionEvent> = 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Action> {
|
||||
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<Action> {
|
||||
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
|
||||
}
|
||||
@@ -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<SensingFrame>;
|
||||
|
||||
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<SensingFrame> {
|
||||
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<super::session::SessionEvent> {
|
||||
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<SensingFrame>,
|
||||
inbound: VecDeque<SensingFrame>,
|
||||
scripted: VecDeque<SensingFrame>,
|
||||
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<SensingFrame> {
|
||||
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<SensingFrame> {
|
||||
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<f64>,
|
||||
phase_cos_accum: Vec<f64>,
|
||||
phase_sin_accum: Vec<f64>,
|
||||
frames_in_batch: usize,
|
||||
}
|
||||
|
||||
impl OpportunisticCsiBridge {
|
||||
pub fn new(setup_id: MeasurementSetupId, batch_size: usize) -> Result<Self, BfError> {
|
||||
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<SensingMeasurementReport> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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<Self, BfError> {
|
||||
if value > MAX_SETUP_ID {
|
||||
Err(BfError::InvalidSetupId { value })
|
||||
} else {
|
||||
Ok(Self(value))
|
||||
}
|
||||
}
|
||||
pub fn value(self) -> u8 {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<u8> for MeasurementSetupId {
|
||||
type Error = BfError;
|
||||
fn try_from(value: u8) -> Result<Self, Self::Error> {
|
||||
Self::new(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<MeasurementSetupId> 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<Self, BfError> {
|
||||
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,
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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<T> 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;
|
||||
|
||||
@@ -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<usize, MaeError> {
|
||||
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<Vec<f32>>,
|
||||
}
|
||||
|
||||
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<usize>,
|
||||
/// Indices of patches the encoder sees.
|
||||
pub visible: Vec<usize>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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<PatchGrid, MaeError> {
|
||||
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<f32> {
|
||||
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<f32> {
|
||||
unpatchify_select(grid, Some(visible), fill)
|
||||
}
|
||||
|
||||
fn unpatchify_select(grid: &PatchGrid, keep: Option<&[usize]>, fill: f32) -> Vec<f32> {
|
||||
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<usize> = (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<usize> = order[..n_masked].to_vec();
|
||||
let mut visible: Vec<usize> = 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(())
|
||||
}
|
||||
@@ -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<usize>,
|
||||
|
||||
/// 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<usize>,
|
||||
|
||||
/// 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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
//! (<https://github.com/DY2434/WiFlow-WiFi-Pose-Estimation-with-Spatio-Temporal-Decoupling>,
|
||||
//! 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;
|
||||
@@ -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<GroupedTemporalBlock>,
|
||||
conv_in: ConvBlock,
|
||||
conv_blocks: Vec<ConvBlock>,
|
||||
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<Self, TrainError> {
|
||||
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]);
|
||||
}
|
||||
}
|
||||
@@ -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<f32> {
|
||||
(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::<u64>(),
|
||||
) {
|
||||
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<usize> = mask.masked.iter().chain(&mask.visible).copied().collect();
|
||||
all.sort_unstable();
|
||||
prop_assert_eq!(all, (0..n).collect::<Vec<_>>());
|
||||
}
|
||||
|
||||
/// Determinism by seed for arbitrary inputs.
|
||||
#[test]
|
||||
fn prop_mask_deterministic(n in 1usize..400, seed in any::<u64>()) {
|
||||
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::<u64>(),
|
||||
) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
Vendored
+1
-1
Submodule vendor/ruvector updated: e383476014...a083bd77fa
Reference in New Issue
Block a user