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:
ruv
2026-06-10 19:46:34 -04:00
parent e1936c9a24
commit b0c324edc5
31 changed files with 5024 additions and 58 deletions
+24 -1
View File
@@ -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 **17.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
View File
@@ -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
View File
@@ -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]` |
//! | 46 | position centroid (x, y, z) | m, clamped ±[`MAX_COORD_M`] |
//! | 79 | position std-dev per axis (x, y, z) | m, `[0,` [`MAX_COORD_M`]`]` |
//! | 1012 | pairwise position distance min / mean / max | m |
//! | 1315 | 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]` |
//! | 1718 | 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]` |
//! | 2122 | 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 |
//! | 2431 | 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 03: 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 49: 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 1012: 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 2123: geometric diversity from the position covariance
// eigenstructure (see module docs for why over polygon area).
let (l1, l2, l3) = covariance_eigenvalues(&known, &centroid);
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 1316: 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 1720: 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 2431: 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
+76 -1
View File
@@ -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,
},
}
+11 -1
View File
@@ -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;
+379
View File
@@ -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, 20160 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.40.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 FisherYates 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 01 × cols 01.
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);
}
}