mirror of
https://github.com/ruvnet/RuView
synced 2026-06-12 10:43:19 +00:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d22616c488 | |||
| 17471e93ff | |||
| 29de574e63 | |||
| d0e27e652e |
@@ -121,12 +121,23 @@ jobs:
|
||||
with:
|
||||
workspaces: v2
|
||||
|
||||
# The 38-crate workspace debug build exhausts the runner's disk when built
|
||||
# with full debuginfo (observed: "final link failed: No space left on
|
||||
# device" once the engine/benchmark crates landed; the same tree's local
|
||||
# debug target measured 151 GB). Debuginfo is useless in CI — tests either
|
||||
# pass or print their failure — so build without it; target shrinks ~5-10x.
|
||||
- name: Run Rust tests
|
||||
working-directory: v2
|
||||
env:
|
||||
CARGO_PROFILE_DEV_DEBUG: "0"
|
||||
CARGO_PROFILE_TEST_DEBUG: "0"
|
||||
run: cargo test --workspace --no-default-features
|
||||
|
||||
- name: Run ADR-147 worldmodel tests
|
||||
working-directory: v2
|
||||
env:
|
||||
CARGO_PROFILE_DEV_DEBUG: "0"
|
||||
CARGO_PROFILE_TEST_DEBUG: "0"
|
||||
run: cargo test -p wifi-densepose-worldmodel --no-default-features
|
||||
|
||||
# ADR-134 CIR tests are behind the `cir` feature so the bench dependency
|
||||
|
||||
@@ -7,7 +7,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
- **Mesh partition risk now demotes the privacy class and is witnessed (ADR-032).** The dynamic min-cut guard's `at_risk` signal was advisory-only (it fed the recalibration advisor). It now also contributes to the ADR-141 privacy demotion alongside fusion- and array-level contradictions: a mesh close to partitioning makes the fused belief less trustworthy, so the cycle emits at a more restricted class (monotonic — information only removed). Because `effective_class` feeds the BLAKE3 witness, a fragmenting array now shifts the witness — partition risk is auditable, not just logged. The mesh computation moved ahead of the demotion step in `process_cycle`; new `mesh_guard_mut()` exposes risk-threshold tuning. Test proves a forced-risk 3-node cycle demotes PrivateHome Anonymous→Restricted and shifts the witness vs a clean *same-topology* baseline (the only delta between the two cycles is the forced risk).
|
||||
|
||||
### Added
|
||||
- **ADR-152 WiFi-Pose SOTA 2026 intake — verified external benchmark + four Rust integrations.** A 22-source adversarially-verified survey of the 2025–2026 WiFi-sensing SOTA, with every adopted number reproduced or graded before integration:
|
||||
- **WiFlow-STD (DY2434) reproduction (`benchmarks/wiflow-std/`)** — the external "97.25% PCK@20, 2.23M params" claim audited end-to-end: the **shipped checkpoint is REFUTED** (0.08% PCK@20 — wrong keypoint normalization, predates the published code), the released code does not run as published (6 documented defects, incl. an import that fails and an unreachable test phase), and the released dataset's final 13 files are corrupted (9,072 windows of NaN + float32-max garbage that NaN-poisons fp16 BatchNorm training). After repairing both, retraining with upstream defaults on an RTX 5080 reproduced **96.09% PCK@20 (full test) / 96.61% (corruption-free)** — claims graded MEASURED-EQUIVALENT; params (2,225,042) and FLOPs (~0.055 G) verified exactly. Full forensics in `benchmarks/wiflow-std/RESULTS.md`.
|
||||
- **`GeometryEmbedding` (ADR-152 §2.1.2, `wifi-densepose-calibration`)** — 32-slot permutation-invariant, NaN-proof featurization of the §2.1.1 `NodeGeometry` records (centroid/spread, measured-first pairwise distances, circular azimuth stats, covariance-eigenvalue geometric diversity, per-node flags), schema-versioned for the ADR-151 P6 LoRA heads; derived `SpecialistBank::geometry_embedding()` accessor. The PerceptAlign "coordinate overfitting" defense, transplanted to per-room banks.
|
||||
- **MAE pretraining recipe (ADR-152 §2.3, `wifi-densepose-train/src/mae.rs`)** — `MaePretrainConfig` pinning the UNSW-measured recipe (80% masking, (30,3) patches) with pure-Rust patchify/random-mask (exact counts, seed-deterministic, error-not-truncate divisibility, NaN rejection), property-tested; the consumption seam for the future ADR-150 ViT-Small encoder.
|
||||
- **`WiFlowStdModel` Rust port (`wifi-densepose-train/src/wiflow_std/`)** — tch-gated idiomatic port of the verified spatio-temporal-decoupled architecture (grouped causal TCN → asymmetric conv stack → dual axial attention); ungated param formula asserted equal to the reference 2,225,042; 15/17-keypoint variants share weights (enables the ADR-152 §2.2(b) ESP32 fine-tune).
|
||||
- **RuVector vendor sync + §2.6 opportunity survey** — vendor at `a083bd77f`; graded ADOPT/EVALUATE/WATCH table; crates.io bumps applied (mincut/solver 2.0.6, attention 2.1.0, gnn 2.2.0; RUSTSEC #504 audit: no pinned crate affected); top WATCH: unpublished `ruvector-graph-condense` differentiable min-cut for trainable subcarrier grouping.
|
||||
- **ADR-153 IEEE 802.11bf-2025 forward-compatibility protocol model (`wifi-densepose-hardware/src/ieee80211bf/`)** — typed WLAN-sensing procedures (measurement setup/instance/report, SBP, termination) with `SpecProfile` version gates, `SensingCapabilities` negotiation, and **required** `ConsentMode` governance metadata on every setup; deterministic session FSM with rejection/timeout paths; `SensingTransport` seam with `SimTransport` and an `OpportunisticCsiBridge` mapping live ESP32 CSI batches into standardized report shape (a future chipset adapter replaces the bridge without touching RuvSense consumers). Not a certified implementation — simulation-tested protocol surface; OTA binding lands when silicon does. 19 acceptance tests.
|
||||
- **Dynamic min-cut mesh partition guard in the streaming engine (`mesh_guard`).** Maintains a `ruvector-mincut` exact min-cut over the live mesh coupling graph (nodes = sensing nodes, coupling = product of fusion attention weights), surfacing per cycle: the global **cut value** (how close the array is to splitting — a structural measure per-node heuristics miss), the **weak side** (which specific nodes would partition: failure/jamming triage feeding ADR-032 posture), and an **at-risk flag** that counts as a structural event for the drift→recalibration advisor. Surfaced as `TrustedOutput::mesh`. **Measured cost policy** (criterion, 12-node mesh): weights are quantized (1/64; a *nonzero* coupling below one quantum saturates to quantum 1 so quantization never erases a live coupling — without the floor, balanced meshes of ≥ 65 nodes had every ~1/n coupling erased and sat permanently "at risk") and updates change-gated, so the steady-state cycle does zero graph work (~7.3 µs, ~23× cheaper than building); on any real change a full exact rebuild (~171 µs) is used because one `DynamicMinCut` delete+insert measured ~240 µs — the incremental machinery's overhead targets much larger graphs, so rebuild-on-change is the measured optimum at mesh scale (one-edge case −28% after the policy switch). Degenerate cases fail toward risk: a node with zero coupling is reported as already partitioned (cut 0). 9 mesh-guard tests + an engine-level wiring test; full `process_cycle` with the guard: ~33 µs for 4 nodes (50 ms budget).
|
||||
- **Opt-in FFT operator for the CIR ISTA solver (8–14× measured).** Φ is a sub-DFT, so each ISTA mat-vec can run as one length-G FFT (O(G log G)) instead of a dense O(K·G) product. New `CirConfig::fft_operator` (default **false** — the dense path stays the bit-exact witness default; the FFT evaluates the same sums in a different order, so enabling it shifts float results and requires regenerating any pinned witness). `FftOperator` (rustfft, planned once at construction, scratch reused across the ISTA loop) dispatches inside `ista_solve`; warm-start/Lipschitz stay dense at construction. Measured (criterion, same run): ht20 2.22 ms → 265 µs (**8.4×**), ht40 10.26 ms → 717 µs (**14.3×**); the real HE40 grid (K=484, G=1452) scales further. 3 new tests: FFT↔dense matvec equivalence to float tolerance (ht20 + he40 grids), end-to-end dominant-tap agreement on a single-path frame, and all default configs keep FFT off. New `cir_estimate_fft` bench group.
|
||||
- **Per-room adapter provenance + drift→recalibration advisor in the streaming engine.** Closes the trust-chain gap where an ~11 KB per-room LoRA adapter (ADR-150 §3.4) could silently change inference without the witness noticing. `StreamingEngine::set_room_adapter(AdapterInfo)` pins the adapter's content-derived id into provenance `model_version` (`rfenc-v1+adapter:<id>`) — and therefore into the BLAKE3 witness — so swapping or clearing adapter weights always shifts the witness (engine test proves base → adapter → other-adapter → cleared all witness differently, and cleared == base). New `RecalibrationAdvisor` recommends re-running the ADR-135 baseline / refitting the adapter on sustained low fusion coherence (streak threshold, default 60 cycles ≈ 3 s at 20 Hz) or an ADR-142 change-point; surfaced as `TrustedOutput::recalibration_recommended` and recorded on the sensing-server's `EngineBridge` alongside the witness. Bridge plumbing: `EngineBridge::{set_room_adapter, clear_room_adapter}` + live-path test that the adapter id flows into the live witness. *Scope note: this is the deployable provenance/trigger half of the "retrained model" roadmap item — fitting the adapter itself runs in the existing external calibration service (`aether-arena/calibration/`), and a trained RF-encoder checkpoint still does not exist in-tree.*
|
||||
- **RuView beyond-SOTA research series** (`docs/research/ruview-beyond-sota/`, 6 docs) — research-swarm output defining the beyond-SOTA bar and the path to it: system capability audit (role→crate maturity matrix, gap analysis, risk register), web-verified 2026 SOTA landscape per capability axis (incl. ratified IEEE 802.11bf-2025), 8-pillar target architecture on the ADR-136 contract spine (no rewrite), 6-layer benchmark/validation methodology (all 15 criterion bench targets inventoried; ADR-149 statistical protocol), and a determinism-safe optimization roadmap. Includes session validation evidence: 2,797 workspace tests / 0 failed, Python proof PASS (bit-exact), paired pre/post criterion runs.
|
||||
|
||||
### Performance
|
||||
- **CIR estimator warm-start precompute** — the diagonal Tikhonov preconditioner `diag(Φ^H Φ)+λI` and its CSR matrix were rebuilt every frame although they depend only on Φ and λ (fixed at `CirEstimator::new`); now precomputed at construction (`ruvsense/cir.rs`). Bit-identical floats (summation order unchanged, witness chain unaffected). Measured: `cir_estimate/he40` −3.9% (p<0.01), multiband groups −1.2/−1.4%; smaller configs within container noise.
|
||||
- **RF tomography solver hoisting** — ISTA gradient buffer no longer allocated inside the 100-iteration loop, and the Frobenius Lipschitz bound moved from per-`reconstruct` to construction (`ruvsense/tomography.rs`). Bit-identical results.
|
||||
|
||||
### Added
|
||||
- **Falsifiable occupancy benchmark (`wifi-densepose-train::occupancy_bench`).** Makes the presence/person-count "beyond SOTA" claim falsifiable in code instead of aspirational (the unfalsifiability gap from the beyond-SOTA system review). Grades predictions vs ground truth and gates a SOTA claim behind one `claim_allowed` invariant requiring all of: `DataProvenance::Measured` (synthetic/mock is scorable but **never claimable** — anti-mock-contamination per the CLAUDE.md Kconfig-bug lesson), a leak-free `EvalSplit` (refuses any split where a subject *or* environment id appears in both train and test — subject leakage / per-environment overfitting), `n_test ≥ min`, a **non-degenerate test set** (both truth classes represented: present-rate ≥ `min_positive_rate` and ≥ 1 absent sample — an all-absent set plus an always-absent predictor cannot release a claim; vacuous F1 scores 0.0, never 1.0), presence-F1 **bootstrap-CI lower bound** (deterministic seeded splitmix64) clearing the threshold, and count MAE within threshold. The claim string is unreadable except through the gate (`NO_CLAIM` otherwise). What remains is data, not method: a frozen, SHA-pinned, subject/environment-disjoint measured replay set turns the claim into a passing/failing test. 12 tests cover each refusal path, including the point-above/CI-below case (claim withheld on the CI lower bound even when the point estimate clears the threshold).
|
||||
- **Live trust path: sensing-server routes real frames through the governed `StreamingEngine` (parallel governed path with partial output gating).** Previously the live server ran only the *bare* `MultistaticFuser` (fused amplitudes, no trust control plane), while the privacy/provenance/witness engine (ADR-135..146) ran only on synthetic in-test frames — the gap called out in ADR-136 §8 and the beyond-SOTA system review. New `engine_bridge` module drives `StreamingEngine::process_cycle` from the server's live `NodeState` map (reusing the existing `NodeState → MultiBandCsiFrame` conversion), lazily wiring each node as a WorldGraph sensor and bounding belief growth via the retention cap; every *governed belief* carries evidence + model + calibration + privacy decision and a deterministic witness. **Honest scope:** the engine runs alongside (not instead of) the bare fusion path that feeds the live `SensingUpdate`. What its decision gates on the wire today: a cycle emitted at class `Restricted` (base mode or contradiction/mesh-risk demotion) suppresses the per-node raw amplitude vectors from the live publish — the same field mapping `wifi-densepose-bfld`'s privacy gate applies at `Restricted`; gating the remaining derived outputs (person count, classification, signal field) is tracked as a follow-up. Trust state is no longer write-only: the latest witness, effective privacy class, demotion flag, recalibration recommendation, and an engine-error counter are readable on `GET /api/v1/status`, and engine errors are counted + rate-limit logged instead of silently swallowed (`EngineBridge::observe_cycle`). Adds `wifi-densepose-engine/-worldgraph/-bfld/-geo` deps. Bridge tests cover witnessed belief with provenance, determinism, idempotent node registration, retention bound, privacy-mode propagation, trust-state recording, the error-counter path, and Restricted-class raw-output suppression.
|
||||
|
||||
### Fixed
|
||||
- **`wifi-densepose-mat` standalone `--no-default-features` build (101 errors → 0).** `pub mod api` was unconditional while its only dependency, serde, is optional behind the `api` feature — so any build without default features failed with unresolved serde imports (masked in `--workspace` runs by feature unification). The `api` module and its `create_router`/`AppState` re-export are now `#[cfg(feature = "api")]`-gated (with docsrs annotations). All feature combos compile: bare `--no-default-features`, `--no-default-features --features api`, and full default (177 tests pass).
|
||||
- **WorldGraph no longer grows unboundedly under the live loop.** `StreamingEngine::process_cycle` appended one `SemanticState` belief per cycle with no eviction — ~1.7M nodes/day at 20 Hz (identified in `docs/research/ruview-beyond-sota/04-optimization-roadmap.md`). Added `WorldGraph::prune_semantic_states(max)` — deterministic eviction of the oldest beliefs by `(valid_from_unix_ms, id)`, structural nodes (rooms/zones/sensors/anchors/tracks/events) never eligible — and wired it into the engine after each belief append (`StreamingEngine::DEFAULT_SEMANTIC_RETENTION` = 7,200 ≈ 6 min at 20 Hz; tunable via `set_semantic_retention`). The WorldGraph holds *current* beliefs; durable history is the recorder's job, so no audit data is lost. 3 new tests (bounded growth end-to-end, oldest-only eviction, deterministic tie-break).
|
||||
- **ESP32 edge heart rate no longer stuck at ~45 BPM / dropping wildly — #987.** The on-device HR estimator (`edge_processing.c`, `0xC5110002`) reported ~45 BPM regardless of true heart rate (Apple-Watch ground truth 87 BPM read as ~45) and swung frame-to-frame. Two root causes: (1) a hardcoded `sample_rate = 10.0f` that became wrong after #985's self-ping raised the CSI callback rate to a variable ~13–19 Hz — BPM scales as `assumed/actual × true`, so 87 read ~45 and the reading swung as CSI yield fluctuated; (2) the zero-crossing estimator locked onto a breathing harmonic (a 0.25 Hz breathing fundamental puts its 3rd harmonic at ~0.74 Hz ≈ 44 BPM inside the HR band). Fix: measure the real sample rate from inter-frame timestamps (used for BPM conversion + biquad re-tuning on >15% drift); replace the HR zero-crossing with an autocorrelation estimator that rejects breathing harmonics (driven by a robust autocorr breathing period); median-13 smooth the output. Hardware A/B (fixed vs unmodified control board, both `edge_tier=2`): control pegged 40–49 BPM; fixed reaches the true 88–91 BPM (vs 87 GT) and holds a stable physiological value (spread 59→0 for a steady subject). Known limitation: heavy subject motion still degrades the estimate (motion gating is a follow-up).
|
||||
- **Person count no longer leaks up to 10 in heuristic mode — addresses #894.** `field_bridge::occupancy_or_fallback` returned the eigenvalue-based `FieldModel::estimate_occupancy` count **unbounded** (its internal ceiling is 10), while the sibling estimators on the same single-link data — the perturbation-energy fallback right below it and `score_to_person_count` — both cap at 3 ("1-3 for single ESP32"). On noisy / under-calibrated CSI the eigenvalue count inflated, producing the "10 persons reported when 1 present" symptom (seen when `--model` fails to load and the server runs on heuristics). Bounded the eigenvalue path to the shared `MAX_SINGLE_LINK_OCCUPANCY` (3) so every estimator on one link agrees; genuine higher counts come from the multistatic fusion path, not a single-link covariance estimate.
|
||||
- **MQTT multi-node deployments now create one Home-Assistant device per node — closes #898.** After the #872 MQTT wiring landed, the JSON→`VitalsSnapshot` bridge hard-coded a single `node_id` (the MQTT client id) and the publisher used a single `OwnedDiscoveryBuilder`, so every physical node collapsed into one device (`identifiers:["wifi_densepose_wifi-densepose-1"]`), contradicting the "one device per node" docs. The bridge now emits one snapshot per node in the sensing update's `nodes[]` (each with its own `node_id` + RSSI, falling back to a single aggregate snapshot for wifi/simulate sources), and the publisher derives a per-node builder (`OwnedDiscoveryBuilder::for_node`) that publishes discovery + availability lazily on first sight of each `node_id` and routes state to per-node topics — yielding N distinct HA devices with per-node availability/LWT. Unit-tested (distinct nodes → distinct `wifi_densepose_<node>` identifiers); 71 MQTT tests pass.
|
||||
|
||||
@@ -10,9 +10,9 @@ Dual codebase: Python v1 (`v1/`) and Rust port (`v2/`).
|
||||
| `wifi-densepose-core` | Core types, traits, error types, CSI frame primitives |
|
||||
| `wifi-densepose-signal` | SOTA signal processing + RuvSense multistatic sensing (16 modules) |
|
||||
| `wifi-densepose-nn` | Neural network inference (ONNX, PyTorch, Candle backends) |
|
||||
| `wifi-densepose-train` | Training pipeline with ruvector integration + ruview_metrics |
|
||||
| `wifi-densepose-train` | Training pipeline with ruvector integration + ruview_metrics; MAE pretraining recipe (`mae.rs`, ADR-152 §2.3) + WiFlow-STD port (`wiflow_std/`, tch-gated) |
|
||||
| `wifi-densepose-mat` | Mass Casualty Assessment Tool — disaster survivor detection |
|
||||
| `wifi-densepose-hardware` | ESP32 aggregator, TDM protocol, channel hopping firmware |
|
||||
| `wifi-densepose-hardware` | ESP32 aggregator, TDM protocol, channel hopping firmware; `ieee80211bf/` 802.11bf forward-compat protocol model (ADR-153) |
|
||||
| `wifi-densepose-ruvector` | RuVector v2.0.4 integration + cross-viewpoint fusion (5 modules) |
|
||||
| `wifi-densepose-wasm` | WebAssembly bindings for browser deployment |
|
||||
| `wifi-densepose-cli` | CLI tool (`wifi-densepose` binary) — `calibrate`/`calibrate-serve`/`enroll`/`train-room`/`room-watch` + MAT (MAT gated behind the `mat` feature; build `--no-default-features` for the aarch64/appliance calibration binary) |
|
||||
@@ -73,6 +73,8 @@ All 5 ruvector crates integrated in workspace:
|
||||
- ADR-031: RuView sensing-first RF mode (Proposed)
|
||||
- ADR-032: Multistatic mesh security hardening (Proposed)
|
||||
- ADR-148: Drone swarm control system / `ruview-swarm` (In Progress)
|
||||
- ADR-152: WiFi-Pose SOTA 2026 intake — geometry conditioning, WiFlow-STD benchmark (measurement (a) complete: claims MEASURED-EQUIVALENT at ~96% PCK@20), MAE recipe (Proposed; §2.1–2.3, 2.6 implemented)
|
||||
- ADR-153: IEEE 802.11bf-2025 forward-compatibility protocol model (Accepted — amends ADR-152 §2.4)
|
||||
|
||||
### Supported Hardware
|
||||
|
||||
|
||||
@@ -221,11 +221,15 @@ class ESP32BinaryParser:
|
||||
|
||||
snr = float(rssi - noise_floor)
|
||||
frequency = float(freq_mhz) * 1e6
|
||||
bandwidth = 20e6 # default; could infer from n_subcarriers
|
||||
|
||||
if n_subcarriers <= 56:
|
||||
# Bandwidth inference (issue #1005): HE-LTF uses a 4x denser tone
|
||||
# grid than HT-LTF on the same channel width — an HE-SU frame with
|
||||
# 256 bins (242 active HE20 tones) is a *20 MHz* capture, not 160.
|
||||
if ppdu_byte in (1, 2, 3): # HE-SU / HE-MU / HE-TB
|
||||
bandwidth = 40e6 if (flags_byte & 0x01) or n_subcarriers > 256 else 20e6
|
||||
elif n_subcarriers <= 64: # ESP32 HT20 delivers the full 64-bin FFT
|
||||
bandwidth = 20e6
|
||||
elif n_subcarriers <= 114:
|
||||
elif n_subcarriers <= 128:
|
||||
bandwidth = 40e6
|
||||
elif n_subcarriers <= 242:
|
||||
bandwidth = 80e6
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
# Upstream clone (WiFlow-STD, DY2434) -- never commit third-party code/weights
|
||||
upstream/
|
||||
|
||||
# Local python env
|
||||
.venv/
|
||||
|
||||
# Downloaded data / artifacts
|
||||
data/
|
||||
downloads/
|
||||
*.pth
|
||||
*.pt
|
||||
*.npy
|
||||
*.npz
|
||||
*.zip
|
||||
*.mat
|
||||
*.safetensors
|
||||
results/parity_fixture.json
|
||||
__pycache__/
|
||||
*.onnx
|
||||
|
||||
# Committed ground truth: corruption masks for the pristine Kaggle download.
|
||||
# remote/clean_v2.py zeroes the corrupted source windows IN PLACE, so these
|
||||
# masks CANNOT be regenerated from a cleaned copy (generate_corruption_masks.py
|
||||
# documents the criteria and reproduces them only from a fresh download).
|
||||
!results/nan_windows_mask.npy
|
||||
!results/big_windows_mask.npy
|
||||
@@ -0,0 +1,486 @@
|
||||
# WiFlow-STD (DY2434) Benchmark Results — ADR-152 §2.2
|
||||
|
||||
Upstream: <https://github.com/DY2434/WiFlow-WiFi-Pose-Estimation-with-Spatio-Temporal-Decoupling>
|
||||
pinned at `06899d29` (2026-04-05), Apache-2.0. Dataset: Kaggle `kaka2434/wiflow-dataset`
|
||||
(12.8 GB archive → 15.5 GB extracted; 360,000 windows of 540×20 CSI + 15-keypoint 2D labels).
|
||||
|
||||
Published claims (README "Setting 1"): PCK@20 97.25%, PCK@30 98.63%, PCK@40 99.16%,
|
||||
PCK@50 99.48%, MPJPE 0.007 m, 2.23M params, 0.07 GFLOPs.
|
||||
|
||||
## Measurement (a): their model on their data
|
||||
|
||||
### Artifact verification (MEASURED, 2026-06-10, this repo `eval_repro.py`)
|
||||
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| Parameter count | **2,225,042 (2.23M) — matches claim** |
|
||||
| FLOPs (torch profiler, batch 1) | ~0.055 GFLOPs — consistent with 0.07B claim |
|
||||
| CPU latency (Windows box, torch 2.12 CPU) | 13.2 ms/window @ batch 1 (76/s); 2.48 ms/sample @ batch 64 (403/s) |
|
||||
| Checkpoint load | `weights_only=True` (no pickle code execution) |
|
||||
|
||||
### Released checkpoint does NOT reproduce the claims — REFUTED as shipped
|
||||
|
||||
Running the released `best_pose_model.pth` through the released code on the released
|
||||
dataset with the released split procedure (seed-42 file-level 70/15/15; 54,000 test
|
||||
samples) yields:
|
||||
|
||||
| Metric | Published | Measured (shipped checkpoint) |
|
||||
|---|---|---|
|
||||
| PCK@20 | 97.25% | **0.08%** |
|
||||
| PCK@30 | 98.63% | 0.78% |
|
||||
| PCK@40 | 99.16% | 5.53% |
|
||||
| PCK@50 | 99.48% | 15.42% |
|
||||
| MPJPE | 0.007 | **NaN** (dataset contains NaN CSI windows) |
|
||||
|
||||
Raw output: `results/repro_a.json`.
|
||||
|
||||
Diagnostics (on 2,000 NaN-free windows from the first files of the dataset, i.e.
|
||||
mostly would-be *training* data — so this is not a split mismatch):
|
||||
|
||||
- Predictions correlate with targets (Pearson r ≈ 0.76) — the checkpoint is a trained
|
||||
model, but in a **different keypoint normalization/order** than the released data.
|
||||
- Best-case post-hoc global per-axis affine correction: PCK@20 ≈ 20%.
|
||||
- Best-case per-keypoint affine correction (15×2 fitted transforms — generous
|
||||
cheating): PCK@20 ≈ 72%, still far below 97.25%.
|
||||
- Pred↔target keypoint correspondence matrix is degenerate (multiple predicted
|
||||
keypoints best-match the same target joint) — keypoint convention mismatch.
|
||||
|
||||
### Reproducibility defects in the released artifacts
|
||||
|
||||
1. `models/__init__.py` imports `TemporalConvNet`, which `models/tcn.py` does not
|
||||
define — **the published code does not import/run as-is**.
|
||||
2. The released root checkpoint uses pre-rename module names (`att.*`, `final_conv.*`)
|
||||
vs the published code (`attention.*`, `decoder.*`) — same shapes/param count, but
|
||||
confirms the checkpoint predates the published code.
|
||||
3. The second shipped checkpoint (`cross_dataset_test/WiFlow/best_pose_model.pth`) is
|
||||
a **different architecture** (342-channel input = MM-Fi layout, 3 TCN layers,
|
||||
3-channel/3D decoder) — not usable on their own dataset.
|
||||
4. `run.py` ignores `--data_dir` and hardcodes `../preprocessed_csi_data`.
|
||||
5. The released dataset's final 13 files (indices 487–499; 9,072 windows, 2.52%)
|
||||
are corrupted: NaN values plus garbage amplitudes up to 3.4e38 (float32 max) in
|
||||
data that is otherwise [0,1]-normalized. Upstream code has no NaN/inf handling;
|
||||
training as published on this download diverges — the first corrupted batch
|
||||
overflows fp16 autocast and permanently poisons BatchNorm running statistics
|
||||
(GradScaler step-skipping does not protect BN). The authors' training curves
|
||||
show normal convergence, so their local data evidently differed from the
|
||||
Kaggle upload. Window masks: `results/nan_windows_mask.npy`,
|
||||
`results/big_windows_mask.npy`.
|
||||
|
||||
### Reproducing the corruption masks
|
||||
|
||||
The two mask files (9,070 NaN/Inf windows, 9,072 with |amplitude| > 1.5;
|
||||
union 9,072, all in dataset files 487–499) are **committed ground truth**
|
||||
(gitignore-negated, ~352 KB each). They can only be regenerated from a
|
||||
**pristine** Kaggle download: `remote/clean_v2.py` repairs the dataset by
|
||||
zeroing the corrupted windows in place, after which the corruption evidence
|
||||
is gone and a rescan returns all-False. `generate_corruption_masks.py`
|
||||
re-derives them (chunked scan, criteria: any non-finite value OR
|
||||
max |finite| > 1.5 per 540×20 window) and refuses to write all-False masks,
|
||||
which indicate a cleaned copy. Verified 2026-06-11: a regeneration from the
|
||||
local pristine download is bit-identical to the committed masks.
|
||||
|
||||
### Retraining result (MEASURED, 2026-06-10): claims APPROXIMATELY REPRODUCED
|
||||
|
||||
Since the shipped checkpoint is unusable, measurement (a) fell back to retraining
|
||||
with upstream code + defaults (seed 42, batch 64, early-stopped at epoch 41 of 50,
|
||||
best epoch 36, ~75 s/epoch) on ruvultra (RTX 5080). Deviations, all forced and
|
||||
documented: one-line fix for defect (1); torch 2.x+cu128 instead of pinned 2.3.1
|
||||
(Blackwell sm_120 unsupported); the 9,072 corrupted windows (defect 5) zeroed
|
||||
entirely — without this the published pipeline produces NaN from epoch 1 (observed).
|
||||
Scripts mirrored in `remote/`; raw metrics in `results/eval_retrained.json`.
|
||||
|
||||
| Metric | Published | Retrained (full test, 54,000) | Retrained (corruption-free, 52,560) |
|
||||
|---|---|---|---|
|
||||
| PCK@20 | 97.25% | **96.09%** | **96.61%** |
|
||||
| PCK@30 | 98.63% | 97.89% | 98.23% |
|
||||
| PCK@40 | 99.16% | 98.58% | 98.79% |
|
||||
| PCK@50 | 99.48% | 98.99% | 99.11% |
|
||||
| MPJPE | 0.007 | 0.0098 | 0.0094 |
|
||||
|
||||
Within ~0.6–1.2 PCK points of every published figure (single run, corrupted train
|
||||
windows zeroed, different torch/GPU). **Verdict: the accuracy claims are credible
|
||||
and approximately reproducible — but only after repairing the released dataset and
|
||||
code.** Val best: PCK@20 96.99%, MPJPE 0.0086 (epoch 36).
|
||||
|
||||
One more defect found during the run:
|
||||
|
||||
6. `train.py` calls `plot_training_history`, which is not defined anywhere — the
|
||||
built-in post-training test evaluation is unreachable as published (crashes
|
||||
with NameError after training completes).
|
||||
|
||||
## ADR-152 §2.2 citation rule
|
||||
|
||||
Evidence grade for the WiFlow-STD accuracy claims after measurement (a):
|
||||
**MEASURED-EQUIVALENT (96.1–96.6% PCK@20 reproduced by retraining; shipped
|
||||
checkpoint REFUTED; dataset/code require repairs)**. RuView docs may cite
|
||||
"~96% PCK@20 (our reproduction)" — still **not comparable** to our 17-keypoint
|
||||
ESP32 numbers (different hardware, 5 subjects, in-domain random split,
|
||||
15 keypoints).
|
||||
|
||||
## Edge optimization (measured)
|
||||
|
||||
ADR-152 "optimize beyond SOTA" track, 2026-06-10, this Windows box (Windows 11,
|
||||
16 torch threads, torch 2.12.0+cpu, onnxruntime 1.26.0). Subject: the retrained
|
||||
checkpoint `results/retrained_best_pose_model.pth` (2,225,042 fp32 params).
|
||||
Scripts: `quantize_bench.py`, `onnx_bench.py`, `eval_ort_accuracy.py`.
|
||||
Raw numbers: `results/edge_optimization.json`.
|
||||
|
||||
Accuracy is on a **10,000-window seed-42 random subset** of the corruption-free
|
||||
test split (same seed-42 file-level 70/15/15 split as `eval_repro.py`; 54,000
|
||||
test windows, 1,440 corrupted excluded via `results/nan_windows_mask.npy` |
|
||||
`results/big_windows_mask.npy`, leaving 52,560; subset drawn with
|
||||
`np.random.default_rng(42)`). The fp32 subset PCK@20 (96.68%) matches the full
|
||||
clean-test figure (96.61%), so the subset is representative.
|
||||
|
||||
Latency is CPU ms/window, median of repeated runs, 3 interleaved repetitions
|
||||
per variant (medians below; run-to-run spread on this box is large, roughly
|
||||
±20-40% at batch 1 — reps are in the JSON).
|
||||
|
||||
| Variant | Disk size | Batch 1 (ms/win) | Batch 64 (ms/win) | PCK@20 | PCK@50 | MPJPE |
|
||||
|---|---|---|---|---|---|---|
|
||||
| torch fp32 (baseline) | 9.07 MB | 11.0 | 2.27 | 96.68% | 99.15% | 0.00936 |
|
||||
| torch fp16 (`.half()`) | **4.58 MB** | 24.3 | 2.42 | 96.68% | 99.15% | 0.00946 |
|
||||
| torch int8 dynamic | 9.07 MB (unchanged) | 15.6 | 2.06 | 96.68% (identical) | 99.15% | 0.00936 |
|
||||
| ONNX fp32 (onnxruntime) | 8.97 MB | **3.2** | **2.0** | 96.68% | 99.15% | 0.00936 |
|
||||
| ONNX int8 (ORT dynamic, supplementary) | **2.44 MB** | 6.5 | 5.8 | 96.52% | 99.15% | 0.01108 |
|
||||
|
||||
Findings:
|
||||
|
||||
- **torch dynamic INT8 quantizes nothing on this model.** The architecture has
|
||||
**zero `nn.Linear` layers** — it is entirely Conv1d (21) + Conv2d (22) +
|
||||
BatchNorm. `torch.ao.quantization.quantize_dynamic` (requested over
|
||||
`{Linear, Conv1d, Conv2d}`) converted **0 modules / 0.0% of params**: dynamic
|
||||
quantization only has kernels for Linear/RNN-family modules and silently
|
||||
skips convolutions. The "int8" model is bit-identical to fp32 (same outputs,
|
||||
same 9.07 MB). Conv quantization would require static (PTQ) quantization
|
||||
with calibration — out of scope here; the ORT dynamic path below is the
|
||||
honest int8 datapoint.
|
||||
- **fp16 halves size for free accuracy-wise** (PCK@20 −0.005 pt, MPJPE
|
||||
+0.0001) but is *slower* on CPU at batch 1 (~2.2×) — torch CPU fp16 conv
|
||||
kernels are emulated. fp16 is a storage/transport format here, not a CPU
|
||||
runtime win.
|
||||
- **ONNX Runtime is the real batch-1 latency win: ~3.4× faster than torch**
|
||||
(3.2 vs 11.0 ms/window) at identical accuracy (parity 2.4e-7).
|
||||
|
||||
### Verdict on the paper's "~2.2 MB int8" claim
|
||||
|
||||
**Plausible but not free, and unreachable by the obvious PyTorch route.**
|
||||
2,225,042 params × 1 byte ≈ 2.2 MB assumes *every* parameter quantizes.
|
||||
PyTorch dynamic quantization — the one-liner most readers would reach for —
|
||||
yields **9.07 MB (0% quantized)** because the model has no Linear layers.
|
||||
ONNX Runtime dynamic quantization, which does have int8 conv weight support,
|
||||
gets **2.44 MB** (close to the claim; the overhead is BatchNorm params/buffers
|
||||
and quantization scales kept in fp32) at a measurable accuracy cost:
|
||||
PCK@20 96.68 → 96.52% (−0.16 pt) and MPJPE 0.00936 → 0.01108 (+18%), and
|
||||
~2× slower inference than ONNX fp32 (ConvInteger kernels). The paper does not
|
||||
state a method or an int8 accuracy; treat "2.2 MB" as a weight-arithmetic
|
||||
estimate, achievable in practice only via conv-capable quantization toolchains
|
||||
and with a small accuracy penalty.
|
||||
|
||||
### ONNX export status
|
||||
|
||||
**Works.** Exported via the TorchScript exporter (`dynamo=False`), opset 17,
|
||||
with a dynamic batch axis — `results/retrained_fp32_dynamic.onnx` (8.97 MB),
|
||||
verified to run at batch 1/2/64. The axial attention's
|
||||
`view(N*W, C, H)` reshape traced correctly (sizes recorded as graph ops, not
|
||||
baked constants). The dynamo exporter also captures the graph but crashed on
|
||||
this box writing a ✅ to a cp1252 console (cosmetic Windows encoding issue, not
|
||||
a model blocker). Parity vs torch on the stored fixture
|
||||
(`results/parity_fixture.npz`, batch 2, seed 42): **max abs diff 2.4e-7 —
|
||||
PASS** (< 1e-4). ORT-quantized int8 model: `results/retrained_int8_ort_dynamic.onnx`.
|
||||
|
||||
### Static PTQ (calibrated) — follow-up
|
||||
|
||||
Follow-up to the dynamic-int8 row above (2026-06-10, same box, onnxruntime
|
||||
1.26.0): ONNX Runtime **static** post-training quantization
|
||||
(`quantize_static`, QDQ format, per-channel int8 weights + int8 activations)
|
||||
of the same fp32 export, calibrated on **corruption-free TRAINING-split
|
||||
windows only** (seed-42 file-level split, same masks; 1,000 windows for
|
||||
MinMax, 512 for the histogram calibrators; never test windows). Scopes:
|
||||
"conv-only" (`op_types_to_quantize=["Conv"]` — the attention path exports as
|
||||
Einsum/Softmax, which ORT never quantizes anyway, so "all-ops" additionally
|
||||
quantizes the elementwise Mul/Sigmoid/Add/AveragePool glue). Accuracy on the
|
||||
identical 10k-window seed-42 corruption-free test subset; latency median of
|
||||
3 interleaved reps (fp32/dynamic re-benched in-session as references).
|
||||
Script: `static_ptq_bench.py`; raw: `results/edge_optimization.json`
|
||||
(`onnx_static_ptq`).
|
||||
|
||||
| Variant | Disk size | Batch 1 (ms/win) | Batch 64 (ms/win) | PCK@20 | PCK@50 | MPJPE |
|
||||
|---|---|---|---|---|---|---|
|
||||
| ONNX fp32 (reference) | 8.97 MB | 2.5 | 1.9 | 96.68% | 99.15% | 0.00936 |
|
||||
| ORT dynamic int8 (baseline) | **2.44 MB** | 5.7 | 4.6 | 96.52% | 99.15% | 0.01108 |
|
||||
| static QDQ **Percentile(99.99) conv-only** | 2.53 MB | 5.3 | 4.7 | 96.61% | 99.16% | **0.01031** |
|
||||
| static QDQ MinMax conv-only | 2.53 MB | 5.2 | 3.3 | **96.63%** | 99.19% | 0.01084 |
|
||||
| static QDQ Entropy conv-only | 2.53 MB | 5.2 | 3.1 | 96.60% | 99.19% | 0.01078 |
|
||||
| static QDQ MinMax all-ops | 2.60 MB | 6.5 | 3.9 | 95.45% | 99.14% | 0.01486 |
|
||||
| static QDQ Entropy all-ops | 2.60 MB | 5.7 | 4.1 | 95.30% | 99.13% | 0.01510 |
|
||||
| static QDQ Percentile all-ops | 2.60 MB | 5.3 | 4.3 | 96.39% | 99.17% | 0.01218 |
|
||||
|
||||
**Verdict: static PTQ (conv-only) is the new best int8 point on accuracy —
|
||||
but only modestly, and it does not fix int8's latency penalty.**
|
||||
|
||||
- **Accuracy: beats dynamic.** All three conv-only calibrations land at
|
||||
PCK@20 96.60–96.63% (vs dynamic 96.52%, fp32 96.68% — recovers ~⅔ of the
|
||||
dynamic gap) and MPJPE 0.0103–0.0108 (vs dynamic 0.01108). Best MPJPE:
|
||||
Percentile conv-only, +10% over fp32 instead of dynamic's +18%.
|
||||
- **Size: slightly worse.** 2.53 MB vs 2.44 MB (+3.6%) — QDQ nodes and
|
||||
per-channel scales cost a little; BatchNorm stays fp32 in both (the 12 BNs
|
||||
follow Slice/Einsum/Reshape, never Conv, so they cannot be folded).
|
||||
- **Latency: a wash vs dynamic, still ~2× slower than ONNX fp32 at batch 1.**
|
||||
Batch-1 medians 5.2–5.3 vs dynamic 5.7 ms/win in-session — within this
|
||||
box's ±20–40% noise. Batch 64 leans static (3.1–3.3 for MinMax/Entropy
|
||||
conv-only vs 4.6), same caveat.
|
||||
- **All-ops QDQ is strictly worse**: up to −1.4 pt PCK@20 and +60% MPJPE for
|
||||
zero size/latency benefit — int8 activations through the elementwise glue
|
||||
around the attention blocks is where the damage is. Conv-only is the right
|
||||
scope.
|
||||
- Negative result worth recording: **Entropy calibration is a no-op here** —
|
||||
on an identical calibration set it selects full-range thresholds
|
||||
bit-identical to MinMax (all 247 scales equal; verified on a 64-window
|
||||
smoke set). Also, ORT 1.26's `CalibMaxIntermediateOutputs` raises a
|
||||
spurious "No data is collected" when the batch count divides the chunk
|
||||
size (worked around in the script).
|
||||
|
||||
Deployment guidance: need speed → ONNX fp32 (3.2 ms b1). Need int8 weights
|
||||
for size → static QDQ conv-only (Percentile or MinMax,
|
||||
`results/retrained_int8_static_percentile_conv.onnx`), which strictly
|
||||
dominates dynamic int8 on accuracy at ~equal latency and +0.09 MB.
|
||||
|
||||
## Efficiency sweep (MEASURED, overnight 2026-06-10/11)
|
||||
|
||||
ADR-152 beyond-SOTA track: compact purpose-built variants of the WiFlow-STD
|
||||
architecture, trained from scratch on the same cleaned dataset, identical
|
||||
seed-42 file-level split, loss and protocol as the measurement-(a) reference
|
||||
(fp32, batch 64, ≤50 epochs, patience 5; RTX 5080, ~22–29 min/variant).
|
||||
Variant transforms are pure channel/group/stride scalings of an
|
||||
architecture-exact parameterized model (validated: reproduces 2,225,042 params
|
||||
at the reference config). Scripts: `remote/sweep/`; raw:
|
||||
`results/efficiency_sweep.jsonl`; checkpoints `results/{half,quarter,tiny}_best.pth`
|
||||
(gitignored).
|
||||
|
||||
| Variant | Params | vs 2.23M | Clean-test PCK@20 | PCK@50 | MPJPE | Best epoch |
|
||||
|---|---|---|---|---|---|---|
|
||||
| full (reference, meas. a) | 2,225,042 | 1× | 96.61% | 99.11% | 0.0094 | 36 |
|
||||
| **half** | **843,834** | **0.38×** | **96.62%** | **99.47%** | **0.00898** | 23 |
|
||||
| quarter | 338,600 | 0.15× | 96.05% | 99.43% | 0.00928 | 50 |
|
||||
| tiny | 56,290 | 0.025× | 94.11% | 99.36% | 0.0125 | 47 |
|
||||
|
||||
Findings:
|
||||
|
||||
- **The half model (843k params) strictly dominates the full reference** on
|
||||
this dataset — equal PCK@20, better PCK@50 and MPJPE, converges in fewer
|
||||
epochs. The published 2.23M architecture is over-parameterized for its own
|
||||
benchmark.
|
||||
- **tiny (56k params, 1/39.5) holds 94.11% PCK@20** — a ~220 KB fp32 /
|
||||
~60 KB int8-class model in reach of severely constrained edge targets,
|
||||
at −2.5 pt from the full reference.
|
||||
- Caveats: in-domain (5-subject random-file split) like every number on this
|
||||
dataset; single run per variant; corruption-free test subset (52,560).
|
||||
Cross-domain behavior of compact variants is untested — ADR-150's evidence
|
||||
says capacity *hurts* cross-subject, so the compact end may generalize no
|
||||
worse, but that is a hypothesis, not a measurement.
|
||||
|
||||
### Compact-variant edge artifacts (MEASURED, 2026-06-11)
|
||||
|
||||
Edge pipeline for the **tiny** checkpoint (56,290 params), same machinery and
|
||||
protocol as the full-model edge rows above (this Windows box, torch
|
||||
2.12.0+cpu, onnxruntime 1.26.0; dynamic-batch opset-17 TorchScript export;
|
||||
static QDQ **Percentile(99.99) conv-only** int8 calibrated on **512**
|
||||
corruption-free TRAIN-split windows; accuracy on the identical 10k-window
|
||||
seed-42 clean test subset; latency = median ms/window over 3 interleaved
|
||||
reps, with the full-model fp32/int8 sessions interleaved as same-session
|
||||
references). Script: `tiny_edge_bench.py`; raw:
|
||||
`results/edge_optimization.json` (`tiny_variant`). Torch-vs-ORT parity on the
|
||||
stored fixture input: **max abs diff 1.5e-7 — PASS** (< 1e-4). The tiny fp32
|
||||
subset PCK@20 (94.11%) matches the full clean-test sweep figure (94.11%)
|
||||
exactly, so the subset remains representative.
|
||||
|
||||
Two forced deviations, both recorded in the JSON:
|
||||
|
||||
1. **Adaptive-pool export rewrite.** tiny's derived stride schedule
|
||||
`[2,1,1,1]` leaves feature width 16, and the TorchScript exporter rejects
|
||||
`AdaptiveAvgPool2d((15,1))` when 15 is not a factor of the input height
|
||||
(the full model never hit this — its width was exactly 15). Since the
|
||||
pool over a fixed-size map is a fixed linear operator, the export wrapper
|
||||
replaces it with `mean(-1)` (W axis, a factor) + a constant averaging
|
||||
matmul using PyTorch's exact bin rule; the parity check (vs the original
|
||||
torch model with the real pool) proves exactness.
|
||||
2. **Calibration count 512, not "~500"**: ORT 1.26's histogram collector
|
||||
`np.asarray()`'s the per-batch maxima, so the calibration count must be a
|
||||
multiple of the 64-window calibration batch or the ragged last batch
|
||||
crashes it (the earlier static-PTQ run dodged this by using exactly 512).
|
||||
|
||||
| Variant | Disk size | Batch 1 (ms/win) | Batch 64 (ms/win) | PCK@20 | PCK@50 | MPJPE |
|
||||
|---|---|---|---|---|---|---|
|
||||
| full ONNX fp32 (same-session ref) | 8.97 MB | 2.27 | 1.42 | 96.68% | 99.15% | 0.00936 |
|
||||
| full static QDQ Percentile conv-only (same-session ref) | 2.53 MB | 5.53 | 3.82 | 96.61% | 99.16% | 0.01031 |
|
||||
| **tiny ONNX fp32** | **0.295 MB** | **0.66** | **0.24** | **94.11%** | 99.37% | 0.01253 |
|
||||
| tiny static QDQ Percentile conv-only | 0.248 MB | 0.85 | 1.03 | 92.68% | 99.33% | 0.01491 |
|
||||
|
||||
(tiny torch `.pth` checkpoint for reference: 0.34 MB on disk; 56,290 fp32
|
||||
params ≈ 225 KB of weights.)
|
||||
|
||||
Findings:
|
||||
|
||||
- **The smallest deployable WiFlow-class model is the tiny ONNX fp32
|
||||
artifact: ~295 KB on disk, 0.66 ms/window batch-1 CPU (~1,500 windows/s),
|
||||
94.1% PCK@20** — 30× smaller and ~3.4× faster (in-session) than the full
|
||||
ONNX fp32 model for −2.6 pt PCK@20.
|
||||
- **int8 is a bad trade at this scale.** Static QDQ conv-only — the recipe
|
||||
that cost the full model only 0.07 pt — costs tiny **−1.43 pt** PCK@20
|
||||
(94.11 → 92.68%) and +19% MPJPE, saves only 47 KB (−16%; QDQ scales and
|
||||
the fp32 BN/attention glue are proportionally larger in a small graph),
|
||||
and is *slower* than tiny fp32 (0.85 vs 0.66 ms b1; 1.03 vs 0.24 ms b64 —
|
||||
QDQ kernel overhead dominates when the convs are this small). A 56k-param
|
||||
model has little redundancy left to absorb weight+activation rounding.
|
||||
- Deployment guidance, compact edition: ship tiny as **ONNX fp32** — at
|
||||
295 KB the int8 size saving solves no real constraint and costs accuracy
|
||||
and speed. If ~250 KB vs ~295 KB ever matters, weight-only quantization
|
||||
would be the thing to try next, not QDQ.
|
||||
|
||||
## Measurement (b): BLOCKED-ON-DATA (attempted 2026-06-10)
|
||||
|
||||
The fine-tune-on-ESP32 measurement stopped at dataset characterization, per the
|
||||
pre-registered stop rule (<2,000 paired windows). Findings (MEASURED):
|
||||
|
||||
- **Only one trainable paired dataset exists**: `ruvultra:~/work/cog-pose-train/paired.jsonl`
|
||||
— 1,077 windows (one subject, one room, one 29.9-min session, single node;
|
||||
CSI [56, 20]; 17 COCO keypoints, MediaPipe confidence mean 0.44 — only 264
|
||||
windows pass ADR-079's own conf>0.5 training filter). Prior measured attempts
|
||||
on this exact set: 0–3% torso-PCK@20 (temporal splits, three independent
|
||||
pipelines). Fine-tuning a 2.23M-param model on ~860 train windows would
|
||||
measure memorization, not transfer.
|
||||
- **The April session behind the old "92.9% PCK@20" claim is lost** (345
|
||||
samples, 35 subcarriers; raw CSI gone from ruvzen/ruvultra/cognitum-v0; only
|
||||
a 69-sample predictions+GT holdout survives at `models/wiflow-real/eval-holdout.jsonl`).
|
||||
- **Forensic recheck of that holdout RETRACTS the 92.9% figure**: the trainer's
|
||||
`pck()` used an absolute 0.2 image-unit threshold (not torso-normalized) and
|
||||
the model output a **constant pose** (pred std 0.0000 across 69 near-static
|
||||
frames; a mean predictor scores 100% under the same protocol). The
|
||||
torso-normalized PCK@20 on the same holdout is 19.1%. This corroborates the
|
||||
2026-05-11 audit retraction (CHANGELOG, PR #535); stale doc citations were
|
||||
removed 2026-06-10 (user-guide, readme-details, ADR-152 §2.1.3). The §2.2
|
||||
no-citation rule now applies to ADR-079 accuracy claims.
|
||||
|
||||
Unblock criteria: a paired collection session of ≥2k windows (≈35+ min at the
|
||||
observed stride; multi-pose, conf>0.5, ideally with the §2.1.3 two-checkerboard
|
||||
calibration), plus a re-baselined our-pipeline number under torso-PCK@20 on the
|
||||
same split. WiFlow-STD assets stand ready on ruvultra (`~/wiflow-std-bench/`).
|
||||
Also worth investigating: ADR-079's protocol predicts ~9k windows per 30 min;
|
||||
the May session under-delivered ~8× (aligner drop rate?).
|
||||
|
||||
## Measurement (b) (MEASURED 2026-06-10/11)
|
||||
|
||||
The data baseline unblocked: the 2026-06-10 22:10–22:40 collection session produced
|
||||
**2,046 paired windows** (`ruvultra:~/wiflow-std-bench/paired-20260610.jsonl`; ONE
|
||||
subject, ONE room, ONE ESP32 node, varied poses: walk/raise/squat/kick/wave/turn/
|
||||
jump/sit; aligner `scripts/align-ground-truth.js`, non-overlapping 20-frame windows
|
||||
~0.42 s; 17 COCO keypoints in normalized [0,1] camera coords; MediaPipe confidence
|
||||
mean 0.802, min 0.692 — all windows pass the conf>0.5 filter). The −4 h timestamp
|
||||
bug and the empty-frame confidence-dilution aligner findings are recorded
|
||||
separately; results only here. Trained on ruvultra (RTX 5080, torch 2.11+cu128,
|
||||
fp32, batch 32, GPU shared with the efficiency sweep). Scripts mirrored in
|
||||
`remote/measb/`; raw metrics + full training curves in `results/measurement_b.json`.
|
||||
|
||||
### Two new aligner/dataset findings (forced deviations, MEASURED)
|
||||
|
||||
1. **`csi_shape` is heterogeneous, not [70, 20]**: 1,347× [70,20], 284× [134,20],
|
||||
243× [26,20], 130× [12,20], 42× [20,20]. The ESP32 stream emits mixed frame
|
||||
types and `extractCsiMatrix` stamps each window's subcarrier count from
|
||||
`window[0].subcarriers`, zero-padding/truncating the other frames — even
|
||||
native-70 windows contain ~20.4% internally zero-padded short frames
|
||||
(subcarriers 40–69 all-zero). Handling: the primary suite ("all 2,046")
|
||||
linearly resamples every frame's subcarrier axis to 70 bins (identity for
|
||||
native-70 frames) so the pre-registered n and split sizes hold; a secondary
|
||||
suite restricts to the 1,347 native [70,20] windows as a homogeneity check.
|
||||
2. **Aligner layout bug**: `extractCsiMatrix` fills `matrix[f * nSc + s]`
|
||||
(frame-major) but declares `shape: [nSc, nFrames]` — the stored shape label is
|
||||
transposed relative to the data. Confirmed by coherent per-frame zero-tails;
|
||||
corrected on load (`reshape(nFrames, nSc).T`).
|
||||
|
||||
### Protocol (pre-registered, followed)
|
||||
|
||||
Temporal split, no shuffling across time: first 70% train (1,432), next 15% val
|
||||
(307), last 15% test (307); seed 42 elsewhere. Model: learned 1×1 Conv1d 70→540
|
||||
adapter prepended to the upstream WiFlow-STD trunk; K=17 via the parameter-free
|
||||
adaptive pool (`AdaptiveAvgPool2d((17,1))` — pretrained weights load strict for
|
||||
any K). CSI normalized by the TRAIN-split p99 amplitude (129.7 all / 130.9
|
||||
native-70), clipped to [0,1]. Three runs, ≤60 epochs, early-stop patience 8 on
|
||||
val MPJPE, AdamW (adapter lr 1e-4; pretrained trunk lr 1e-5, 10× lower; scratch
|
||||
all 1e-4), fp32. Pretrained init = the measurement-(a) **retrained** checkpoint
|
||||
(`upstream/test/best_pose_model.pth`, ~96% PCK@20 on WiFlow data; the
|
||||
`att.`/`final_conv.` key remap from `eval_repro.py` applied defensively — a no-op,
|
||||
that checkpoint already uses post-rename keys). Frozen-trunk run: trunk
|
||||
`requires_grad=False` **and** held in `.eval()` so BatchNorm running stats cannot
|
||||
drift — a pure transfer probe; only the 70→540 adapter (38,340 params) trains.
|
||||
|
||||
PCK is torso-normalized with **torso = ‖l_shoulder(5) − l_hip(11)‖** (upstream
|
||||
`calculate_pck` math — per-frame norm clamped at 0.01, mean over keypoints ×
|
||||
frames — but upstream's `NECK_IDX/PELVIS_IDX = 2, 12` is a 15-keypoint
|
||||
convention; on 17-kp COCO those indices are right_eye/right_hip, so the indices
|
||||
were replaced, not the math). MPJPE is in normalized image units (not meters).
|
||||
|
||||
### Results — primary suite, all 2,046 windows (test = last 307)
|
||||
|
||||
| Run | PCK@10 | PCK@20 | PCK@30 | PCK@40 | PCK@50 | MPJPE | pred std | best ep |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| **mean-pose baseline** (honesty bar) | **73.1%** | **95.9%** | **98.7%** | 99.3% | 99.3% | **0.0148** | 0 (by constr.) | — |
|
||||
| (i) pretrained-init, full fine-tune | 26.0% | 65.0% | 88.0% | 96.4% | 98.9% | 0.0313 | 0.0113 | 58/60 |
|
||||
| (ii) scratch | 0.0% | 0.0% | 0.0% | 0.0% | 0.0% | 0.2554 | 0.0002 | 4 (stop @13) |
|
||||
| (iii) frozen-trunk (adapter only) | 0.0% | 0.0% | 0.2% | 3.2% | 14.4% | 0.1260 | 0.0073 | 59/60 |
|
||||
|
||||
Secondary suite (native [70,20] windows only, n=1,347, test=202) reproduces the
|
||||
same ordering: mean-baseline 96.0% / pretrained 67.1% / scratch 0.0% /
|
||||
frozen-trunk 0.0% PCK@20 (MPJPE 0.0153 / 0.0318 / 0.2236 / 0.1343) — the
|
||||
subcarrier-resampling choice does not change any conclusion.
|
||||
|
||||
### Interpretation
|
||||
|
||||
- **Did pretraining-transfer happen? Partially — as optimization transfer, not
|
||||
feature transfer, and not past the honesty bar.**
|
||||
- *Pretrained vs scratch*: dramatic (65.0% vs 0.0% PCK@20). The pretrained init
|
||||
is the only configuration that trains at all under the pre-registered budget.
|
||||
- *Frozen-trunk*: near-zero (0.0% PCK@20, 14.4% @50). WiFlow-STD's frozen
|
||||
features do **not** transfer to our ESP32 domain through a linear subcarrier
|
||||
adapter — the pretrained benefit is a well-conditioned initialization (incl.
|
||||
calibrated BN/output scales), not reusable CSI→pose features.
|
||||
- *Everything vs mean-pose baseline*: **no run beats it.** A constant
|
||||
train-mean pose scores 95.9% torso-PCK@20 / 0.0148 MPJPE on this test split,
|
||||
because a single subject in one camera frame barely moves in normalized
|
||||
coordinates. The fine-tuned model is a real, non-constant model
|
||||
(pred std 0.0113 > 0 — passes the constant-pose detector that retracted the
|
||||
old 92.9% figure) but its deviations from the mean hurt: it fits train-period
|
||||
temporal dynamics that do not generalize across the temporal split.
|
||||
- **Verdict for ADR-152 §2.2(b): fine-tuning WiFlow-STD on this dataset does not
|
||||
demonstrate CSI→pose signal beyond the mean pose.** Until a model beats the
|
||||
mean-pose baseline on a temporal split, no PCK number from this line may be
|
||||
cited as pose-estimation capability.
|
||||
|
||||
### Caveats (honest, pre-registered)
|
||||
|
||||
- Single subject, single room, single session (30 min), single ESP32 node —
|
||||
in-domain temporal split only; nothing here speaks to cross-room or
|
||||
cross-subject generalization.
|
||||
- 2k windows vs the 360k-window WiFlow-STD corpus — **NOT comparable** to the
|
||||
~96% in-domain measurement-(a) number, and the published 97.25% even less so.
|
||||
- The scratch run's total collapse (it cannot even reach the mean pose; its
|
||||
output BatchNorm/SiLU head must learn output scale from random init at lr 1e-4)
|
||||
is an optimization outcome under the fixed budget, not proof the architecture
|
||||
cannot learn from scratch — the pretrained-vs-scratch gap partially reflects
|
||||
this conditioning advantage.
|
||||
- Mixed-subcarrier frames (finding 1) mean even the "clean" windows carry ~20%
|
||||
zero-padded frames; collection-side frame-type filtering should precede the
|
||||
next session.
|
||||
- Mean-baseline PCK is inflated by low pose variance relative to torso size
|
||||
(~0.2–0.3 image units); PCK@10 (73.1%) shows the same ceiling effect at a
|
||||
stricter threshold — the bar is the bar, but a livelier dataset would lower it.
|
||||
|
||||
## Pending
|
||||
|
||||
- (b) fine-tune on our ESP32 17-keypoint eval set — **MEASURED 2026-06-10/11**,
|
||||
see above: no run beats the mean-pose baseline; pretraining transfers as
|
||||
optimization aid only.
|
||||
- (c) our internal WiFlow on their dataset (15-keypoint subset mapping) — also
|
||||
affected: there is currently no validated internal pose model to compare
|
||||
(the 92.9% artifact is retracted; the MM-Fi SOTA models in ADR-150 §3 are a
|
||||
different input domain).
|
||||
@@ -0,0 +1,200 @@
|
||||
"""Shared infrastructure for the LOCAL wiflow-std benchmark scripts (ADR-152).
|
||||
|
||||
This module is the single canonical implementation of the helpers that were
|
||||
previously copy-pasted across eval_repro.py / quantize_bench.py /
|
||||
onnx_bench.py / eval_ort_accuracy.py / export_to_safetensors.py:
|
||||
|
||||
- ``import_upstream()`` -- sys.path setup + the models-package stub that
|
||||
works around the upstream import bug, plus the >1GB np.load mmap patch
|
||||
- ``install_np_load_mmap_patch()`` -- the mmap patch on its own
|
||||
- ``remap_legacy_keys()`` / ``load_remapped_state()`` -- checkpoint
|
||||
key remap for the pre-rename released checkpoint
|
||||
- ``load_wiflow_model()`` -- WiFlowPoseModel from a checkpoint, eval mode
|
||||
- ``set_seed()`` -- mirrors upstream run.py seeding exactly
|
||||
- ``evaluate()`` -- THE canonical batch-weighted PCK/MPJPE evaluation loop
|
||||
(thresholds 0.1-0.5, upstream utils/metrics.py math); accepts either a
|
||||
torch nn.Module or an onnxruntime InferenceSession
|
||||
|
||||
The scripts under remote/ deploy to ruvultra as standalone single files and
|
||||
therefore intentionally inline private copies of these helpers; when editing
|
||||
them, treat this module as the reference implementation and keep the copies
|
||||
in sync.
|
||||
"""
|
||||
|
||||
import os
|
||||
import random
|
||||
import sys
|
||||
import time
|
||||
import types
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
UPSTREAM = os.path.join(HERE, "upstream")
|
||||
RESULTS = os.path.join(HERE, "results")
|
||||
|
||||
DEFAULT_THRESHOLDS = (0.1, 0.2, 0.3, 0.4, 0.5)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# >1GB np.load mmap patch
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# csi_windows.npy is ~13 GB; mmap large arrays instead of loading into RAM
|
||||
# (loading it eagerly needs ~15 GB).
|
||||
_np_load = np.load
|
||||
|
||||
|
||||
def _np_load_mmap(path, *a, **kw):
|
||||
if (isinstance(path, str) and path.endswith(".npy")
|
||||
and os.path.getsize(path) > 1 << 30 and "mmap_mode" not in kw):
|
||||
kw["mmap_mode"] = "r"
|
||||
return _np_load(path, *a, **kw)
|
||||
|
||||
|
||||
def install_np_load_mmap_patch():
|
||||
"""Globally patch np.load so .npy files >1GB are mmap'd read-only.
|
||||
|
||||
Idempotent. Patching the numpy module attribute is equivalent to the
|
||||
historical ``upstream_dataset.np.load = _np_load_mmap`` (dataset.np IS
|
||||
the numpy module), but works regardless of import order.
|
||||
"""
|
||||
np.load = _np_load_mmap
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# upstream import shim
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def import_upstream(mmap_patch=True):
|
||||
"""Make the upstream WiFlow-STD clone importable; returns its path.
|
||||
|
||||
Upstream bug: models/__init__.py imports TemporalConvNet, which
|
||||
models/tcn.py does not define -- the package fails to import as
|
||||
published. Register a stub package so the broken __init__ never
|
||||
executes; submodules (models.pose_model etc.) still resolve via
|
||||
__path__. Idempotent.
|
||||
"""
|
||||
if UPSTREAM not in sys.path:
|
||||
sys.path.insert(0, UPSTREAM)
|
||||
if "models" not in sys.modules:
|
||||
_models_pkg = types.ModuleType("models")
|
||||
_models_pkg.__path__ = [os.path.join(UPSTREAM, "models")]
|
||||
sys.modules["models"] = _models_pkg
|
||||
if mmap_patch:
|
||||
install_np_load_mmap_patch()
|
||||
return UPSTREAM
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# checkpoint loading
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# The released checkpoint predates the published code: modules were renamed
|
||||
# att -> attention, final_conv -> decoder (param count identical, 2.23M).
|
||||
LEGACY_RENAMES = {"att.": "attention.", "final_conv.": "decoder."}
|
||||
|
||||
|
||||
def remap_legacy_keys(state):
|
||||
"""Remap pre-rename state_dict keys; no-op for already-new-style keys."""
|
||||
return {next((new + k[len(old):] for old, new in LEGACY_RENAMES.items()
|
||||
if k.startswith(old)), k): v
|
||||
for k, v in state.items()}
|
||||
|
||||
|
||||
def load_remapped_state(path, map_location="cpu"):
|
||||
"""torch.load (weights_only) + legacy key remap."""
|
||||
state = torch.load(path, map_location=map_location, weights_only=True)
|
||||
return remap_legacy_keys(state)
|
||||
|
||||
|
||||
def load_wiflow_model(checkpoint, map_location="cpu", dropout=0.5):
|
||||
"""Full-size WiFlowPoseModel from a checkpoint, strict load, eval mode."""
|
||||
import_upstream()
|
||||
from models.pose_model import WiFlowPoseModel
|
||||
model = WiFlowPoseModel(dropout=dropout)
|
||||
model.load_state_dict(load_remapped_state(checkpoint, map_location),
|
||||
strict=True)
|
||||
model.eval()
|
||||
return model
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# seeding
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def set_seed(seed=42):
|
||||
# mirror upstream run.py exactly
|
||||
random.seed(seed)
|
||||
np.random.seed(seed)
|
||||
torch.manual_seed(seed)
|
||||
if torch.cuda.is_available():
|
||||
torch.cuda.manual_seed(seed)
|
||||
torch.cuda.manual_seed_all(seed)
|
||||
torch.backends.cudnn.deterministic = True
|
||||
torch.backends.cudnn.benchmark = False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# THE canonical evaluation loop
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def evaluate(model, loader, device=None, dtype=None, label="",
|
||||
thresholds=DEFAULT_THRESHOLDS, progress_every=50):
|
||||
"""Batch-weighted PCK/MPJPE over a DataLoader (upstream metrics math).
|
||||
|
||||
``model`` may be a torch nn.Module (optionally evaluated on ``device``
|
||||
with inputs cast to ``dtype``) or an onnxruntime InferenceSession.
|
||||
Per-threshold PCK values are independent in upstream calculate_pck, so
|
||||
evaluating a superset of thresholds never changes any individual value.
|
||||
|
||||
Returns {"samples", "mpjpe", "pck@10".."pck@50", "wall_seconds"}.
|
||||
"""
|
||||
import_upstream()
|
||||
from utils.metrics import calculate_mpjpe, calculate_pck
|
||||
|
||||
is_ort = hasattr(model, "get_inputs") # onnxruntime InferenceSession
|
||||
if is_ort:
|
||||
inp = model.get_inputs()[0].name
|
||||
|
||||
def forward(bx):
|
||||
return torch.from_numpy(model.run(None, {inp: bx.numpy()})[0])
|
||||
else:
|
||||
model.eval()
|
||||
|
||||
def forward(bx):
|
||||
if device is not None:
|
||||
bx = bx.to(device)
|
||||
if dtype is not None:
|
||||
bx = bx.to(dtype)
|
||||
return model(bx).float()
|
||||
|
||||
thresholds = list(thresholds)
|
||||
totals = {t: 0.0 for t in thresholds}
|
||||
total_mpe, n = 0.0, 0
|
||||
t0 = time.time()
|
||||
with torch.no_grad():
|
||||
for batch_idx, (bx, by) in enumerate(loader):
|
||||
out = forward(bx)
|
||||
if device is not None and not is_ort:
|
||||
by = by.to(device)
|
||||
mpe = calculate_mpjpe(out, by)
|
||||
pck = calculate_pck(out, by, thresholds=thresholds)
|
||||
bs = by.size(0)
|
||||
total_mpe += mpe * bs
|
||||
for t in totals:
|
||||
totals[t] += pck[t] * bs
|
||||
n += bs
|
||||
if batch_idx % progress_every == 0:
|
||||
tag = f"[{label}] " if label else ""
|
||||
pck20 = totals.get(0.2)
|
||||
pck20_str = f"pck20={pck20 / n:.4f} " if pck20 is not None else ""
|
||||
print(f" {tag}batch {batch_idx}: n={n} {pck20_str}"
|
||||
f"mpjpe={total_mpe / n:.4f} ({time.time() - t0:.0f}s)",
|
||||
flush=True)
|
||||
return {
|
||||
"samples": n,
|
||||
"mpjpe": total_mpe / n,
|
||||
**{f"pck@{int(t * 100)}": totals[t] / n for t in thresholds},
|
||||
"wall_seconds": time.time() - t0,
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
"""ADR-152 edge optimization: accuracy of the ONNX fp32 and ORT-dynamic-int8
|
||||
models on the same corruption-free 10k test subset used by quantize_bench.py.
|
||||
|
||||
The torch dynamic-int8 path quantizes nothing (no nn.Linear in the model), so
|
||||
the only real int8 datapoint for the paper's "~2.2 MB int8" claim is the
|
||||
onnxruntime dynamically quantized model -- this script measures what that
|
||||
quantization costs in PCK/MPJPE.
|
||||
|
||||
Usage:
|
||||
.venv/Scripts/python.exe eval_ort_accuracy.py \
|
||||
--data-dir <preprocessed_csi_data> [--subset 10000]
|
||||
|
||||
Writes/merges into results/edge_optimization.json under key "onnx_accuracy".
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, HERE)
|
||||
|
||||
from _bench_common import RESULTS, evaluate # noqa: E402
|
||||
from quantize_bench import build_test_subset # noqa: E402 (sets up upstream imports)
|
||||
|
||||
|
||||
def evaluate_ort(sess, loader, label):
|
||||
"""ORT-session evaluation via the canonical _bench_common.evaluate loop."""
|
||||
return evaluate(sess, loader, label=label)
|
||||
|
||||
|
||||
def main():
|
||||
import onnxruntime as ort
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--data-dir", default=os.path.join(
|
||||
os.path.expanduser("~"), ".cache", "kagglehub", "datasets", "kaka2434",
|
||||
"wiflow-dataset", "versions", "1", "preprocessed_csi_data"))
|
||||
parser.add_argument("--subset", type=int, default=10000)
|
||||
parser.add_argument("--out", default=os.path.join(RESULTS, "edge_optimization.json"))
|
||||
args = parser.parse_args()
|
||||
|
||||
loader, _n_clean = build_test_subset(args.data_dir, args.subset)
|
||||
results = {}
|
||||
for label, fname in (("onnx_fp32", "retrained_fp32_dynamic.onnx"),
|
||||
("onnx_int8_ort_dynamic", "retrained_int8_ort_dynamic.onnx")):
|
||||
path = os.path.join(RESULTS, fname)
|
||||
if not os.path.exists(path):
|
||||
results[label] = {"error": f"{fname} not found; run onnx_bench.py first"}
|
||||
continue
|
||||
sess = ort.InferenceSession(path, providers=["CPUExecutionProvider"])
|
||||
print(f"=== accuracy: {label} ({fname}) ===")
|
||||
results[label] = evaluate_ort(sess, loader, label)
|
||||
print(json.dumps(results[label], indent=2))
|
||||
|
||||
merged = {}
|
||||
if os.path.exists(args.out):
|
||||
with open(args.out) as f:
|
||||
merged = json.load(f)
|
||||
merged["onnx_accuracy"] = results
|
||||
with open(args.out, "w") as f:
|
||||
json.dump(merged, f, indent=2)
|
||||
print(f"wrote {args.out}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,102 @@
|
||||
"""ADR-152 §2.2 measurement (a): reproduce WiFlow-STD (DY2434) published test metrics.
|
||||
|
||||
Runs the released pretrained checkpoint (upstream/best_pose_model.pth) against the
|
||||
released Kaggle dataset (kaka2434/wiflow-dataset) using the upstream code path:
|
||||
identical dataset class, identical file-level 70/15/15 split at seed 42, identical
|
||||
PCK/MPJPE implementations (utils/metrics.py).
|
||||
|
||||
Published claims (README, "Setting 1 random split"):
|
||||
PCK@20 97.25% | PCK@30 98.63% | PCK@40 99.16% | PCK@50 99.48% | MPJPE 0.007 m
|
||||
|
||||
Usage:
|
||||
.venv/Scripts/python.exe eval_repro.py --data-dir <dir containing csi_windows.npy>
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
import torch
|
||||
from torch.utils.data import DataLoader
|
||||
|
||||
from _bench_common import (UPSTREAM, evaluate, import_upstream,
|
||||
load_remapped_state, set_seed)
|
||||
|
||||
import_upstream() # sys.path + models stub + >1GB np.load mmap patch
|
||||
|
||||
from dataset import PreprocessedCSIKeypointsDataset, create_preprocessed_train_val_test_loaders # noqa: E402
|
||||
from models.pose_model import WiFlowPoseModel # noqa: E402
|
||||
|
||||
|
||||
def find_data_dir(root):
|
||||
for dirpath, _dirnames, filenames in os.walk(root):
|
||||
if "csi_windows.npy" in filenames:
|
||||
return dirpath
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--data-dir", required=True,
|
||||
help="Directory containing csi_windows.npy (searched recursively)")
|
||||
parser.add_argument("--checkpoint", default=os.path.join(UPSTREAM, "best_pose_model.pth"))
|
||||
parser.add_argument("--batch-size", type=int, default=64)
|
||||
parser.add_argument("--out", default=os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
||||
"results", "repro_a.json"))
|
||||
args = parser.parse_args()
|
||||
|
||||
data_dir = args.data_dir
|
||||
if not os.path.exists(os.path.join(data_dir, "csi_windows.npy")):
|
||||
located = find_data_dir(data_dir)
|
||||
if located is None:
|
||||
sys.exit(f"csi_windows.npy not found under {data_dir}")
|
||||
data_dir = located
|
||||
print(f"data dir: {data_dir}")
|
||||
|
||||
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
||||
print(f"device: {device}, torch {torch.__version__}")
|
||||
|
||||
set_seed(42)
|
||||
|
||||
dataset = PreprocessedCSIKeypointsDataset(
|
||||
data_dir=data_dir, keypoint_scale=1000.0, enable_temporal_clean=True)
|
||||
|
||||
# split must match upstream: file-level shuffle at random_seed=42, 70/15/15
|
||||
_train_loader, _val_loader, test_loader = create_preprocessed_train_val_test_loaders(
|
||||
dataset=dataset, batch_size=args.batch_size, num_workers=0, random_seed=42)
|
||||
|
||||
model = WiFlowPoseModel(dropout=0.5).to(device)
|
||||
# released checkpoint predates the published code: modules were renamed
|
||||
# att -> attention, final_conv -> decoder (param count identical, 2.23M)
|
||||
state = load_remapped_state(args.checkpoint, map_location=device)
|
||||
model.load_state_dict(state, strict=True)
|
||||
n_params = sum(p.numel() for p in model.parameters())
|
||||
print(f"checkpoint: {args.checkpoint} ({n_params/1e6:.2f}M params)")
|
||||
|
||||
# upstream also evaluates with drop_last=True; we report the full test set
|
||||
# (drop_last=False) and the drop_last variant for exact comparability
|
||||
results = {"published": {"pck@20": 0.9725, "pck@30": 0.9863, "pck@40": 0.9916,
|
||||
"pck@50": 0.9948, "mpjpe": 0.007},
|
||||
"params_millions": n_params / 1e6,
|
||||
"data_dir": data_dir,
|
||||
"device": str(device)}
|
||||
|
||||
print("=== test set (full, drop_last=False) ===")
|
||||
results["test_full"] = evaluate(model, test_loader, device=device)
|
||||
print(json.dumps(results["test_full"], indent=2))
|
||||
|
||||
test_loader_dl = DataLoader(test_loader.dataset, batch_size=args.batch_size,
|
||||
shuffle=False, drop_last=True)
|
||||
print("=== test set (drop_last=True, as upstream train.py) ===")
|
||||
results["test_drop_last"] = evaluate(model, test_loader_dl, device=device)
|
||||
print(json.dumps(results["test_drop_last"], indent=2))
|
||||
|
||||
os.makedirs(os.path.dirname(args.out), exist_ok=True)
|
||||
with open(args.out, "w") as f:
|
||||
json.dump(results, f, indent=2)
|
||||
print(f"wrote {args.out}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,174 @@
|
||||
"""ADR-152 §2.2: export the retrained WiFlow-STD PyTorch checkpoint to
|
||||
safetensors with tch-rs (VarStore) variable names, plus a numerical-parity
|
||||
fixture for the Rust port.
|
||||
|
||||
Outputs (all under results/, gitignored):
|
||||
retrained_wiflow_std.safetensors -- 248 f32 tensors named exactly as the
|
||||
Rust WiFlowStdModel VarStore expects
|
||||
(see wiflow_std/model.rs
|
||||
`dump_variable_names` for the
|
||||
authoritative name dump)
|
||||
parity_fixture.npz -- deterministic input (seed 42,
|
||||
shape (2, 540, 20), uniform [0,1]) and
|
||||
the Python model's eval-mode output
|
||||
parity_fixture.json -- same data as flattened f32 lists, for
|
||||
the dependency-free Rust test
|
||||
(tests/test_wiflow_std_parity.rs)
|
||||
|
||||
PyTorch -> tch key mapping (derived from the VarStore dump, not guessed):
|
||||
|
||||
tcn.network.{i}.conv1_group.weight -> tcn{i}.conv1_group.weight
|
||||
tcn.network.{i}.bn*_{group,pw}.<leaf> -> tcn{i}.bn*_{group,pw}.<leaf>
|
||||
tcn.network.{i}.downsample.0.weight -> tcn{i}.ds_conv.weight
|
||||
tcn.network.{i}.downsample.1.<leaf> -> tcn{i}.ds_bn.<leaf>
|
||||
up.block.{0,1,4,5,8,9}.<leaf> -> conv_in.{conv1,bn1,conv2,bn2,conv3,bn3}.<leaf>
|
||||
up.downsample.{0,1}.<leaf> -> conv_in.{ds_conv,ds_bn}.<leaf>
|
||||
residual_blocks.{i}.block.{...}.<leaf> -> conv{i}.{conv1..bn3}.<leaf>
|
||||
residual_blocks.{i}.downsample.{0,1} -> conv{i}.{ds_conv,ds_bn}
|
||||
attention.{width,height}_axis.qkv_transform.weight
|
||||
-> attention.{width,height}.qkv.weight
|
||||
attention.{width,height}_axis.bn_* -> attention.{width,height}.bn_*
|
||||
decoder.{0,1,3,4}.<leaf> -> {dec_conv1,dec_bn1,dec_conv2,dec_bn2}.<leaf>
|
||||
*.num_batches_tracked -> dropped (tch BatchNorm has no such buffer)
|
||||
|
||||
Legacy upstream names (att. -> attention., final_conv. -> decoder.) are
|
||||
remapped first, exactly as eval_repro.py does for the released checkpoint.
|
||||
|
||||
Usage:
|
||||
.venv/Scripts/python.exe export_to_safetensors.py
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
from safetensors.torch import save_file
|
||||
|
||||
from _bench_common import RESULTS, import_upstream, remap_legacy_keys
|
||||
|
||||
import_upstream() # sys.path + models stub
|
||||
|
||||
from models.pose_model import WiFlowPoseModel # noqa: E402
|
||||
|
||||
CHECKPOINT = os.path.join(RESULTS, "retrained_best_pose_model.pth")
|
||||
|
||||
# Sequential index -> tch sub-name inside one ConvBlock1/AsymmetricConvBlock:
|
||||
# [Conv2d(0), BN(1), SiLU(2), Dropout2d(3), Conv2d(4), BN(5), SiLU(6),
|
||||
# Dropout2d(7), Conv2d(8), BN(9)]
|
||||
_BLOCK_IDX = {"0": "conv1", "1": "bn1", "4": "conv2", "5": "bn2",
|
||||
"8": "conv3", "9": "bn3"}
|
||||
_DS_IDX = {"0": "ds_conv", "1": "ds_bn"}
|
||||
_DECODER_IDX = {"0": "dec_conv1", "1": "dec_bn1", "3": "dec_conv2",
|
||||
"4": "dec_bn2"}
|
||||
|
||||
|
||||
def _conv_block(new_prefix: str, rest: str) -> str:
|
||||
m = re.fullmatch(r"block\.(\d+)\.(.+)", rest)
|
||||
if m:
|
||||
return f"{new_prefix}.{_BLOCK_IDX[m.group(1)]}.{m.group(2)}"
|
||||
m = re.fullmatch(r"downsample\.(\d+)\.(.+)", rest)
|
||||
if m:
|
||||
return f"{new_prefix}.{_DS_IDX[m.group(1)]}.{m.group(2)}"
|
||||
raise KeyError(f"unmapped conv-block key: {new_prefix} / {rest}")
|
||||
|
||||
|
||||
def map_key(key: str) -> str:
|
||||
"""Map one PyTorch state_dict key to the tch VarStore name."""
|
||||
m = re.fullmatch(r"tcn\.network\.(\d+)\.(.+)", key)
|
||||
if m:
|
||||
i, rest = m.groups()
|
||||
rest = (rest.replace("downsample.0.", "ds_conv.")
|
||||
.replace("downsample.1.", "ds_bn."))
|
||||
return f"tcn{i}.{rest}"
|
||||
|
||||
m = re.fullmatch(r"up\.(.+)", key)
|
||||
if m:
|
||||
return _conv_block("conv_in", m.group(1))
|
||||
|
||||
m = re.fullmatch(r"residual_blocks\.(\d+)\.(.+)", key)
|
||||
if m:
|
||||
return _conv_block(f"conv{m.group(1)}", m.group(2))
|
||||
|
||||
m = re.fullmatch(r"attention\.(width|height)_axis\.(.+)", key)
|
||||
if m:
|
||||
axis, rest = m.groups()
|
||||
rest = rest.replace("qkv_transform.", "qkv.")
|
||||
return f"attention.{axis}.{rest}"
|
||||
|
||||
m = re.fullmatch(r"decoder\.(\d+)\.(.+)", key)
|
||||
if m:
|
||||
return f"{_DECODER_IDX[m.group(1)]}.{m.group(2)}"
|
||||
|
||||
raise KeyError(f"unmapped checkpoint key: {key}")
|
||||
|
||||
|
||||
def main():
|
||||
state = torch.load(CHECKPOINT, map_location="cpu", weights_only=True)
|
||||
if not isinstance(state, dict) or "tcn.network.0.conv1_group.weight" not in {
|
||||
k for k in state
|
||||
} | {k.replace("att.", "attention.") for k in state}:
|
||||
# tolerate trainer wrappers like {"model_state_dict": ...}
|
||||
for wrapper in ("model_state_dict", "state_dict", "model"):
|
||||
if isinstance(state, dict) and wrapper in state:
|
||||
state = state[wrapper]
|
||||
break
|
||||
|
||||
# Legacy upstream names predate the published code (_bench_common).
|
||||
state = remap_legacy_keys(state)
|
||||
|
||||
mapped = {}
|
||||
dropped = 0
|
||||
for k, v in state.items():
|
||||
if k.endswith("num_batches_tracked"):
|
||||
dropped += 1
|
||||
continue
|
||||
tch_key = map_key(k)
|
||||
if tch_key in mapped:
|
||||
raise KeyError(f"duplicate mapped key: {k} -> {tch_key}")
|
||||
mapped[tch_key] = v.detach().to(torch.float32).contiguous()
|
||||
|
||||
n_params = sum(v.numel() for k, v in mapped.items()
|
||||
if "running_" not in k)
|
||||
print(f"checkpoint tensors: {len(state)} "
|
||||
f"(dropped {dropped} num_batches_tracked)")
|
||||
print(f"mapped tensors: {len(mapped)}, "
|
||||
f"non-buffer params: {n_params/1e6:.6f}M")
|
||||
assert len(mapped) == 248, f"expected 248 tch variables, got {len(mapped)}"
|
||||
assert n_params == 2_225_042, f"param count mismatch: {n_params}"
|
||||
|
||||
st_path = os.path.join(RESULTS, "retrained_wiflow_std.safetensors")
|
||||
save_file(mapped, st_path)
|
||||
print(f"wrote {st_path}")
|
||||
|
||||
# ---- parity fixture --------------------------------------------------
|
||||
model = WiFlowPoseModel(dropout=0.5)
|
||||
model.load_state_dict(state, strict=True)
|
||||
model.eval()
|
||||
|
||||
gen = torch.Generator().manual_seed(42)
|
||||
x = torch.rand(2, 540, 20, generator=gen, dtype=torch.float32)
|
||||
with torch.no_grad():
|
||||
y = model(x)
|
||||
print(f"fixture input {tuple(x.shape)} -> output {tuple(y.shape)}, "
|
||||
f"output range [{y.min().item():.6f}, {y.max().item():.6f}]")
|
||||
|
||||
np.savez(os.path.join(RESULTS, "parity_fixture.npz"),
|
||||
input=x.numpy(), output=y.numpy())
|
||||
fixture = {
|
||||
"seed": 42,
|
||||
"input_shape": list(x.shape),
|
||||
"input": x.flatten().tolist(),
|
||||
"output_shape": list(y.shape),
|
||||
"output": y.flatten().tolist(),
|
||||
}
|
||||
json_path = os.path.join(RESULTS, "parity_fixture.json")
|
||||
with open(json_path, "w") as f:
|
||||
json.dump(fixture, f)
|
||||
print(f"wrote {os.path.join(RESULTS, 'parity_fixture.npz')}")
|
||||
print(f"wrote {json_path}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,148 @@
|
||||
"""Regenerate results/nan_windows_mask.npy + results/big_windows_mask.npy by
|
||||
scanning a PRISTINE kagglehub download of the WiFlow-STD dataset
|
||||
(kaka2434/wiflow-dataset v1, csi_windows.npy, 360,000 windows of 540x20).
|
||||
|
||||
============================ READ THIS FIRST ===============================
|
||||
This script MUST be run against an UNCLEANED copy of the dataset.
|
||||
|
||||
remote/clean_v2.py (and its predecessor clean_nan.py) repair the dataset by
|
||||
zeroing the corrupted windows IN PLACE, with no backup. A cleaned copy
|
||||
contains no non-finite values and no out-of-range amplitudes, so on a cleaned
|
||||
copy this scan produces ALL-FALSE masks -- silently wrong ground truth. The
|
||||
script errors out loudly in that case (see the sanity check in main()).
|
||||
|
||||
That irreversibility is exactly why the two committed mask files under
|
||||
results/ (gitignore-negated) are the canonical ground truth: once a download
|
||||
has been cleaned, the masks can NEVER be regenerated from it. Only run this
|
||||
on a fresh `kagglehub.dataset_download("kaka2434/wiflow-dataset")`.
|
||||
============================================================================
|
||||
|
||||
Criteria (per window; mirrors the original 2026-06-10 scan and the
|
||||
remote/clean_v2.py repair criteria):
|
||||
|
||||
nan mask: any non-finite value (NaN/Inf) anywhere in the 540x20 window
|
||||
big mask: max |finite value| > 1.5 (the data is otherwise [0,1]-normalized;
|
||||
the corrupted files contain garbage up to 3.4e38, float32 max)
|
||||
|
||||
Expected result on the pristine Kaggle download (RESULTS.md defect 5):
|
||||
nan: 9,070 True | big: 9,072 True | union: 9,072 -- all windows in dataset
|
||||
files 487-499 (the final 13 files), window indices 350,922-359,999.
|
||||
|
||||
Usage:
|
||||
PYTHONUTF8=1 .venv/Scripts/python.exe generate_corruption_masks.py \
|
||||
[--data-dir <dir containing csi_windows.npy>] [--out-dir results]
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
|
||||
import numpy as np
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
RESULTS = os.path.join(HERE, "results")
|
||||
|
||||
EXPECTED = {"nan": 9070, "big": 9072, "union": 9072,
|
||||
"files": (487, 499), "windows": (350922, 359999)}
|
||||
|
||||
|
||||
def scan(csi_path, chunk=4000):
|
||||
"""Chunked scan of the (mmap'd) windows array; returns (nan_mask, big_mask)."""
|
||||
csi = np.load(csi_path, mmap_mode="r")
|
||||
n = len(csi)
|
||||
nan_mask = np.zeros(n, dtype=bool)
|
||||
big_mask = np.zeros(n, dtype=bool)
|
||||
for i in range(0, n, chunk):
|
||||
block = np.asarray(csi[i:i + chunk])
|
||||
finite = np.isfinite(block)
|
||||
nan_mask[i:i + chunk] = (~finite).any(axis=(1, 2))
|
||||
big_mask[i:i + chunk] = (
|
||||
np.abs(np.where(finite, block, 0)).max(axis=(1, 2)) > 1.5)
|
||||
if (i // chunk) % 10 == 0:
|
||||
print(f" scanned {min(i + chunk, n):,}/{n:,} windows "
|
||||
f"(nan={int(nan_mask.sum()):,} big={int(big_mask.sum()):,})",
|
||||
flush=True)
|
||||
return nan_mask, big_mask
|
||||
|
||||
|
||||
def describe_files(data_dir, mask):
|
||||
"""Map marked windows to dataset file indices via window_info.npz."""
|
||||
info = os.path.join(data_dir, "window_info.npz")
|
||||
if not os.path.exists(info):
|
||||
return None
|
||||
w2f = np.load(info)["window_to_file"]
|
||||
return np.unique(w2f[mask])
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Regenerate the corruption masks from a PRISTINE "
|
||||
"(uncleaned) kagglehub download. See module docstring.")
|
||||
parser.add_argument("--data-dir", default=os.path.join(
|
||||
os.path.expanduser("~"), ".cache", "kagglehub", "datasets", "kaka2434",
|
||||
"wiflow-dataset", "versions", "1", "preprocessed_csi_data"),
|
||||
help="Directory containing csi_windows.npy (PRISTINE copy)")
|
||||
parser.add_argument("--out-dir", default=RESULTS,
|
||||
help="Where to write the two .npy masks")
|
||||
parser.add_argument("--chunk", type=int, default=4000,
|
||||
help="Windows per scan chunk (memory/speed tradeoff)")
|
||||
args = parser.parse_args()
|
||||
|
||||
csi_path = os.path.join(args.data_dir, "csi_windows.npy")
|
||||
if not os.path.exists(csi_path):
|
||||
sys.exit(f"csi_windows.npy not found in {args.data_dir}")
|
||||
|
||||
print(f"scanning {csi_path} (chunk={args.chunk}) ...")
|
||||
nan_mask, big_mask = scan(csi_path, args.chunk)
|
||||
union = nan_mask | big_mask
|
||||
print(f"nan: {int(nan_mask.sum()):,} | big: {int(big_mask.sum()):,} | "
|
||||
f"union: {int(union.sum()):,} of {len(union):,} windows")
|
||||
|
||||
# ---- sanity check: an all-False result means a CLEANED copy ------------
|
||||
if not union.any():
|
||||
sys.exit(
|
||||
"ERROR: scan found ZERO corrupted windows.\n"
|
||||
"\n"
|
||||
"The pristine Kaggle download (kaka2434/wiflow-dataset v1) is "
|
||||
"known to contain\n"
|
||||
"9,072 corrupted windows (NaN/Inf + amplitudes up to 3.4e38) in "
|
||||
"dataset files\n"
|
||||
"487-499 (RESULTS.md, reproducibility defect 5). Finding none "
|
||||
"means this copy\n"
|
||||
"has almost certainly already been repaired by remote/clean_v2.py "
|
||||
"(or clean_nan.py),\n"
|
||||
"which zeroes the corrupted windows IN PLACE -- after that the "
|
||||
"corruption evidence\n"
|
||||
"is gone and the masks CANNOT be regenerated from this copy.\n"
|
||||
"\n"
|
||||
"Refusing to overwrite the committed ground-truth masks with "
|
||||
"all-False ones.\n"
|
||||
"Re-download the dataset (kagglehub.dataset_download("
|
||||
"'kaka2434/wiflow-dataset'))\n"
|
||||
"and point --data-dir at the fresh, uncleaned copy.")
|
||||
|
||||
files = describe_files(args.data_dir, union)
|
||||
if files is not None:
|
||||
print(f"marked windows span dataset files {files.min()}-{files.max()}: "
|
||||
f"{files.tolist()}")
|
||||
lo, hi = EXPECTED["files"]
|
||||
if files.min() != lo or files.max() != hi:
|
||||
print(f"WARNING: expected marked files exactly {lo}-{hi} "
|
||||
f"(the pristine v1 download); got {files.min()}-{files.max()}. "
|
||||
f"Different dataset version, or a partially cleaned copy?")
|
||||
for name, mask, exp in (("nan", nan_mask, EXPECTED["nan"]),
|
||||
("big", big_mask, EXPECTED["big"])):
|
||||
if int(mask.sum()) != exp:
|
||||
print(f"WARNING: {name} mask has {int(mask.sum()):,} True windows; "
|
||||
f"the pristine v1 download yields {exp:,}.")
|
||||
|
||||
os.makedirs(args.out_dir, exist_ok=True)
|
||||
for name, mask in (("nan_windows_mask.npy", nan_mask),
|
||||
("big_windows_mask.npy", big_mask)):
|
||||
out = os.path.join(args.out_dir, name)
|
||||
np.save(out, mask)
|
||||
print(f"wrote {out} ({int(mask.sum()):,} True)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,220 @@
|
||||
"""ADR-152 edge optimization: ONNX export + onnxruntime CPU benchmark for the
|
||||
retrained WiFlow-STD checkpoint.
|
||||
|
||||
- Exports fp32 to ONNX. The axial attention reshapes with python ints taken
|
||||
from tensor.size() (view(N*W, C, H)), so a traced graph bakes the batch
|
||||
size; we first try a dynamic-batch export and verify it actually works at
|
||||
batch sizes 1/2/64 -- if not, we fall back to fixed-batch exports.
|
||||
- Verifies output parity vs torch on the stored fixture
|
||||
(results/parity_fixture.npz, batch 2, seed 42): max abs diff < 1e-4.
|
||||
- Measures onnxruntime CPU latency at batch 1 and 64 (median of N runs).
|
||||
- Supplementary: onnxruntime dynamic int8 quantization of the exported model
|
||||
(weight size datapoint for the paper's "~2.2 MB int8" claim).
|
||||
|
||||
Usage:
|
||||
.venv/Scripts/python.exe onnx_bench.py
|
||||
|
||||
Writes/merges into results/edge_optimization.json under key "onnx".
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import statistics
|
||||
import time
|
||||
import traceback
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
|
||||
from _bench_common import RESULTS, import_upstream, load_wiflow_model
|
||||
|
||||
import_upstream() # sys.path + models stub + >1GB np.load mmap patch
|
||||
|
||||
CHECKPOINT = os.path.join(RESULTS, "retrained_best_pose_model.pth")
|
||||
OUT_JSON = os.path.join(RESULTS, "edge_optimization.json")
|
||||
|
||||
|
||||
def load_fp32_model():
|
||||
return load_wiflow_model(CHECKPOINT)
|
||||
|
||||
|
||||
def try_export(model, path, batch, dynamic, opset=17):
|
||||
"""Returns (ok, exporter_used, error)."""
|
||||
x = torch.rand(batch, 540, 20)
|
||||
attempts = []
|
||||
if dynamic:
|
||||
attempts.append(("dynamo", dict(dynamo=True,
|
||||
dynamic_shapes={"x": {0: "batch"}})))
|
||||
attempts.append(("torchscript", dict(dynamo=False,
|
||||
dynamic_axes={"input": {0: "batch"},
|
||||
"output": {0: "batch"}})))
|
||||
else:
|
||||
attempts.append(("torchscript", dict(dynamo=False)))
|
||||
attempts.append(("dynamo", dict(dynamo=True)))
|
||||
last_err = None
|
||||
for name, kw in attempts:
|
||||
try:
|
||||
with torch.no_grad():
|
||||
torch.onnx.export(model, (x,), path, opset_version=opset,
|
||||
input_names=["input"], output_names=["output"],
|
||||
**kw)
|
||||
return True, name, None
|
||||
except Exception as e: # noqa: BLE001
|
||||
last_err = f"{name}: {type(e).__name__}: {e}"
|
||||
traceback.print_exc()
|
||||
return False, None, last_err
|
||||
|
||||
|
||||
def ort_session(path):
|
||||
import onnxruntime as ort
|
||||
return ort.InferenceSession(path, providers=["CPUExecutionProvider"])
|
||||
|
||||
|
||||
def ort_run(sess, x):
|
||||
inp = sess.get_inputs()[0].name
|
||||
return sess.run(None, {inp: x})[0]
|
||||
|
||||
|
||||
def bench_ort(sess, batch, n_runs):
|
||||
rng = np.random.default_rng(123)
|
||||
x = rng.random((batch, 540, 20), dtype=np.float32)
|
||||
for _ in range(max(5, n_runs // 10)):
|
||||
ort_run(sess, x)
|
||||
times = []
|
||||
for _ in range(n_runs):
|
||||
t0 = time.perf_counter()
|
||||
ort_run(sess, x)
|
||||
times.append(time.perf_counter() - t0)
|
||||
med = statistics.median(times)
|
||||
return {
|
||||
"batch_size": batch,
|
||||
"runs": n_runs,
|
||||
"median_ms_per_batch": med * 1e3,
|
||||
"median_ms_per_window": med * 1e3 / batch,
|
||||
"windows_per_second": batch / med,
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser(
|
||||
description="ONNX export + onnxruntime CPU benchmark for the "
|
||||
"retrained WiFlow-STD checkpoint (no options; see "
|
||||
"module docstring). NB: the published "
|
||||
"retrained_fp32_dynamic.onnx came from the TorchScript "
|
||||
"exporter; on newer torch the dynamo attempt may succeed "
|
||||
"first and produce a different (external-data) artifact.")
|
||||
parser.parse_args()
|
||||
|
||||
import onnxruntime
|
||||
model = load_fp32_model()
|
||||
results = {
|
||||
"env": {
|
||||
"torch": torch.__version__,
|
||||
"onnxruntime": onnxruntime.__version__,
|
||||
"platform": platform.platform(),
|
||||
},
|
||||
}
|
||||
|
||||
fixture = np.load(os.path.join(RESULTS, "parity_fixture.npz"))
|
||||
fx, fy = fixture["input"], fixture["output"] # (2,540,20) -> (2,15,2)
|
||||
|
||||
# ---- export: dynamic batch first, fall back to fixed --------------------
|
||||
dyn_path = os.path.join(RESULTS, "retrained_fp32_dynamic.onnx")
|
||||
ok, exporter, err = try_export(model, dyn_path, batch=2, dynamic=True)
|
||||
dynamic_works = False
|
||||
if ok:
|
||||
# verify the dynamic graph really runs at other batch sizes
|
||||
try:
|
||||
sess = ort_session(dyn_path)
|
||||
for b in (1, 2, 64):
|
||||
y = ort_run(sess, np.zeros((b, 540, 20), dtype=np.float32))
|
||||
assert y.shape == (b, 15, 2), y.shape
|
||||
dynamic_works = True
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"dynamic-batch model does not generalize: {e}")
|
||||
|
||||
sessions = {}
|
||||
if dynamic_works:
|
||||
results["export"] = {"mode": "dynamic-batch", "exporter": exporter,
|
||||
"file": os.path.basename(dyn_path),
|
||||
"size_mb": os.path.getsize(dyn_path) / 1e6}
|
||||
sess = ort_session(dyn_path)
|
||||
sessions = {1: sess, 2: sess, 64: sess}
|
||||
print(f"dynamic-batch export OK via {exporter}")
|
||||
else:
|
||||
results["export"] = {"mode": "fixed-batch", "fallback_reason": err,
|
||||
"files": {}}
|
||||
for b in (1, 2, 64):
|
||||
p = os.path.join(RESULTS, f"retrained_fp32_b{b}.onnx")
|
||||
ok, exporter, err = try_export(model, p, batch=b, dynamic=False)
|
||||
if not ok:
|
||||
results["export"]["files"][str(b)] = {"error": err}
|
||||
print(f"EXPORT FAILED at batch {b}: {err}")
|
||||
continue
|
||||
results["export"]["files"][str(b)] = {
|
||||
"exporter": exporter, "file": os.path.basename(p),
|
||||
"size_mb": os.path.getsize(p) / 1e6}
|
||||
sessions[b] = ort_session(p)
|
||||
print(f"fixed-batch {b} export OK via {exporter}")
|
||||
|
||||
# ---- parity vs torch on the fixture -------------------------------------
|
||||
if 2 in sessions:
|
||||
y_ort = ort_run(sessions[2], fx)
|
||||
with torch.no_grad():
|
||||
y_torch = model(torch.from_numpy(fx)).numpy()
|
||||
results["parity"] = {
|
||||
"fixture": "results/parity_fixture.npz (batch 2, seed 42)",
|
||||
"max_abs_diff_vs_stored_fixture": float(np.abs(y_ort - fy).max()),
|
||||
"max_abs_diff_vs_torch_now": float(np.abs(y_ort - y_torch).max()),
|
||||
"pass_lt_1e-4": bool(np.abs(y_ort - y_torch).max() < 1e-4),
|
||||
}
|
||||
print("parity:", json.dumps(results["parity"], indent=2))
|
||||
|
||||
# ---- latency -------------------------------------------------------------
|
||||
results["latency"] = {}
|
||||
if 1 in sessions:
|
||||
results["latency"]["batch1"] = bench_ort(sessions[1], 1, 100)
|
||||
print(f"ORT batch 1: {results['latency']['batch1']['median_ms_per_window']:.2f} ms/window")
|
||||
if 64 in sessions:
|
||||
results["latency"]["batch64"] = bench_ort(sessions[64], 64, 30)
|
||||
print(f"ORT batch 64: {results['latency']['batch64']['median_ms_per_window']:.3f} ms/window")
|
||||
|
||||
# ---- supplementary: ORT dynamic int8 (size datapoint for the 2.2MB claim)
|
||||
src = (dyn_path if dynamic_works
|
||||
else os.path.join(RESULTS, "retrained_fp32_b1.onnx"))
|
||||
if os.path.exists(src):
|
||||
try:
|
||||
from onnxruntime.quantization import QuantType, quantize_dynamic
|
||||
q_path = os.path.join(RESULTS, "retrained_int8_ort_dynamic.onnx")
|
||||
quantize_dynamic(src, q_path, weight_type=QuantType.QInt8)
|
||||
entry = {"file": os.path.basename(q_path),
|
||||
"size_mb": os.path.getsize(q_path) / 1e6}
|
||||
try:
|
||||
qs = ort_session(q_path)
|
||||
yq = ort_run(qs, fx[:1] if not dynamic_works else fx)
|
||||
ref = fy[:1] if not dynamic_works else fy
|
||||
entry["runs"] = True
|
||||
entry["max_abs_diff_vs_fp32_fixture"] = float(np.abs(yq - ref).max())
|
||||
except Exception as e: # noqa: BLE001
|
||||
entry["runs"] = False
|
||||
entry["run_error"] = f"{type(e).__name__}: {e}"
|
||||
results["ort_int8_dynamic_supplementary"] = entry
|
||||
print("ORT int8:", json.dumps(entry, indent=2))
|
||||
except Exception as e: # noqa: BLE001
|
||||
results["ort_int8_dynamic_supplementary"] = {
|
||||
"error": f"{type(e).__name__}: {e}"}
|
||||
|
||||
merged = {}
|
||||
if os.path.exists(OUT_JSON):
|
||||
with open(OUT_JSON) as f:
|
||||
merged = json.load(f)
|
||||
merged["onnx"] = results
|
||||
with open(OUT_JSON, "w") as f:
|
||||
json.dump(merged, f, indent=2)
|
||||
print(f"wrote {OUT_JSON}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,228 @@
|
||||
"""ADR-152 "optimize beyond SOTA": edge-optimization benchmark for the
|
||||
retrained WiFlow-STD checkpoint (results/retrained_best_pose_model.pth,
|
||||
~96% PCK@20, fp32 params 2,225,042).
|
||||
|
||||
Measures, for fp32 / fp16 / dynamic-int8 torch variants:
|
||||
(a) serialized state_dict size on disk,
|
||||
(b) CPU inference latency per window at batch 1 and batch 64
|
||||
(median of repeated runs, this Windows box),
|
||||
(c) accuracy (PCK@20/50 + MPJPE, upstream metrics) on a corruption-free
|
||||
random subset of the seed-42 file-level 70/15/15 test split
|
||||
(same split as eval_repro.py; corrupted windows 487-499 excluded via
|
||||
results/nan_windows_mask.npy | results/big_windows_mask.npy).
|
||||
|
||||
Also verifies the paper's "~2.2 MB int8" size claim: reports which layer
|
||||
types torch dynamic quantization actually converts (the model contains NO
|
||||
nn.Linear -- it is Conv1d/Conv2d/BatchNorm only) and the real on-disk size.
|
||||
|
||||
Usage:
|
||||
.venv/Scripts/python.exe quantize_bench.py \
|
||||
--data-dir C:/Users/ruv/.cache/kagglehub/datasets/kaka2434/wiflow-dataset/versions/1/preprocessed_csi_data \
|
||||
[--subset 10000] [--skip-accuracy]
|
||||
|
||||
Writes/merges into results/edge_optimization.json under key "torch".
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import statistics
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
from torch.utils.data import DataLoader
|
||||
|
||||
from _bench_common import HERE, RESULTS, evaluate, import_upstream, load_wiflow_model
|
||||
|
||||
import_upstream() # sys.path + models stub + >1GB np.load mmap patch
|
||||
|
||||
from dataset import ( # noqa: E402
|
||||
PreprocessedCSIKeypointsDataset,
|
||||
create_preprocessed_train_val_test_loaders,
|
||||
)
|
||||
|
||||
CHECKPOINT = os.path.join(RESULTS, "retrained_best_pose_model.pth")
|
||||
|
||||
|
||||
def load_fp32_model():
|
||||
# legacy upstream key remap inside is a harmless no-op on this checkpoint
|
||||
return load_wiflow_model(CHECKPOINT)
|
||||
|
||||
|
||||
def state_dict_size_bytes(model, path):
|
||||
torch.save(model.state_dict(), path)
|
||||
return os.path.getsize(path)
|
||||
|
||||
|
||||
def bench_latency(model, batch_size, n_runs, dtype=torch.float32):
|
||||
gen = torch.Generator().manual_seed(123)
|
||||
x = torch.rand(batch_size, 540, 20, generator=gen).to(dtype)
|
||||
with torch.no_grad():
|
||||
for _ in range(max(5, n_runs // 10)): # warmup
|
||||
model(x)
|
||||
times = []
|
||||
for _ in range(n_runs):
|
||||
t0 = time.perf_counter()
|
||||
model(x)
|
||||
times.append(time.perf_counter() - t0)
|
||||
med = statistics.median(times)
|
||||
return {
|
||||
"batch_size": batch_size,
|
||||
"runs": n_runs,
|
||||
"median_ms_per_batch": med * 1e3,
|
||||
"median_ms_per_window": med * 1e3 / batch_size,
|
||||
"windows_per_second": batch_size / med,
|
||||
}
|
||||
|
||||
|
||||
def build_test_subset(data_dir, subset_size, batch_size=64):
|
||||
"""Seed-42 file-level 70/15/15 test split (exactly as eval_repro.py),
|
||||
minus corrupted windows, then a seed-42 random subset."""
|
||||
dataset = PreprocessedCSIKeypointsDataset(
|
||||
data_dir=data_dir, keypoint_scale=1000.0, enable_temporal_clean=True)
|
||||
_tr, _va, test_loader = create_preprocessed_train_val_test_loaders(
|
||||
dataset=dataset, batch_size=batch_size, num_workers=0, random_seed=42)
|
||||
test_indices = np.asarray(test_loader.dataset.indices)
|
||||
|
||||
corrupted = (np.load(os.path.join(RESULTS, "nan_windows_mask.npy"))
|
||||
| np.load(os.path.join(RESULTS, "big_windows_mask.npy")))
|
||||
clean = test_indices[~corrupted[test_indices]]
|
||||
print(f"test split: {len(test_indices)} windows, "
|
||||
f"{len(test_indices) - len(clean)} corrupted excluded, "
|
||||
f"{len(clean)} clean")
|
||||
|
||||
if subset_size and subset_size < len(clean):
|
||||
rng = np.random.default_rng(42)
|
||||
clean = np.sort(rng.choice(clean, size=subset_size, replace=False))
|
||||
subset = torch.utils.data.Subset(dataset, clean.tolist())
|
||||
loader = DataLoader(subset, batch_size=batch_size, shuffle=False,
|
||||
num_workers=0)
|
||||
return loader, len(clean)
|
||||
|
||||
|
||||
def quantize_int8_dynamic(fp32_model):
|
||||
"""torch.ao.quantization.quantize_dynamic on Linear/Conv where supported.
|
||||
Returns (model, report) where report documents what actually quantized."""
|
||||
qmodel = torch.ao.quantization.quantize_dynamic(
|
||||
fp32_model, {nn.Linear, nn.Conv1d, nn.Conv2d}, dtype=torch.qint8)
|
||||
|
||||
quantized, total_params, quant_params = [], 0, 0
|
||||
for name, mod in qmodel.named_modules():
|
||||
cls = type(mod).__module__ + "." + type(mod).__name__
|
||||
if "quantized" in cls:
|
||||
w = mod.weight() if callable(getattr(mod, "weight", None)) else None
|
||||
numel = w.numel() if w is not None else 0
|
||||
quant_params += numel
|
||||
quantized.append({"module": name, "class": cls, "params": numel})
|
||||
for p in fp32_model.parameters():
|
||||
total_params += p.numel()
|
||||
|
||||
n_linear = sum(isinstance(m, nn.Linear) for m in fp32_model.modules())
|
||||
n_conv1d = sum(isinstance(m, nn.Conv1d) for m in fp32_model.modules())
|
||||
n_conv2d = sum(isinstance(m, nn.Conv2d) for m in fp32_model.modules())
|
||||
report = {
|
||||
"eligible_module_counts": {
|
||||
"nn.Linear": n_linear, "nn.Conv1d": n_conv1d, "nn.Conv2d": n_conv2d},
|
||||
"modules_actually_quantized": quantized,
|
||||
"n_modules_quantized": len(quantized),
|
||||
"params_total": total_params,
|
||||
"params_quantized": quant_params,
|
||||
"params_quantized_fraction": quant_params / total_params,
|
||||
}
|
||||
return qmodel, report
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--data-dir", default=os.path.join(
|
||||
os.path.expanduser("~"), ".cache", "kagglehub", "datasets", "kaka2434",
|
||||
"wiflow-dataset", "versions", "1", "preprocessed_csi_data"))
|
||||
parser.add_argument("--subset", type=int, default=10000)
|
||||
parser.add_argument("--runs-b1", type=int, default=100)
|
||||
parser.add_argument("--runs-b64", type=int, default=30)
|
||||
parser.add_argument("--skip-accuracy", action="store_true")
|
||||
parser.add_argument("--out", default=os.path.join(RESULTS, "edge_optimization.json"))
|
||||
args = parser.parse_args()
|
||||
|
||||
torch.manual_seed(42)
|
||||
results = {
|
||||
"env": {
|
||||
"torch": torch.__version__,
|
||||
"platform": platform.platform(),
|
||||
"processor": platform.processor(),
|
||||
"num_threads": torch.get_num_threads(),
|
||||
"checkpoint": os.path.relpath(CHECKPOINT, HERE),
|
||||
},
|
||||
"variants": {},
|
||||
}
|
||||
|
||||
# ---- build variants ---------------------------------------------------
|
||||
fp32 = load_fp32_model()
|
||||
n_params = sum(p.numel() for p in fp32.parameters())
|
||||
results["env"]["params"] = n_params
|
||||
print(f"fp32 model: {n_params:,} params")
|
||||
|
||||
fp16 = load_fp32_model().half()
|
||||
|
||||
int8, q_report = quantize_int8_dynamic(load_fp32_model())
|
||||
results["int8_dynamic_quant_report"] = q_report
|
||||
print(f"int8 dynamic: {q_report['n_modules_quantized']} modules quantized, "
|
||||
f"{q_report['params_quantized_fraction']*100:.1f}% of params")
|
||||
|
||||
variants = {
|
||||
"fp32": (fp32, torch.float32, "retrained_fp32_resaved.pth"),
|
||||
"fp16": (fp16, torch.float16, "retrained_fp16.pth"),
|
||||
"int8_dynamic": (int8, torch.float32, "retrained_int8_dynamic.pth"),
|
||||
}
|
||||
|
||||
# ---- (a) size + (b) latency -------------------------------------------
|
||||
for name, (model, dtype, fname) in variants.items():
|
||||
path = os.path.join(RESULTS, fname)
|
||||
size = state_dict_size_bytes(model, path)
|
||||
print(f"\n=== {name}: {size/1e6:.3f} MB on disk ({fname}) ===")
|
||||
lat1 = bench_latency(model, 1, args.runs_b1, dtype)
|
||||
lat64 = bench_latency(model, 64, args.runs_b64, dtype)
|
||||
print(f" batch 1: {lat1['median_ms_per_window']:.2f} ms/window "
|
||||
f"({lat1['windows_per_second']:.0f}/s)")
|
||||
print(f" batch 64: {lat64['median_ms_per_window']:.3f} ms/window "
|
||||
f"({lat64['windows_per_second']:.0f}/s)")
|
||||
results["variants"][name] = {
|
||||
"file": fname,
|
||||
"size_bytes": size,
|
||||
"size_mb": size / 1e6,
|
||||
"latency_batch1": lat1,
|
||||
"latency_batch64": lat64,
|
||||
}
|
||||
|
||||
# ---- (c) accuracy ------------------------------------------------------
|
||||
if not args.skip_accuracy:
|
||||
loader, n_clean = build_test_subset(args.data_dir, args.subset)
|
||||
results["accuracy_subset"] = {
|
||||
"description": "seed-42 file-level 70/15/15 test split, corrupted "
|
||||
"windows (files 487-499) excluded, seed-42 random "
|
||||
"subset",
|
||||
"subset_size": min(args.subset, n_clean) if args.subset else n_clean,
|
||||
"clean_test_total": n_clean,
|
||||
}
|
||||
for name, (model, dtype, _f) in variants.items():
|
||||
print(f"\n=== accuracy: {name} ===")
|
||||
results["variants"][name]["accuracy"] = evaluate(
|
||||
model, loader, dtype=dtype, label=name)
|
||||
print(json.dumps(results["variants"][name]["accuracy"], indent=2))
|
||||
|
||||
# ---- merge into edge_optimization.json ---------------------------------
|
||||
merged = {}
|
||||
if os.path.exists(args.out):
|
||||
with open(args.out) as f:
|
||||
merged = json.load(f)
|
||||
merged["torch"] = results
|
||||
with open(args.out, "w") as f:
|
||||
json.dump(merged, f, indent=2)
|
||||
print(f"\nwrote {args.out}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,14 @@
|
||||
import numpy as np, os
|
||||
d = os.path.expanduser('~/wiflow-std-bench/preprocessed_csi_data')
|
||||
csi = np.load(os.path.join(d, 'csi_windows.npy'), mmap_mode='r+')
|
||||
zeroed = 0
|
||||
chunk = 4000
|
||||
for i in range(0, len(csi), chunk):
|
||||
block = csi[i:i+chunk]
|
||||
finite = np.isfinite(block)
|
||||
bad = (~finite).any(axis=(1, 2)) | (np.abs(np.where(finite, block, 0)).max(axis=(1, 2)) > 1.5)
|
||||
if bad.any():
|
||||
block[bad] = 0.0
|
||||
zeroed += int(bad.sum())
|
||||
csi.flush()
|
||||
print(f'zeroed {zeroed} corrupted windows entirely')
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Evaluate the retrained WiFlow-STD checkpoint (ADR-152 §2.2a fallback).
|
||||
|
||||
Scores the model produced by run.py (train_output/best_pose_model.pth or similar)
|
||||
on the seed-42 test split: full test set AND NaN-free subset (excluding windows
|
||||
that were zero-filled by clean_nan.py — file indices 487-499).
|
||||
|
||||
NOTE: deployed to ruvultra (~/wiflow-std-bench) as a standalone single file,
|
||||
so it deliberately inlines its helpers. The reference implementations (upstream
|
||||
import shim, >1GB np.load mmap patch, key-remap loader, canonical evaluate
|
||||
loop) live in benchmarks/wiflow-std/_bench_common.py — keep copies in sync.
|
||||
"""
|
||||
import json, os, random, sys
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
from torch.utils.data import DataLoader, Subset
|
||||
|
||||
# csi_windows.npy is ~13 GB; mmap large arrays instead of eagerly loading
|
||||
# ~15 GB into RAM (same patch as _bench_common._np_load_mmap).
|
||||
_np_load = np.load
|
||||
|
||||
|
||||
def _np_load_mmap(path, *a, **kw):
|
||||
if (isinstance(path, str) and path.endswith('.npy')
|
||||
and os.path.getsize(path) > 1 << 30 and 'mmap_mode' not in kw):
|
||||
kw['mmap_mode'] = 'r'
|
||||
return _np_load(path, *a, **kw)
|
||||
|
||||
|
||||
np.load = _np_load_mmap
|
||||
|
||||
sys.path.insert(0, os.path.expanduser('~/wiflow-std-bench/upstream'))
|
||||
from dataset import PreprocessedCSIKeypointsDataset, create_preprocessed_train_val_test_loaders
|
||||
from models.pose_model import WiFlowPoseModel
|
||||
from utils.metrics import calculate_pck, calculate_mpjpe
|
||||
|
||||
|
||||
def find_checkpoint():
|
||||
cands = []
|
||||
for root, _, files in os.walk(os.path.expanduser('~/wiflow-std-bench/train_output')):
|
||||
for f in files:
|
||||
if f.endswith('.pth'):
|
||||
cands.append(os.path.join(root, f))
|
||||
# also upstream/test default output dir
|
||||
for root, _, files in os.walk(os.path.expanduser('~/wiflow-std-bench/upstream')):
|
||||
for f in files:
|
||||
if f.endswith('.pth') and 'best' in f and 'cross_dataset' not in root:
|
||||
p = os.path.join(root, f)
|
||||
if os.path.getmtime(p) > os.path.getmtime(os.path.expanduser('~/wiflow-std-bench/train.log')) - 86400 * 2:
|
||||
cands.append(p)
|
||||
cands = [c for c in cands if not c.endswith('upstream/best_pose_model.pth')]
|
||||
if not cands:
|
||||
sys.exit('no retrained checkpoint found')
|
||||
return max(cands, key=os.path.getmtime)
|
||||
|
||||
|
||||
def evaluate(model, loader, device):
|
||||
model.eval()
|
||||
totals = {t: 0.0 for t in (0.1, 0.2, 0.3, 0.4, 0.5)}
|
||||
total_mpe, n = 0.0, 0
|
||||
with torch.no_grad():
|
||||
for bx, by in loader:
|
||||
bx, by = bx.to(device), by.to(device)
|
||||
out = model(bx)
|
||||
bs = by.size(0)
|
||||
total_mpe += calculate_mpjpe(out, by) * bs
|
||||
pck = calculate_pck(out, by, thresholds=list(totals))
|
||||
for t in totals:
|
||||
totals[t] += pck[t] * bs
|
||||
n += bs
|
||||
return {'samples': n, 'mpjpe': total_mpe / n,
|
||||
**{f'pck@{int(t*100)}': totals[t] / n for t in totals}}
|
||||
|
||||
|
||||
random.seed(42); np.random.seed(42); torch.manual_seed(42)
|
||||
torch.cuda.manual_seed_all(42)
|
||||
torch.backends.cudnn.deterministic = True
|
||||
|
||||
d = os.path.expanduser('~/wiflow-std-bench/preprocessed_csi_data')
|
||||
dataset = PreprocessedCSIKeypointsDataset(data_dir=d, keypoint_scale=1000.0,
|
||||
enable_temporal_clean=True)
|
||||
_, _, test_loader = create_preprocessed_train_val_test_loaders(
|
||||
dataset=dataset, batch_size=256, num_workers=2, random_seed=42)
|
||||
|
||||
device = torch.device('cuda')
|
||||
ckpt = find_checkpoint()
|
||||
print('checkpoint:', ckpt)
|
||||
model = WiFlowPoseModel(dropout=0.5).to(device)
|
||||
state = torch.load(ckpt, map_location=device, weights_only=True)
|
||||
renames = {'att.': 'attention.', 'final_conv.': 'decoder.'}
|
||||
state = {next((new + k[len(old):] for old, new in renames.items()
|
||||
if k.startswith(old)), k): v for k, v in state.items()}
|
||||
model.load_state_dict(state, strict=True)
|
||||
|
||||
results = {'checkpoint': ckpt}
|
||||
print('=== full test set ===')
|
||||
results['test_full'] = evaluate(model, test_loader, device)
|
||||
print(json.dumps(results['test_full'], indent=2))
|
||||
|
||||
# NaN-free subset: exclude windows from corrupted files 487-499
|
||||
test_subset = test_loader.dataset # Subset(dataset, test_indices)
|
||||
w2f = dataset.window_to_file
|
||||
clean_idx = [i for i in test_subset.indices if w2f[i] < 487]
|
||||
print(f'=== NaN-free test subset ({len(clean_idx)} of {len(test_subset.indices)}) ===')
|
||||
clean_loader = DataLoader(Subset(dataset, clean_idx), batch_size=256, shuffle=False)
|
||||
results['test_clean'] = evaluate(model, clean_loader, device)
|
||||
print(json.dumps(results['test_clean'], indent=2))
|
||||
|
||||
out = os.path.expanduser('~/wiflow-std-bench/eval_retrained.json')
|
||||
with open(out, 'w') as f:
|
||||
json.dump(results, f, indent=2)
|
||||
print('wrote', out)
|
||||
@@ -0,0 +1,374 @@
|
||||
"""ADR-152 SS2.2 measurement (b): WiFlow-STD fine-tuned on our fresh ESP32 paired dataset.
|
||||
|
||||
Dataset: ~/wiflow-std-bench/paired-20260610.jsonl -- 2,046 paired windows collected
|
||||
2026-06-10 22:10-22:40 (ONE subject, ONE room, ONE ESP32 node, varied poses).
|
||||
Per record: csi = flat float32 list, csi_shape, kp = 17 COCO [x, y] normalized [0,1]
|
||||
camera coords, conf (MediaPipe mean confidence, all > 0.5 in this set), ts_start/ts_end.
|
||||
Aligner: scripts/align-ground-truth.js, non-overlapping 20-frame windows (~0.42 s each).
|
||||
|
||||
Dataset findings (MEASURED on this file, 2026-06-10):
|
||||
- csi_shape is HETEROGENEOUS, not uniformly [70, 20]: 1,347x [70,20], 284x [134,20],
|
||||
243x [26,20], 130x [12,20], 42x [20,20]. The ESP32 stream emits mixed frame types
|
||||
and the aligner stamps each window's subcarrier count from frame[0]
|
||||
(extractCsiMatrix: nSc = window[0].subcarriers), zero-padding/truncating the rest.
|
||||
Even native-70 windows contain ~20.4% internally zero-padded short frames
|
||||
(subcarriers 40..69 all-zero for those frames).
|
||||
- LAYOUT BUG: the aligner fills matrix[f * nSc + s] (frame-major) but declares
|
||||
shape [nSc, nFrames]. The true layout is (frame, subcarrier); we reshape
|
||||
(nFrames, nSc) and transpose. Confirmed by coherent per-frame zero-tails.
|
||||
- Handling here (primary suite, "all2046"): every frame's subcarrier axis is
|
||||
linearly resampled to 70 bins (np.interp over a normalized index domain;
|
||||
identity for native-70 frames) so the pre-registered n=2,046 and split sizes
|
||||
hold. Secondary suite ("native70") restricts to the 1,347 native [70,20]
|
||||
windows (temporal 70/15/15 of those) as a homogeneity robustness check.
|
||||
|
||||
Pre-registered protocol (followed exactly):
|
||||
1. TEMPORAL split (records are time-sorted; asserted): first 70% train (1,432),
|
||||
next 15% val (307), last 15% test (307). No shuffling across time. Seed 42
|
||||
for everything else.
|
||||
2. Model: upstream WiFlow-STD trunk (WiFlowPoseModel) with a learned 1x1 Conv1d
|
||||
projection 70->540 prepended, and K=17 via the parameter-free adaptive pool
|
||||
(AdaptiveAvgPool2d((17, 1)) instead of (15, 1)) -- pretrained weights load
|
||||
for any K. CSI normalization: divide by the TRAIN-split 99th-percentile
|
||||
amplitude, clip to [0, 1] (documented in output JSON).
|
||||
3. Three runs, <=60 epochs, early-stop patience 8 on val MPJPE, batch 32,
|
||||
AdamW, fp32 (no autocast):
|
||||
(i) pretrained-init: trunk init from upstream/test/best_pose_model.pth
|
||||
(the measurement-(a) retrained checkpoint, ~96% PCK@20 on WiFlow data;
|
||||
key remap att.->attention. / final_conv.->decoder. applied defensively
|
||||
as in eval_repro.py -- a no-op for this checkpoint, which already uses
|
||||
the new names). Discriminative lr: adapter 1e-4, trunk 1e-5.
|
||||
(ii) scratch: same architecture, random init, all params lr 1e-4.
|
||||
(iii) frozen-trunk: pretrained trunk frozen (requires_grad=False AND held in
|
||||
.eval() so BatchNorm running stats cannot drift -- pure transfer probe);
|
||||
only the 70->540 adapter trains, lr 1e-4.
|
||||
4. Metrics on the temporal TEST split: torso-normalized PCK@10/20/30/40/50 and
|
||||
MPJPE. Upstream utils/metrics.py calculate_pck(use_torso_norm=True) hardcodes
|
||||
NECK_IDX/PELVIS_IDX = 2, 12 -- a 15-keypoint convention that is WRONG for our
|
||||
17 COCO keypoints (2 = right_eye, 12 = right_hip). We therefore reimplement the
|
||||
identical math (per-frame norm distance, clamp min 0.01, mean over all
|
||||
keypoints x frames) with torso = ||l_shoulder(5) - l_hip(11)||.
|
||||
Also reported: prediction std across test frames (constant-pose detector;
|
||||
must be > 0) and the mean-pose-predictor baseline (train-split mean pose
|
||||
evaluated on test -- the honesty bar).
|
||||
|
||||
Usage (on ruvultra):
|
||||
nice -n 10 nohup ~/wiflow-std-bench/venv/bin/python train_measb.py > train_measb.log 2>&1 &
|
||||
|
||||
NOTE: deployed to ruvultra as a standalone single file, so it deliberately
|
||||
inlines its helpers. The reference implementations (upstream import shim,
|
||||
np.load mmap patch, key-remap loader, canonical evaluate loop) live in
|
||||
benchmarks/wiflow-std/_bench_common.py — keep copies in sync.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import sys
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
|
||||
BENCH = os.path.expanduser("~/wiflow-std-bench")
|
||||
UPSTREAM = os.path.join(BENCH, "upstream")
|
||||
MEASB = os.path.join(BENCH, "measb")
|
||||
DATA = os.path.join(BENCH, "paired-20260610.jsonl")
|
||||
CHECKPOINT = os.path.join(UPSTREAM, "test", "best_pose_model.pth")
|
||||
|
||||
sys.path.insert(0, UPSTREAM)
|
||||
|
||||
# Upstream defect (1): models/__init__.py imports a name tcn.py does not define.
|
||||
# Register a stub package so the broken __init__ never executes (as eval_repro.py).
|
||||
import types # noqa: E402
|
||||
|
||||
_models_pkg = types.ModuleType("models")
|
||||
_models_pkg.__path__ = [os.path.join(UPSTREAM, "models")]
|
||||
sys.modules["models"] = _models_pkg
|
||||
|
||||
from models.pose_model import WiFlowPoseModel # noqa: E402
|
||||
|
||||
SEED = 42
|
||||
K = 17
|
||||
N_SUBC = 70
|
||||
TRUNK_IN = 540
|
||||
BATCH = 32 # <= 64 per protocol (GPU shared with the efficiency sweep)
|
||||
MAX_EPOCHS = 60
|
||||
PATIENCE = 8
|
||||
LR_ADAPTER = 1e-4
|
||||
LR_TRUNK_FT = 1e-5 # 10x lower for the pretrained trunk vs the fresh adapter
|
||||
L_SHOULDER, L_HIP = 5, 11
|
||||
THRESHOLDS = (0.1, 0.2, 0.3, 0.4, 0.5)
|
||||
|
||||
|
||||
def set_seed(seed=SEED):
|
||||
random.seed(seed)
|
||||
np.random.seed(seed)
|
||||
torch.manual_seed(seed)
|
||||
if torch.cuda.is_available():
|
||||
torch.cuda.manual_seed_all(seed)
|
||||
torch.backends.cudnn.deterministic = True
|
||||
torch.backends.cudnn.benchmark = False
|
||||
|
||||
|
||||
def resample_subcarriers(frame_major, n_out=N_SUBC):
|
||||
"""(nFrames, nSc) -> (nFrames, n_out) by per-frame linear interpolation.
|
||||
|
||||
Identity for nSc == n_out. Normalized index domain [0, 1] on both sides.
|
||||
"""
|
||||
nf, nsc = frame_major.shape
|
||||
if nsc == n_out:
|
||||
return frame_major
|
||||
xi = np.linspace(0.0, 1.0, nsc)
|
||||
xo = np.linspace(0.0, 1.0, n_out)
|
||||
return np.stack([np.interp(xo, xi, frame_major[f]) for f in range(nf)]).astype(np.float32)
|
||||
|
||||
|
||||
def load_dataset():
|
||||
csi, kps, confs, ts, native70 = [], [], [], [], []
|
||||
shape_counts = {}
|
||||
with open(DATA) as f:
|
||||
for line in f:
|
||||
r = json.loads(line)
|
||||
nsc, nf = r["csi_shape"]
|
||||
shape_counts[f"{nsc}x{nf}"] = shape_counts.get(f"{nsc}x{nf}", 0) + 1
|
||||
assert nf == 20, r["csi_shape"]
|
||||
# Aligner layout bug: data is frame-major despite the declared
|
||||
# [nSc, nFrames] shape -- reshape (nFrames, nSc), then resample the
|
||||
# subcarrier axis to 70 and transpose to (70 subcarriers, 20 frames).
|
||||
fm = np.asarray(r["csi"], dtype=np.float32).reshape(nf, nsc)
|
||||
csi.append(resample_subcarriers(fm).T)
|
||||
kp = np.asarray(r["kp"], dtype=np.float32)
|
||||
assert kp.shape == (K, 2), kp.shape
|
||||
kps.append(kp)
|
||||
confs.append(r["conf"])
|
||||
ts.append(r["ts_start"])
|
||||
native70.append(nsc == N_SUBC)
|
||||
assert all(ts[i] <= ts[i + 1] for i in range(len(ts) - 1)), "records not time-sorted"
|
||||
return (np.stack(csi), np.stack(kps), np.asarray(confs, dtype=np.float32),
|
||||
np.asarray(native70), shape_counts, ts[0], ts[-1])
|
||||
|
||||
|
||||
def temporal_split(n):
|
||||
n_train = int(round(n * 0.70))
|
||||
n_val = int(round(n * 0.15))
|
||||
return slice(0, n_train), slice(n_train, n_train + n_val), slice(n_train + n_val, n)
|
||||
|
||||
|
||||
class AdaptedWiFlow(nn.Module):
|
||||
"""1x1 Conv1d adapter 70->540 + upstream WiFlow-STD trunk with K=17 pool head."""
|
||||
|
||||
def __init__(self, k=K, dropout=0.5):
|
||||
super().__init__()
|
||||
self.adapter = nn.Conv1d(N_SUBC, TRUNK_IN, kernel_size=1)
|
||||
nn.init.kaiming_normal_(self.adapter.weight, mode="fan_out", nonlinearity="relu")
|
||||
nn.init.constant_(self.adapter.bias, 0)
|
||||
self.trunk = WiFlowPoseModel(dropout=dropout)
|
||||
# K=17 via the parameter-free adaptive pool: decoder emits [B, 2, 15, 20]
|
||||
# spatial maps; pooling H->17 instead of 15 yields [B, 17, 2] with no new
|
||||
# parameters, so the pretrained state_dict loads strict=True for any K.
|
||||
self.trunk.avg_pool = nn.AdaptiveAvgPool2d((k, 1))
|
||||
|
||||
def forward(self, x):
|
||||
return self.trunk(self.adapter(x))
|
||||
|
||||
|
||||
def load_pretrained_trunk(trunk, path):
|
||||
state = torch.load(path, map_location="cpu", weights_only=True)
|
||||
# Defensive remap as in eval_repro.py (no-op for the retrained checkpoint).
|
||||
renames = {"att.": "attention.", "final_conv.": "decoder."}
|
||||
state = {next((new + k[len(old):] for old, new in renames.items()
|
||||
if k.startswith(old)), k): v
|
||||
for k, v in state.items()}
|
||||
trunk.load_state_dict(state, strict=True)
|
||||
|
||||
|
||||
def pck_torso(pred, target, thresholds=THRESHOLDS):
|
||||
"""Upstream calculate_pck math, torso = l_shoulder(5)<->l_hip(11) for 17-kp COCO."""
|
||||
norm = torch.sqrt(((target[:, L_SHOULDER] - target[:, L_HIP]) ** 2).sum(dim=1))
|
||||
norm = torch.clamp(norm, min=0.01)
|
||||
dist = torch.sqrt(((pred - target) ** 2).sum(dim=2)) / norm.unsqueeze(1)
|
||||
return {f"pck@{int(t * 100)}": (dist <= t).float().mean().item() for t in thresholds}
|
||||
|
||||
|
||||
def mpjpe(pred, target):
|
||||
return torch.sqrt(((pred - target) ** 2).sum(dim=2)).mean().item()
|
||||
|
||||
|
||||
@torch.no_grad()
|
||||
def predict(model, x, batch=256):
|
||||
model.eval()
|
||||
return torch.cat([model(x[i:i + batch]) for i in range(0, len(x), batch)])
|
||||
|
||||
|
||||
def eval_preds(pred, target):
|
||||
out = pck_torso(pred, target)
|
||||
out["mpjpe"] = mpjpe(pred, target)
|
||||
# Constant-pose detector: std across test frames per coordinate, mean over
|
||||
# the 17x2 coordinates. 0.0 == degenerate constant predictor.
|
||||
out["pred_std"] = pred.std(dim=0).mean().item()
|
||||
return out
|
||||
|
||||
|
||||
def train_run(name, x_tr, y_tr, x_va, y_va, device, pretrained, freeze_trunk,
|
||||
lr_trunk):
|
||||
set_seed(SEED)
|
||||
model = AdaptedWiFlow().to(device)
|
||||
if pretrained:
|
||||
load_pretrained_trunk(model.trunk, CHECKPOINT)
|
||||
if freeze_trunk:
|
||||
for p in model.trunk.parameters():
|
||||
p.requires_grad = False
|
||||
groups = [{"params": model.adapter.parameters(), "lr": LR_ADAPTER}]
|
||||
else:
|
||||
groups = [{"params": model.adapter.parameters(), "lr": LR_ADAPTER},
|
||||
{"params": model.trunk.parameters(), "lr": lr_trunk}]
|
||||
opt = torch.optim.AdamW(groups)
|
||||
loss_fn = nn.MSELoss()
|
||||
|
||||
n = len(x_tr)
|
||||
best_val, best_state, best_epoch, bad = float("inf"), None, -1, 0
|
||||
history = []
|
||||
t0 = time.time()
|
||||
for epoch in range(MAX_EPOCHS):
|
||||
model.train()
|
||||
if freeze_trunk:
|
||||
model.trunk.eval() # keep BatchNorm running stats fixed: pure transfer
|
||||
perm = torch.randperm(n, device=device)
|
||||
ep_loss = 0.0
|
||||
for i in range(0, n, BATCH):
|
||||
idx = perm[i:i + BATCH]
|
||||
opt.zero_grad()
|
||||
loss = loss_fn(model(x_tr[idx]), y_tr[idx])
|
||||
loss.backward()
|
||||
opt.step()
|
||||
ep_loss += loss.item() * len(idx)
|
||||
val_mpjpe = mpjpe(predict(model, x_va), y_va)
|
||||
history.append({"epoch": epoch, "train_mse": ep_loss / n, "val_mpjpe": val_mpjpe})
|
||||
marker = ""
|
||||
if val_mpjpe < best_val:
|
||||
best_val, best_epoch, bad = val_mpjpe, epoch, 0
|
||||
best_state = {k: v.detach().cpu().clone() for k, v in model.state_dict().items()}
|
||||
marker = " *"
|
||||
else:
|
||||
bad += 1
|
||||
print(f"[{name}] epoch {epoch:02d} train_mse {ep_loss / n:.6f} "
|
||||
f"val_mpjpe {val_mpjpe:.5f}{marker}", flush=True)
|
||||
if bad >= PATIENCE:
|
||||
print(f"[{name}] early stop at epoch {epoch} (best {best_epoch})", flush=True)
|
||||
break
|
||||
model.load_state_dict(best_state)
|
||||
torch.save(best_state, os.path.join(MEASB, f"{name}_best.pth"))
|
||||
return model, {"best_epoch": best_epoch, "best_val_mpjpe": best_val,
|
||||
"epochs_run": len(history), "wall_seconds": round(time.time() - t0, 1),
|
||||
"history": history}
|
||||
|
||||
|
||||
def run_suite(tag, csi, kps, device):
|
||||
"""Temporal 70/15/15 split, mean-pose baseline, three training runs."""
|
||||
n = len(csi)
|
||||
tr, va, te = temporal_split(n)
|
||||
print(f"=== suite {tag}: n={n} train={tr.stop} val={va.stop - va.start} "
|
||||
f"test={te.stop - te.start} ===", flush=True)
|
||||
|
||||
# CSI normalization constant from TRAIN split only.
|
||||
train_p99 = float(np.percentile(csi[tr], 99))
|
||||
train_max = float(csi[tr].max())
|
||||
print(f"[{tag}] train p99={train_p99:.3f} max={train_max:.3f} -> /p99, clip [0,1]",
|
||||
flush=True)
|
||||
csi_n = np.clip(csi / train_p99, 0.0, 1.0).astype(np.float32)
|
||||
|
||||
x = torch.from_numpy(csi_n).to(device)
|
||||
y = torch.from_numpy(kps).to(device)
|
||||
x_tr, y_tr = x[tr], y[tr]
|
||||
x_va, y_va = x[va], y[va]
|
||||
x_te, y_te = x[te], y[te]
|
||||
|
||||
suite = {
|
||||
"n_windows": n,
|
||||
"split": {"n_train": int(tr.stop), "n_val": int(va.stop - va.start),
|
||||
"n_test": int(te.stop - te.start)},
|
||||
"csi_norm": {"method": "divide by train-split p99 amplitude, clip [0,1]",
|
||||
"train_p99": train_p99, "train_max": train_max},
|
||||
"runs": {},
|
||||
}
|
||||
|
||||
# Honesty bar: mean-pose predictor fit on TRAIN, evaluated on TEST.
|
||||
mean_pose = y_tr.mean(dim=0, keepdim=True).expand(len(y_te), -1, -1)
|
||||
suite["mean_pose_baseline"] = eval_preds(mean_pose, y_te)
|
||||
suite["mean_pose_baseline"]["note"] = "train-split mean pose; pred_std 0 by construction"
|
||||
print(f"[{tag}] mean-pose baseline:", json.dumps(suite["mean_pose_baseline"]),
|
||||
flush=True)
|
||||
|
||||
configs = [
|
||||
("pretrained", dict(pretrained=True, freeze_trunk=False, lr_trunk=LR_TRUNK_FT)),
|
||||
("scratch", dict(pretrained=False, freeze_trunk=False, lr_trunk=LR_ADAPTER)),
|
||||
("frozen_trunk", dict(pretrained=True, freeze_trunk=True, lr_trunk=0.0)),
|
||||
]
|
||||
for name, cfg in configs:
|
||||
print(f"=== run: {tag}/{name} {cfg} ===", flush=True)
|
||||
model, train_info = train_run(f"{tag}_{name}", x_tr, y_tr, x_va, y_va,
|
||||
device, **cfg)
|
||||
test_metrics = eval_preds(predict(model, x_te), y_te)
|
||||
n_trainable = sum(p.numel() for p in model.parameters() if p.requires_grad)
|
||||
suite["runs"][name] = {"config": cfg, "trainable_params": n_trainable,
|
||||
"train": {k: v for k, v in train_info.items()
|
||||
if k != "history"},
|
||||
"history": train_info["history"],
|
||||
"test": test_metrics}
|
||||
print(f"[{tag}/{name}] TEST:", json.dumps(test_metrics), flush=True)
|
||||
return suite
|
||||
|
||||
|
||||
def main():
|
||||
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
||||
print(f"device {device}, torch {torch.__version__}", flush=True)
|
||||
set_seed(SEED)
|
||||
|
||||
csi, kps, confs, native70, shape_counts, ts_first, ts_last = load_dataset()
|
||||
print(f"shape distribution: {shape_counts}", flush=True)
|
||||
|
||||
results = {
|
||||
"protocol": {
|
||||
"dataset": DATA, "n_windows": len(csi),
|
||||
"ts_first": ts_first, "ts_last": ts_last,
|
||||
"conf_mean": float(confs.mean()), "conf_min": float(confs.min()),
|
||||
"csi_shape_distribution": shape_counts,
|
||||
"csi_layout_note": "aligner stores frame-major data under a transposed "
|
||||
"[nSc, nFrames] shape label; corrected on load",
|
||||
"csi_resample": "per-frame linear interp of subcarrier axis to 70 bins "
|
||||
"(identity for native-70 frames); native-70 windows still "
|
||||
"contain ~20.4% internally zero-padded short frames",
|
||||
"split": "temporal 70/15/15 (no shuffle across time)",
|
||||
"model": "1x1 Conv1d 70->540 adapter + WiFlowPoseModel trunk, "
|
||||
"AdaptiveAvgPool2d((17,1)) head (parameter-free K=17)",
|
||||
"checkpoint": CHECKPOINT,
|
||||
"checkpoint_note": "measurement-(a) retrained checkpoint (~96% PCK@20 on "
|
||||
"WiFlow data); att./final_conv. remap applied "
|
||||
"defensively (no-op, already new-style keys)",
|
||||
"optimizer": f"AdamW, adapter lr {LR_ADAPTER}, fine-tuned trunk lr "
|
||||
f"{LR_TRUNK_FT} (10x lower), scratch all {LR_ADAPTER}",
|
||||
"batch": BATCH, "max_epochs": MAX_EPOCHS, "patience": PATIENCE,
|
||||
"precision": "fp32", "seed": SEED,
|
||||
"pck": "torso-normalized, torso = ||l_shoulder(5) - l_hip(11)||, "
|
||||
"clamp min 0.01, mean over keypoints x frames "
|
||||
"(upstream math; upstream 2/12 indices are a 15-kp convention)",
|
||||
},
|
||||
# Primary: all 2,046 windows (pre-registered n), subcarrier axis resampled.
|
||||
"all2046": None,
|
||||
# Secondary robustness check: the 1,347 native [70,20] windows only.
|
||||
"native70": None,
|
||||
}
|
||||
|
||||
results["all2046"] = run_suite("all2046", csi, kps, device)
|
||||
results["native70"] = run_suite("native70", csi[native70], kps[native70], device)
|
||||
|
||||
out = os.path.join(MEASB, "measurement_b.json")
|
||||
with open(out, "w") as f:
|
||||
json.dump(results, f, indent=2)
|
||||
print(f"wrote {out}", flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,33 @@
|
||||
#!/bin/bash
|
||||
set -ex
|
||||
cd ~/wiflow-std-bench
|
||||
|
||||
# 1. clone upstream at the pinned commit
|
||||
if [ ! -d upstream ]; then
|
||||
git clone https://github.com/DY2434/WiFlow-WiFi-Pose-Estimation-with-Spatio-Temporal-Decoupling upstream
|
||||
fi
|
||||
cd upstream && git checkout 06899d294a0f44709d601a53e91dbf24759daefb && cd ..
|
||||
|
||||
# 2. documented deviation: fix upstream import bug (TemporalConvNet does not exist)
|
||||
sed -i 's/from .tcn import TemporalConvNet/from .tcn import TemporalBlock/; s/'"'"'TemporalConvNet'"'"'/'"'"'TemporalBlock'"'"'/' upstream/models/__init__.py
|
||||
|
||||
# 3. venv: torch cu128 (RTX 5080 = sm_120 needs >=2.7; their pin 2.3.1 predates Blackwell)
|
||||
if [ ! -d venv ]; then
|
||||
python3 -m venv venv
|
||||
./venv/bin/pip install -q --upgrade pip
|
||||
./venv/bin/pip install -q torch --index-url https://download.pytorch.org/whl/cu128
|
||||
./venv/bin/pip install -q numpy pandas matplotlib seaborn scikit-learn opencv-python-headless scipy tqdm psutil kagglehub
|
||||
fi
|
||||
./venv/bin/python -c "import torch; print(torch.__version__, torch.cuda.is_available(), torch.cuda.get_device_name(0))"
|
||||
|
||||
# 4. dataset via kagglehub (anonymous, public dataset)
|
||||
DS=$(./venv/bin/python -c "import kagglehub; print(kagglehub.dataset_download('kaka2434/wiflow-dataset'))")
|
||||
echo "dataset at: $DS"
|
||||
|
||||
# 5. run.py hardcodes ../preprocessed_csi_data relative to upstream/
|
||||
ln -sfn "$DS/preprocessed_csi_data" ~/wiflow-std-bench/preprocessed_csi_data
|
||||
|
||||
# 6. train with upstream defaults (seed 42 set inside run.py)
|
||||
../venv/bin/python ../clean_nan.py 2>/dev/null || venv/bin/python clean_nan.py
|
||||
cd upstream
|
||||
../venv/bin/python run.py --gpu 0 --batch_size 64 --epochs 50 --output_dir ../train_output
|
||||
@@ -0,0 +1,332 @@
|
||||
"""Configurable compact variants of the WiFlow-STD pose model (ADR-152 efficiency sweep).
|
||||
|
||||
This is a parameterized copy of upstream models/{pose_model,tcn,convnet,attention}.py
|
||||
(DY2434/WiFlow @ 06899d29, Apache-2.0). upstream/ is NOT modified. Deviations from
|
||||
upstream, all forced by shrinking channels and documented per variant in run_sweep.py:
|
||||
|
||||
1. TCN grouped-conv groups: upstream hardcodes groups=20, which does not divide
|
||||
the compact channel counts (e.g. 270, 135, 85). Rule here:
|
||||
- groups_mode='gcd20': per-conv groups = gcd(channels, 20) (== 20 wherever
|
||||
upstream's choice is valid, incl. the 540-ch input conv; falls back to the
|
||||
largest common divisor with 20 otherwise).
|
||||
- groups_mode='depthwise': groups = channels (tiny variant only).
|
||||
2. Conv2d downsampling strides: upstream uses 4 stride-(1,2) blocks because
|
||||
240/2^4 = 15 == n_keypoints. With smaller TCN output widths that would leave
|
||||
<15 rows and AdaptiveAvgPool2d((15,1)) would duplicate rows across keypoints.
|
||||
Rule: halve the width only while the result stays >= 15 (stride-2 blocks
|
||||
first, stride-1 after). Full model: 240 -> 4 halvings = upstream exactly.
|
||||
3. input_pw_groups (tiny only): the dense 540->c pointwise + residual downsample
|
||||
in TCN block 1 cost 2*540*c params (a ~117k floor that alone exceeds the
|
||||
tiny <100k budget). tiny groups these two convs (groups=4; 4 | gcd(540, 68)).
|
||||
4. Decoder mid-channels: upstream 64->32; here c_last -> max(c_last // 2, 4).
|
||||
"""
|
||||
import math
|
||||
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
import torch.nn.functional as F
|
||||
|
||||
|
||||
def tcn_groups(channels: int, mode: str) -> int:
|
||||
if mode == 'depthwise':
|
||||
return channels
|
||||
if mode == 'gcd20':
|
||||
return math.gcd(channels, 20)
|
||||
raise ValueError(mode)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- TCN (copy of tcn.py)
|
||||
class Chomp1d(nn.Module):
|
||||
def __init__(self, chomp_size):
|
||||
super().__init__()
|
||||
self.chomp_size = chomp_size
|
||||
|
||||
def forward(self, x):
|
||||
return x[:, :, :-self.chomp_size].contiguous()
|
||||
|
||||
|
||||
class CompactGroupedTemporalBlock(nn.Module):
|
||||
"""Upstream InnerGroupedTemporalBlock with parameterized groups."""
|
||||
|
||||
def __init__(self, n_inputs, n_outputs, kernel_size, stride, dilation, padding,
|
||||
dropout=0.2, groups_mode='gcd20', pw_groups=1):
|
||||
super().__init__()
|
||||
g_in = tcn_groups(n_inputs, groups_mode)
|
||||
g_out = tcn_groups(n_outputs, groups_mode)
|
||||
self.groups = (g_in, g_out)
|
||||
self.pw_groups = pw_groups
|
||||
|
||||
self.conv1_group = nn.Conv1d(n_inputs, n_inputs, kernel_size, stride=stride,
|
||||
padding=padding, dilation=dilation,
|
||||
groups=g_in, bias=False)
|
||||
self.chomp1 = Chomp1d(padding) if padding > 0 else nn.Identity()
|
||||
self.bn1_group = nn.BatchNorm1d(n_inputs)
|
||||
self.relu1_group = nn.SiLU(inplace=True)
|
||||
|
||||
self.conv1_pw = nn.Conv1d(n_inputs, n_outputs, 1, groups=pw_groups, bias=False)
|
||||
self.bn1_pw = nn.BatchNorm1d(n_outputs)
|
||||
self.relu1_pw = nn.SiLU(inplace=True)
|
||||
self.dropout1 = nn.Dropout(dropout)
|
||||
|
||||
self.conv2_group = nn.Conv1d(n_outputs, n_outputs, kernel_size, stride=1,
|
||||
padding=padding, dilation=dilation,
|
||||
groups=g_out, bias=False)
|
||||
self.chomp2 = Chomp1d(padding) if padding > 0 else nn.Identity()
|
||||
self.bn2_group = nn.BatchNorm1d(n_outputs)
|
||||
self.relu2_group = nn.SiLU(inplace=True)
|
||||
|
||||
self.conv2_pw = nn.Conv1d(n_outputs, n_outputs, 1, bias=False)
|
||||
self.bn2_pw = nn.BatchNorm1d(n_outputs)
|
||||
self.relu2_pw = nn.SiLU(inplace=True)
|
||||
self.dropout2 = nn.Dropout(dropout)
|
||||
|
||||
self.downsample = nn.Sequential(
|
||||
nn.Conv1d(n_inputs, n_outputs, 1, groups=pw_groups, bias=False),
|
||||
nn.BatchNorm1d(n_outputs)
|
||||
) if n_inputs != n_outputs else nn.Identity()
|
||||
|
||||
def forward(self, x):
|
||||
res = self.downsample(x)
|
||||
out = self.conv1_group(x)
|
||||
out = self.chomp1(out)
|
||||
out = self.bn1_group(out)
|
||||
out = self.relu1_group(out)
|
||||
out = self.conv1_pw(out)
|
||||
out = self.bn1_pw(out)
|
||||
out = self.relu1_pw(out)
|
||||
out = self.dropout1(out)
|
||||
out = self.conv2_group(out)
|
||||
out = self.chomp2(out)
|
||||
out = self.bn2_group(out)
|
||||
out = self.relu2_group(out)
|
||||
out = self.conv2_pw(out)
|
||||
out = self.bn2_pw(out)
|
||||
out = self.relu2_pw(out)
|
||||
out = self.dropout2(out)
|
||||
return F.silu(out + res)
|
||||
|
||||
|
||||
class CompactTemporalBlock(nn.Module):
|
||||
def __init__(self, num_inputs, num_channels, kernel_size=3, dropout=0.2,
|
||||
groups_mode='gcd20', input_pw_groups=1):
|
||||
super().__init__()
|
||||
layers = []
|
||||
for i, out_channels in enumerate(num_channels):
|
||||
dilation_size = 2 ** i
|
||||
in_channels = num_inputs if i == 0 else num_channels[i - 1]
|
||||
layers.append(CompactGroupedTemporalBlock(
|
||||
in_channels, out_channels, kernel_size, stride=1,
|
||||
dilation=dilation_size, padding=(kernel_size - 1) * dilation_size,
|
||||
dropout=dropout, groups_mode=groups_mode,
|
||||
pw_groups=input_pw_groups if i == 0 else 1))
|
||||
self.network = nn.Sequential(*layers)
|
||||
|
||||
def forward(self, x):
|
||||
return self.network(x)
|
||||
|
||||
|
||||
# ------------------------------------------------------- Conv2d path (copy of convnet.py)
|
||||
class AsymmetricConvBlock(nn.Module):
|
||||
"""Upstream block with parameterized width stride (upstream: always (1,2))."""
|
||||
|
||||
def __init__(self, in_channels, out_channels, dropout=0.3, stride_w=2):
|
||||
super().__init__()
|
||||
self.block = nn.Sequential(
|
||||
nn.Conv2d(in_channels, out_channels, kernel_size=(1, 3),
|
||||
stride=(1, stride_w), padding=(0, 1)),
|
||||
nn.BatchNorm2d(out_channels),
|
||||
nn.SiLU(inplace=True),
|
||||
nn.Dropout2d(dropout),
|
||||
nn.Conv2d(out_channels, out_channels, kernel_size=(1, 3), padding=(0, 1)),
|
||||
nn.BatchNorm2d(out_channels),
|
||||
nn.SiLU(inplace=True),
|
||||
nn.Dropout2d(dropout),
|
||||
nn.Conv2d(out_channels, out_channels, kernel_size=(1, 3), padding=(0, 1)),
|
||||
nn.BatchNorm2d(out_channels)
|
||||
)
|
||||
self.downsample = nn.Sequential(
|
||||
nn.Conv2d(in_channels, out_channels, kernel_size=1,
|
||||
stride=(1, stride_w), bias=False),
|
||||
nn.BatchNorm2d(out_channels)
|
||||
)
|
||||
self.activation = nn.SiLU(inplace=True)
|
||||
|
||||
def forward(self, x):
|
||||
return self.activation(self.block(x) + self.downsample(x))
|
||||
|
||||
|
||||
class ConvBlock1(nn.Module):
|
||||
def __init__(self, in_channels, out_channels, dropout=0.3):
|
||||
super().__init__()
|
||||
self.block = nn.Sequential(
|
||||
nn.Conv2d(in_channels, out_channels, kernel_size=(1, 3), padding=(0, 1)),
|
||||
nn.BatchNorm2d(out_channels),
|
||||
nn.SiLU(inplace=True),
|
||||
nn.Dropout2d(dropout),
|
||||
nn.Conv2d(out_channels, out_channels, kernel_size=(1, 3), padding=(0, 1)),
|
||||
nn.BatchNorm2d(out_channels),
|
||||
nn.SiLU(inplace=True),
|
||||
nn.Dropout2d(dropout),
|
||||
nn.Conv2d(out_channels, out_channels, kernel_size=(1, 3), padding=(0, 1)),
|
||||
nn.BatchNorm2d(out_channels)
|
||||
)
|
||||
self.downsample = nn.Sequential(
|
||||
nn.Conv2d(in_channels, out_channels, kernel_size=1, stride=1, bias=False),
|
||||
nn.BatchNorm2d(out_channels)
|
||||
)
|
||||
self.activation = nn.SiLU(inplace=True)
|
||||
|
||||
def forward(self, x):
|
||||
return self.activation(self.block(x) + self.downsample(x))
|
||||
|
||||
|
||||
# ----------------------------------------------------- attention (verbatim attention.py)
|
||||
class AxialAttention(nn.Module):
|
||||
def __init__(self, in_planes, out_planes, groups=8, stride=1, bias=False, width=False):
|
||||
assert (in_planes % groups == 0) and (out_planes % groups == 0)
|
||||
super().__init__()
|
||||
self.in_planes = in_planes
|
||||
self.out_planes = out_planes
|
||||
self.groups = groups
|
||||
self.group_planes = out_planes // groups
|
||||
self.stride = stride
|
||||
self.bias = bias
|
||||
self.width = width
|
||||
self.qkv_transform = nn.Conv1d(in_planes, out_planes * 3, kernel_size=1,
|
||||
stride=1, padding=0, bias=False)
|
||||
self.bn_qkv = nn.BatchNorm1d(out_planes * 3)
|
||||
self.bn_similarity = nn.BatchNorm2d(groups)
|
||||
self.bn_output = nn.BatchNorm1d(out_planes)
|
||||
if stride > 1:
|
||||
self.pooling = nn.AvgPool2d(stride, stride=stride)
|
||||
nn.init.normal_(self.qkv_transform.weight.data, 0, math.sqrt(1. / self.in_planes))
|
||||
|
||||
def forward(self, x):
|
||||
if self.width:
|
||||
x = x.permute(0, 2, 1, 3)
|
||||
else:
|
||||
x = x.permute(0, 3, 1, 2)
|
||||
N, W, C, H = x.shape
|
||||
x = x.contiguous().view(N * W, C, H)
|
||||
qkv = self.bn_qkv(self.qkv_transform(x))
|
||||
qkv = qkv.reshape(N * W, 3, self.out_planes, H).permute(1, 0, 2, 3)
|
||||
q, k, v = qkv[0], qkv[1], qkv[2]
|
||||
q = q.reshape(N * W, self.groups, self.group_planes, H)
|
||||
k = k.reshape(N * W, self.groups, self.group_planes, H)
|
||||
v = v.reshape(N * W, self.groups, self.group_planes, H)
|
||||
qk = torch.einsum('bgci, bgcj->bgij', q, k)
|
||||
qk = self.bn_similarity(qk)
|
||||
similarity = F.softmax(qk, dim=-1)
|
||||
sv = torch.einsum('bgij,bgcj->bgci', similarity, v)
|
||||
sv = sv.reshape(N * W, self.out_planes, H)
|
||||
out = self.bn_output(sv)
|
||||
out = out.view(N, W, self.out_planes, H)
|
||||
if self.width:
|
||||
out = out.permute(0, 2, 1, 3)
|
||||
else:
|
||||
out = out.permute(0, 2, 3, 1)
|
||||
if self.stride > 1:
|
||||
out = self.pooling(out)
|
||||
return out
|
||||
|
||||
|
||||
class DualAxialAttention(nn.Module):
|
||||
def __init__(self, in_planes, out_planes, groups=8, stride=1, bias=False):
|
||||
super().__init__()
|
||||
self.width_axis = AxialAttention(in_planes, out_planes, groups, stride, bias, width=True)
|
||||
self.height_axis = AxialAttention(out_planes, out_planes, groups, stride, bias, width=False)
|
||||
|
||||
def forward(self, x):
|
||||
return self.height_axis(self.width_axis(x))
|
||||
|
||||
|
||||
# --------------------------------------------------------------- full model
|
||||
def compute_strides(width: int, n_blocks: int, target: int = 15):
|
||||
"""Halve width while result stays >= target (upstream: 240 -> 4 halvings -> 15)."""
|
||||
strides = []
|
||||
for _ in range(n_blocks):
|
||||
nxt = (width + 1) // 2 # conv k=3 s=2 p=1: out = ceil(in/2)
|
||||
if nxt >= target:
|
||||
strides.append(2)
|
||||
width = nxt
|
||||
else:
|
||||
strides.append(1)
|
||||
return strides, width
|
||||
|
||||
|
||||
class CompactWiFlowPoseModel(nn.Module):
|
||||
"""Parameterized upstream WiFlowPoseModel.
|
||||
|
||||
Upstream config == tcn_channels=[540,440,340,240], conv_channels=[8,16,32,64],
|
||||
attn_groups=8, groups_mode='gcd20' (gcd(c,20)==20 for all upstream channels),
|
||||
input_pw_groups=1 -> identical architecture, 2,225,042 params.
|
||||
"""
|
||||
|
||||
def __init__(self, tcn_channels, conv_channels, attn_groups,
|
||||
groups_mode='gcd20', input_pw_groups=1, dropout=0.3,
|
||||
num_subcarriers=540, num_keypoints=15):
|
||||
super().__init__()
|
||||
self.tcn = CompactTemporalBlock(
|
||||
num_inputs=num_subcarriers, num_channels=tcn_channels, kernel_size=3,
|
||||
dropout=dropout, groups_mode=groups_mode, input_pw_groups=input_pw_groups)
|
||||
|
||||
self.up = ConvBlock1(1, conv_channels[0])
|
||||
|
||||
strides, self.final_width = compute_strides(
|
||||
tcn_channels[-1], len(conv_channels), target=num_keypoints)
|
||||
self.conv_strides = strides
|
||||
self.residual_blocks = nn.ModuleList()
|
||||
in_channels = conv_channels[0]
|
||||
for out_channels, s in zip(conv_channels, strides):
|
||||
self.residual_blocks.append(
|
||||
AsymmetricConvBlock(in_channels, out_channels, stride_w=s))
|
||||
in_channels = out_channels
|
||||
|
||||
c_last = conv_channels[-1]
|
||||
self.attention = DualAxialAttention(c_last, c_last, groups=attn_groups)
|
||||
|
||||
c_mid = max(c_last // 2, 4)
|
||||
self.decoder = nn.Sequential(
|
||||
nn.Conv2d(c_last, c_mid, kernel_size=3, padding=1),
|
||||
nn.BatchNorm2d(c_mid),
|
||||
nn.SiLU(inplace=True),
|
||||
nn.Conv2d(c_mid, 2, kernel_size=1),
|
||||
nn.BatchNorm2d(2),
|
||||
nn.SiLU(inplace=True)
|
||||
)
|
||||
self.avg_pool = nn.AdaptiveAvgPool2d((num_keypoints, 1))
|
||||
self._initialize_weights()
|
||||
|
||||
def _initialize_weights(self):
|
||||
for m in self.modules():
|
||||
if isinstance(m, nn.Conv1d):
|
||||
nn.init.kaiming_normal_(m.weight, mode='fan_out', nonlinearity='relu')
|
||||
if m.bias is not None:
|
||||
nn.init.constant_(m.bias, 0)
|
||||
elif isinstance(m, (nn.BatchNorm1d, nn.LayerNorm)):
|
||||
nn.init.constant_(m.weight, 1)
|
||||
nn.init.constant_(m.bias, 0)
|
||||
elif isinstance(m, nn.Linear):
|
||||
nn.init.xavier_normal_(m.weight)
|
||||
if m.bias is not None:
|
||||
nn.init.constant_(m.bias, 0)
|
||||
|
||||
def forward(self, x):
|
||||
# [B, 540, 20]
|
||||
x = self.tcn(x) # [B, C_tcn, 20]
|
||||
x = x.transpose(1, 2).unsqueeze(1) # [B, 1, 20, C_tcn]
|
||||
x = self.up(x)
|
||||
for block in self.residual_blocks:
|
||||
x = block(x) # [B, C_conv, 20, W']
|
||||
x = x.permute(0, 1, 3, 2) # [B, C_conv, W', 20]
|
||||
x = self.attention(x)
|
||||
x = self.decoder(x) # [B, 2, W', 20]
|
||||
x = self.avg_pool(x).squeeze(-1) # [B, 2, 15]
|
||||
return x.transpose(1, 2) # [B, 15, 2]
|
||||
|
||||
|
||||
def describe(model: 'CompactWiFlowPoseModel'):
|
||||
params = sum(p.numel() for p in model.parameters())
|
||||
tcn_g = [blk.groups for blk in model.tcn.network]
|
||||
return {'params': params, 'tcn_groups_per_block': tcn_g,
|
||||
'conv_strides': model.conv_strides, 'final_width': model.final_width}
|
||||
@@ -0,0 +1,278 @@
|
||||
"""WiFlow-STD compact-variant efficiency sweep (ADR-152) — sequential overnight runner.
|
||||
|
||||
Trains compact variants of the upstream WiFlow-STD architecture on the same
|
||||
data/split as the full-size reference retraining (seed 42, file-level 70/15/15,
|
||||
upstream dataset.py) and evaluates PCK@10..50 + MPJPE on the full test split and
|
||||
the corruption-free test subset (file indices < 487).
|
||||
|
||||
Training mirrors upstream run.py/train.py defaults except:
|
||||
- fp32 only (no fp16 autocast / GradScaler — avoids the BN-poisoning trap
|
||||
documented in RESULTS.md defect 5; data on disk is already cleaned).
|
||||
- batch 64 (kept modest: another GPU job may share the 16 GB card tonight).
|
||||
- scheduler + early stopping keyed on val MPJPE (upstream early-stops on val MPE
|
||||
with patience 5; same here).
|
||||
|
||||
Usage:
|
||||
venv/bin/python sweep/run_sweep.py --dry-run # param counts only
|
||||
nohup venv/bin/python sweep/run_sweep.py > sweep/sweep.log 2>&1 &
|
||||
|
||||
Idempotent: variants already present in sweep/results.jsonl are skipped.
|
||||
|
||||
NOTE: deployed to ruvultra (~/wiflow-std-bench/sweep) as a standalone file, so
|
||||
it deliberately inlines its helpers. The reference implementations (upstream
|
||||
import shim, >1GB np.load mmap patch, key-remap loader, canonical evaluate
|
||||
loop) live in benchmarks/wiflow-std/_bench_common.py — keep copies in sync.
|
||||
"""
|
||||
import argparse
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import sys
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
from torch.utils.data import DataLoader, Subset
|
||||
|
||||
# csi_windows.npy is ~13 GB; mmap large arrays instead of eagerly loading
|
||||
# ~15 GB into RAM (same patch as _bench_common._np_load_mmap).
|
||||
_np_load = np.load
|
||||
|
||||
|
||||
def _np_load_mmap(path, *a, **kw):
|
||||
if (isinstance(path, str) and path.endswith('.npy')
|
||||
and os.path.getsize(path) > 1 << 30 and 'mmap_mode' not in kw):
|
||||
kw['mmap_mode'] = 'r'
|
||||
return _np_load(path, *a, **kw)
|
||||
|
||||
|
||||
np.load = _np_load_mmap
|
||||
|
||||
BENCH = os.path.expanduser('~/wiflow-std-bench')
|
||||
SWEEP = os.path.join(BENCH, 'sweep')
|
||||
sys.path.insert(0, os.path.join(BENCH, 'upstream'))
|
||||
sys.path.insert(0, SWEEP)
|
||||
|
||||
from dataset import PreprocessedCSIKeypointsDataset, create_preprocessed_train_val_test_loaders # noqa: E402
|
||||
from losses.pose_loss import PoseLoss # noqa: E402
|
||||
from utils.metrics import calculate_pck, calculate_mpjpe # noqa: E402
|
||||
from model_compact import CompactWiFlowPoseModel, describe # noqa: E402
|
||||
|
||||
VARIANTS = [
|
||||
# name, tcn_channels, conv_channels, attn_groups, groups_mode, input_pw_groups
|
||||
dict(name='half', tcn=[270, 220, 170, 120], conv=[4, 8, 16, 32], attn_groups=4,
|
||||
groups_mode='gcd20', input_pw_groups=1),
|
||||
dict(name='quarter', tcn=[135, 110, 85, 60], conv=[2, 4, 8, 16], attn_groups=2,
|
||||
groups_mode='gcd20', input_pw_groups=1),
|
||||
dict(name='tiny', tcn=[68, 56, 44, 32], conv=[2, 4, 8, 16], attn_groups=2,
|
||||
groups_mode='depthwise', input_pw_groups=4),
|
||||
]
|
||||
|
||||
BATCH = 64
|
||||
EPOCHS = 50
|
||||
PATIENCE = 5
|
||||
LR = 1e-4
|
||||
WEIGHT_DECAY = 5e-5
|
||||
SEED = 42
|
||||
CORRUPT_FILE_START = 487 # files 487-499 were zero-filled by clean_nan.py
|
||||
|
||||
|
||||
def set_seed(seed=SEED):
|
||||
random.seed(seed)
|
||||
np.random.seed(seed)
|
||||
torch.manual_seed(seed)
|
||||
torch.cuda.manual_seed_all(seed)
|
||||
torch.backends.cudnn.deterministic = True
|
||||
torch.backends.cudnn.benchmark = False
|
||||
|
||||
|
||||
def build_model(v, dropout=0.5):
|
||||
return CompactWiFlowPoseModel(
|
||||
tcn_channels=v['tcn'], conv_channels=v['conv'], attn_groups=v['attn_groups'],
|
||||
groups_mode=v['groups_mode'], input_pw_groups=v['input_pw_groups'],
|
||||
dropout=dropout)
|
||||
|
||||
|
||||
@torch.no_grad()
|
||||
def evaluate(model, loader, device):
|
||||
model.eval()
|
||||
totals = {t: 0.0 for t in (0.1, 0.2, 0.3, 0.4, 0.5)}
|
||||
total_mpe, n = 0.0, 0
|
||||
for bx, by in loader:
|
||||
bx, by = bx.to(device), by.to(device)
|
||||
out = model(bx)
|
||||
bs = by.size(0)
|
||||
total_mpe += calculate_mpjpe(out, by) * bs
|
||||
pck = calculate_pck(out, by, thresholds=list(totals))
|
||||
for t in totals:
|
||||
totals[t] += pck[t] * bs
|
||||
n += bs
|
||||
return {'samples': n, 'mpjpe': total_mpe / n,
|
||||
**{f'pck@{int(t * 100)}': totals[t] / n for t in totals}}
|
||||
|
||||
|
||||
def train_variant(v, dataset, device):
|
||||
set_seed(SEED)
|
||||
train_loader, val_loader, test_loader = create_preprocessed_train_val_test_loaders(
|
||||
dataset=dataset, batch_size=BATCH, num_workers=2, random_seed=SEED)
|
||||
|
||||
set_seed(SEED) # re-seed after split so init is split-independent
|
||||
model = build_model(v).to(device)
|
||||
info = describe(model)
|
||||
print(f"[{v['name']}] params={info['params']:,} tcn_groups={info['tcn_groups_per_block']} "
|
||||
f"conv_strides={info['conv_strides']} final_width={info['final_width']}", flush=True)
|
||||
|
||||
criterion = PoseLoss(position_weight=1.0, bone_weight=0.2, loss_type='smooth_l1')
|
||||
optimizer = torch.optim.AdamW(model.parameters(), lr=LR, weight_decay=WEIGHT_DECAY,
|
||||
betas=(0.9, 0.999))
|
||||
scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau(
|
||||
optimizer, mode='min', factor=0.5, patience=3, min_lr=LR / 1000,
|
||||
cooldown=1, threshold=1e-4)
|
||||
|
||||
best_val_mpe = float('inf')
|
||||
best_val_pck20 = 0.0
|
||||
best_epoch = 0
|
||||
best_state = None
|
||||
patience_counter = 0
|
||||
t0 = time.time()
|
||||
error = None
|
||||
epochs_run = 0
|
||||
|
||||
for epoch in range(1, EPOCHS + 1):
|
||||
model.train()
|
||||
ep_loss, nb = 0.0, 0
|
||||
te = time.time()
|
||||
for i, (bx, by) in enumerate(train_loader):
|
||||
bx = bx.to(device, non_blocking=True)
|
||||
by = by.to(device, non_blocking=True)
|
||||
optimizer.zero_grad(set_to_none=True)
|
||||
out = model(bx)
|
||||
loss, _parts = criterion(out, by)
|
||||
if not torch.isfinite(loss):
|
||||
error = f'non-finite loss at epoch {epoch} step {i}'
|
||||
break
|
||||
loss.backward()
|
||||
optimizer.step()
|
||||
ep_loss += loss.item()
|
||||
nb += 1
|
||||
if epoch == 1 and i % 500 == 0:
|
||||
print(f"[{v['name']}] e1 step {i}/{len(train_loader)} loss={loss.item():.5f}",
|
||||
flush=True)
|
||||
if error:
|
||||
break
|
||||
epochs_run = epoch
|
||||
|
||||
val = evaluate(model, val_loader, device)
|
||||
scheduler.step(val['mpjpe'])
|
||||
lr_now = optimizer.param_groups[0]['lr']
|
||||
print(f"[{v['name']}] epoch {epoch}/{EPOCHS} train_loss={ep_loss / max(nb, 1):.5f} "
|
||||
f"val_mpjpe={val['mpjpe']:.5f} val_pck20={val['pck@20'] * 100:.2f}% "
|
||||
f"lr={lr_now:.2e} ({time.time() - te:.0f}s)", flush=True)
|
||||
|
||||
if val['mpjpe'] < best_val_mpe:
|
||||
best_val_mpe = val['mpjpe']
|
||||
best_val_pck20 = val['pck@20']
|
||||
best_epoch = epoch
|
||||
best_state = copy.deepcopy(model.state_dict())
|
||||
patience_counter = 0
|
||||
else:
|
||||
patience_counter += 1
|
||||
if patience_counter >= PATIENCE:
|
||||
print(f"[{v['name']}] early stop at epoch {epoch} (best {best_epoch})", flush=True)
|
||||
break
|
||||
|
||||
train_seconds = time.time() - t0
|
||||
result = {
|
||||
'variant': v['name'], 'params': info['params'],
|
||||
'tcn_channels': v['tcn'], 'conv_channels': v['conv'],
|
||||
'attn_groups': v['attn_groups'], 'groups_mode': v['groups_mode'],
|
||||
'input_pw_groups': v['input_pw_groups'],
|
||||
'tcn_groups_per_block': info['tcn_groups_per_block'],
|
||||
'conv_strides': info['conv_strides'], 'final_width': info['final_width'],
|
||||
'batch_size': BATCH, 'max_epochs': EPOCHS, 'patience': PATIENCE,
|
||||
'lr': LR, 'weight_decay': WEIGHT_DECAY, 'seed': SEED, 'precision': 'fp32',
|
||||
'epochs_run': epochs_run, 'best_epoch': best_epoch,
|
||||
'best_val_mpjpe': best_val_mpe if best_state else None,
|
||||
'best_val_pck20': best_val_pck20 if best_state else None,
|
||||
'train_seconds': round(train_seconds, 1),
|
||||
'torch': torch.__version__, 'error': error,
|
||||
'finished_utc': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()),
|
||||
}
|
||||
|
||||
if best_state is not None:
|
||||
ckpt = os.path.join(SWEEP, f"{v['name']}_best.pth")
|
||||
torch.save(best_state, ckpt)
|
||||
result['checkpoint'] = ckpt
|
||||
model.load_state_dict(best_state)
|
||||
|
||||
eval_loader = DataLoader(test_loader.dataset, batch_size=256, shuffle=False,
|
||||
num_workers=2)
|
||||
result['test_full'] = evaluate(model, eval_loader, device)
|
||||
|
||||
w2f = dataset.window_to_file
|
||||
clean_idx = [i for i in test_loader.dataset.indices if w2f[i] < CORRUPT_FILE_START]
|
||||
clean_loader = DataLoader(Subset(dataset, clean_idx), batch_size=256,
|
||||
shuffle=False, num_workers=2)
|
||||
result['test_clean'] = evaluate(model, clean_loader, device)
|
||||
print(f"[{v['name']}] TEST clean: pck20={result['test_clean']['pck@20'] * 100:.2f}% "
|
||||
f"mpjpe={result['test_clean']['mpjpe']:.5f} | full: "
|
||||
f"pck20={result['test_full']['pck@20'] * 100:.2f}%", flush=True)
|
||||
return result
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument('--dry-run', action='store_true', help='print param counts and exit')
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.dry_run:
|
||||
for v in VARIANTS:
|
||||
m = build_model(v)
|
||||
info = describe(m)
|
||||
x = torch.randn(2, 540, 20)
|
||||
m.eval()
|
||||
y = m(x)
|
||||
print(f"{v['name']:8s} params={info['params']:>9,} "
|
||||
f"tcn={v['tcn']} conv={v['conv']} attn_g={v['attn_groups']} "
|
||||
f"mode={v['groups_mode']} pw_g={v['input_pw_groups']} "
|
||||
f"tcn_groups={info['tcn_groups_per_block']} strides={info['conv_strides']} "
|
||||
f"W'={info['final_width']} out={tuple(y.shape)}")
|
||||
return
|
||||
|
||||
results_path = os.path.join(SWEEP, 'results.jsonl')
|
||||
done = set()
|
||||
if os.path.exists(results_path):
|
||||
with open(results_path) as f:
|
||||
for line in f:
|
||||
try:
|
||||
done.add(json.loads(line)['variant'])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
device = torch.device('cuda')
|
||||
print(f"torch {torch.__version__} on {torch.cuda.get_device_name(0)}", flush=True)
|
||||
data_dir = os.path.join(BENCH, 'preprocessed_csi_data')
|
||||
dataset = PreprocessedCSIKeypointsDataset(data_dir=data_dir, keypoint_scale=1000.0,
|
||||
enable_temporal_clean=True)
|
||||
|
||||
for v in VARIANTS:
|
||||
if v['name'] in done:
|
||||
print(f"[{v['name']}] already in results.jsonl — skipping", flush=True)
|
||||
continue
|
||||
print(f"\n===== variant: {v['name']} =====", flush=True)
|
||||
try:
|
||||
result = train_variant(v, dataset, device)
|
||||
except Exception as e: # record and move on to next variant
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
result = {'variant': v['name'], 'error': repr(e),
|
||||
'finished_utc': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())}
|
||||
with open(results_path, 'a') as f:
|
||||
f.write(json.dumps(result) + '\n')
|
||||
f.flush()
|
||||
print('\nSWEEP COMPLETE', flush=True)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Binary file not shown.
@@ -0,0 +1,772 @@
|
||||
{
|
||||
"torch": {
|
||||
"env": {
|
||||
"torch": "2.12.0+cpu",
|
||||
"platform": "Windows-11-10.0.26200-SP0",
|
||||
"processor": "Intel64 Family 6 Model 197 Stepping 2, GenuineIntel",
|
||||
"num_threads": 16,
|
||||
"checkpoint": "results\\retrained_best_pose_model.pth",
|
||||
"params": 2225042
|
||||
},
|
||||
"variants": {
|
||||
"fp32": {
|
||||
"file": "retrained_fp32_resaved.pth",
|
||||
"size_bytes": 9068948,
|
||||
"size_mb": 9.068948,
|
||||
"latency_batch1": {
|
||||
"batch_size": 1,
|
||||
"runs": 100,
|
||||
"median_ms_per_batch": 24.903650000851485,
|
||||
"median_ms_per_window": 24.903650000851485,
|
||||
"windows_per_second": 40.15475642991324
|
||||
},
|
||||
"latency_batch64": {
|
||||
"batch_size": 64,
|
||||
"runs": 30,
|
||||
"median_ms_per_batch": 184.02919999789447,
|
||||
"median_ms_per_window": 2.875456249967101,
|
||||
"windows_per_second": 347.77089723115813
|
||||
},
|
||||
"accuracy": {
|
||||
"samples": 10000,
|
||||
"pck@20": 0.9668200004577636,
|
||||
"pck@50": 0.9915333324432373,
|
||||
"mpjpe": 0.00936222033649683,
|
||||
"wall_seconds": 37.85407733917236
|
||||
}
|
||||
},
|
||||
"fp16": {
|
||||
"file": "retrained_fp16.pth",
|
||||
"size_bytes": 4580332,
|
||||
"size_mb": 4.580332,
|
||||
"latency_batch1": {
|
||||
"batch_size": 1,
|
||||
"runs": 100,
|
||||
"median_ms_per_batch": 23.936699999467237,
|
||||
"median_ms_per_window": 23.936699999467237,
|
||||
"windows_per_second": 41.776853117691964
|
||||
},
|
||||
"latency_batch64": {
|
||||
"batch_size": 64,
|
||||
"runs": 30,
|
||||
"median_ms_per_batch": 102.32584999903338,
|
||||
"median_ms_per_window": 1.5988414062348966,
|
||||
"windows_per_second": 625.4529036465817
|
||||
},
|
||||
"accuracy": {
|
||||
"samples": 10000,
|
||||
"pck@20": 0.966773332977295,
|
||||
"pck@50": 0.9915066654205322,
|
||||
"mpjpe": 0.009460017587244511,
|
||||
"wall_seconds": 21.632277250289917
|
||||
}
|
||||
},
|
||||
"int8_dynamic": {
|
||||
"file": "retrained_int8_dynamic.pth",
|
||||
"size_bytes": 9068948,
|
||||
"size_mb": 9.068948,
|
||||
"latency_batch1": {
|
||||
"batch_size": 1,
|
||||
"runs": 100,
|
||||
"median_ms_per_batch": 18.105350000041653,
|
||||
"median_ms_per_window": 18.105350000041653,
|
||||
"windows_per_second": 55.23229321707117
|
||||
},
|
||||
"latency_batch64": {
|
||||
"batch_size": 64,
|
||||
"runs": 30,
|
||||
"median_ms_per_batch": 168.77549999844632,
|
||||
"median_ms_per_window": 2.6371171874757238,
|
||||
"windows_per_second": 379.20195763359703
|
||||
},
|
||||
"accuracy": {
|
||||
"samples": 10000,
|
||||
"pck@20": 0.9668200004577636,
|
||||
"pck@50": 0.9915333324432373,
|
||||
"mpjpe": 0.00936222033649683,
|
||||
"wall_seconds": 45.35376596450806
|
||||
}
|
||||
}
|
||||
},
|
||||
"int8_dynamic_quant_report": {
|
||||
"eligible_module_counts": {
|
||||
"nn.Linear": 0,
|
||||
"nn.Conv1d": 21,
|
||||
"nn.Conv2d": 22
|
||||
},
|
||||
"modules_actually_quantized": [],
|
||||
"n_modules_quantized": 0,
|
||||
"params_total": 2225042,
|
||||
"params_quantized": 0,
|
||||
"params_quantized_fraction": 0.0
|
||||
},
|
||||
"accuracy_subset": {
|
||||
"description": "seed-42 file-level 70/15/15 test split, corrupted windows (files 487-499) excluded, seed-42 random subset",
|
||||
"subset_size": 10000,
|
||||
"clean_test_total": 10000
|
||||
}
|
||||
},
|
||||
"onnx": {
|
||||
"env": {
|
||||
"torch": "2.12.0+cpu",
|
||||
"onnxruntime": "1.26.0",
|
||||
"platform": "Windows-11-10.0.26200-SP0"
|
||||
},
|
||||
"export": {
|
||||
"mode": "dynamic-batch",
|
||||
"exporter": "torchscript",
|
||||
"file": "retrained_fp32_dynamic.onnx",
|
||||
"size_mb": 8.971781
|
||||
},
|
||||
"parity": {
|
||||
"fixture": "results/parity_fixture.npz (batch 2, seed 42)",
|
||||
"max_abs_diff_vs_stored_fixture": 2.384185791015625e-07,
|
||||
"max_abs_diff_vs_torch_now": 2.384185791015625e-07,
|
||||
"pass_lt_1e-4": true
|
||||
},
|
||||
"latency": {
|
||||
"batch1": {
|
||||
"batch_size": 1,
|
||||
"runs": 100,
|
||||
"median_ms_per_batch": 2.5410999987798277,
|
||||
"median_ms_per_window": 2.5410999987798277,
|
||||
"windows_per_second": 393.5303610563043
|
||||
},
|
||||
"batch64": {
|
||||
"batch_size": 64,
|
||||
"runs": 30,
|
||||
"median_ms_per_batch": 181.95204999938142,
|
||||
"median_ms_per_window": 2.8430007812403346,
|
||||
"windows_per_second": 351.7410218803118
|
||||
}
|
||||
},
|
||||
"ort_int8_dynamic_supplementary": {
|
||||
"file": "retrained_int8_ort_dynamic.onnx",
|
||||
"size_mb": 2.438794,
|
||||
"runs": true,
|
||||
"max_abs_diff_vs_fp32_fixture": 0.00827130675315857
|
||||
}
|
||||
},
|
||||
"onnx_accuracy": {
|
||||
"onnx_fp32": {
|
||||
"samples": 10000,
|
||||
"pck@20": 0.9668200004577636,
|
||||
"pck@50": 0.9915333324432373,
|
||||
"mpjpe": 0.00936222568154335,
|
||||
"wall_seconds": 22.34790802001953
|
||||
},
|
||||
"onnx_int8_ort_dynamic": {
|
||||
"samples": 10000,
|
||||
"pck@20": 0.965240001964569,
|
||||
"pck@50": 0.9915466655731201,
|
||||
"mpjpe": 0.01108054072111845,
|
||||
"wall_seconds": 55.742953062057495
|
||||
}
|
||||
},
|
||||
"latency_controlled_rerun": {
|
||||
"note": "3 interleaved repetitions per variant, median ms/window; quiet box",
|
||||
"fp32": {
|
||||
"batch1_ms_per_window_median": 10.969150001983508,
|
||||
"batch1_reps": [
|
||||
10.969150001983508,
|
||||
12.646450000829645,
|
||||
10.49820000116597
|
||||
],
|
||||
"batch64_ms_per_window_median": 2.2734187500077496,
|
||||
"batch64_reps": [
|
||||
2.377234374989712,
|
||||
2.124126562478068,
|
||||
2.2734187500077496
|
||||
]
|
||||
},
|
||||
"fp16": {
|
||||
"batch1_ms_per_window_median": 24.313550000442774,
|
||||
"batch1_reps": [
|
||||
25.1078499986761,
|
||||
21.856999999727122,
|
||||
24.313550000442774
|
||||
],
|
||||
"batch64_ms_per_window_median": 2.414695312495496,
|
||||
"batch64_reps": [
|
||||
2.5705156249955508,
|
||||
1.7137437499741281,
|
||||
2.414695312495496
|
||||
]
|
||||
},
|
||||
"int8_dynamic": {
|
||||
"batch1_ms_per_window_median": 15.627150000000256,
|
||||
"batch1_reps": [
|
||||
17.67525000104797,
|
||||
14.627999998992891,
|
||||
15.627150000000256
|
||||
],
|
||||
"batch64_ms_per_window_median": 2.0546906250160646,
|
||||
"batch64_reps": [
|
||||
2.0546906250160646,
|
||||
2.03407343752815,
|
||||
2.9325796875241394
|
||||
]
|
||||
},
|
||||
"onnx_fp32": {
|
||||
"batch1_ms_per_window_median": 3.186650001225644,
|
||||
"batch1_reps": [
|
||||
2.7332500012562377,
|
||||
3.1995500012271805,
|
||||
3.186650001225644
|
||||
],
|
||||
"batch64_ms_per_window_median": 1.9893374999924163,
|
||||
"batch64_reps": [
|
||||
1.5590843750032946,
|
||||
1.9893374999924163,
|
||||
2.2144343749914697
|
||||
]
|
||||
},
|
||||
"onnx_int8_ort_dynamic": {
|
||||
"batch1_ms_per_window_median": 6.50984999811044,
|
||||
"batch1_reps": [
|
||||
6.50984999811044,
|
||||
6.455249998907675,
|
||||
6.789299999581999
|
||||
],
|
||||
"batch64_ms_per_window_median": 5.770093750015803,
|
||||
"batch64_reps": [
|
||||
5.770093750015803,
|
||||
3.912374999970325,
|
||||
7.8067296875019565
|
||||
]
|
||||
}
|
||||
},
|
||||
"onnx_static_ptq": {
|
||||
"env": {
|
||||
"onnxruntime": "1.26.0",
|
||||
"torch": "2.12.0+cpu",
|
||||
"platform": "Windows-11-10.0.26200-SP0",
|
||||
"source_model": "retrained_fp32_dynamic.onnx",
|
||||
"preprocessed_model": {
|
||||
"file": "retrained_fp32_preproc.onnx",
|
||||
"size_mb": 8.981529
|
||||
}
|
||||
},
|
||||
"variants": {
|
||||
"minmax_all": {
|
||||
"file": "retrained_int8_static_minmax_all.onnx",
|
||||
"size_bytes": 2604286,
|
||||
"size_mb": 2.604286,
|
||||
"calibration": {
|
||||
"method": "minmax",
|
||||
"windows": 1000,
|
||||
"percentile": null,
|
||||
"seconds": 5.052440166473389
|
||||
},
|
||||
"scope": "all",
|
||||
"per_channel": true,
|
||||
"activation_type": "QInt8",
|
||||
"weight_type": "QInt8",
|
||||
"node_counts": {
|
||||
"Add": 9,
|
||||
"AveragePool": 1,
|
||||
"BatchNormalization": 12,
|
||||
"Concat": 10,
|
||||
"Conv": 43,
|
||||
"DequantizeLinear": 283,
|
||||
"Einsum": 4,
|
||||
"Gather": 16,
|
||||
"Mul": 39,
|
||||
"QuantizeLinear": 181,
|
||||
"Reshape": 14,
|
||||
"Shape": 2,
|
||||
"Sigmoid": 37,
|
||||
"Slice": 8,
|
||||
"Softmax": 2,
|
||||
"Squeeze": 1,
|
||||
"Transpose": 7,
|
||||
"Unsqueeze": 11
|
||||
},
|
||||
"max_abs_diff_vs_fp32_fixture": 0.015945255756378174,
|
||||
"accuracy": {
|
||||
"samples": 10000,
|
||||
"pck@20": 0.9545266661643982,
|
||||
"pck@50": 0.9913666645050049,
|
||||
"mpjpe": 0.014860070134699345,
|
||||
"wall_seconds": 43.455235958099365
|
||||
}
|
||||
},
|
||||
"minmax_conv": {
|
||||
"file": "retrained_int8_static_minmax_conv.onnx",
|
||||
"size_bytes": 2527421,
|
||||
"size_mb": 2.527421,
|
||||
"calibration": {
|
||||
"method": "minmax",
|
||||
"windows": 1000,
|
||||
"percentile": null,
|
||||
"seconds": 4.380746126174927
|
||||
},
|
||||
"scope": "conv",
|
||||
"per_channel": true,
|
||||
"activation_type": "QInt8",
|
||||
"weight_type": "QInt8",
|
||||
"node_counts": {
|
||||
"Add": 9,
|
||||
"AveragePool": 1,
|
||||
"BatchNormalization": 12,
|
||||
"Concat": 10,
|
||||
"Conv": 43,
|
||||
"DequantizeLinear": 156,
|
||||
"Einsum": 4,
|
||||
"Gather": 16,
|
||||
"Mul": 39,
|
||||
"QuantizeLinear": 78,
|
||||
"Reshape": 14,
|
||||
"Shape": 2,
|
||||
"Sigmoid": 37,
|
||||
"Slice": 8,
|
||||
"Softmax": 2,
|
||||
"Squeeze": 1,
|
||||
"Transpose": 7,
|
||||
"Unsqueeze": 11
|
||||
},
|
||||
"max_abs_diff_vs_fp32_fixture": 0.010693132877349854,
|
||||
"accuracy": {
|
||||
"samples": 10000,
|
||||
"pck@20": 0.9663399996757507,
|
||||
"pck@50": 0.9918666641235352,
|
||||
"mpjpe": 0.01084446222037077,
|
||||
"wall_seconds": 35.937947034835815
|
||||
}
|
||||
},
|
||||
"entropy_all": {
|
||||
"file": "retrained_int8_static_entropy_all.onnx",
|
||||
"size_bytes": 2604268,
|
||||
"size_mb": 2.604268,
|
||||
"calibration": {
|
||||
"method": "entropy",
|
||||
"windows": 512,
|
||||
"percentile": null,
|
||||
"seconds": 23.835066318511963
|
||||
},
|
||||
"scope": "all",
|
||||
"per_channel": true,
|
||||
"activation_type": "QInt8",
|
||||
"weight_type": "QInt8",
|
||||
"node_counts": {
|
||||
"Add": 9,
|
||||
"AveragePool": 1,
|
||||
"BatchNormalization": 12,
|
||||
"Concat": 10,
|
||||
"Conv": 43,
|
||||
"DequantizeLinear": 283,
|
||||
"Einsum": 4,
|
||||
"Gather": 16,
|
||||
"Mul": 39,
|
||||
"QuantizeLinear": 181,
|
||||
"Reshape": 14,
|
||||
"Shape": 2,
|
||||
"Sigmoid": 37,
|
||||
"Slice": 8,
|
||||
"Softmax": 2,
|
||||
"Squeeze": 1,
|
||||
"Transpose": 7,
|
||||
"Unsqueeze": 11
|
||||
},
|
||||
"max_abs_diff_vs_fp32_fixture": 0.015280365943908691,
|
||||
"accuracy": {
|
||||
"samples": 10000,
|
||||
"pck@20": 0.9530466662406921,
|
||||
"pck@50": 0.9912600006103516,
|
||||
"mpjpe": 0.015098519864678382,
|
||||
"wall_seconds": 51.514281034469604
|
||||
}
|
||||
},
|
||||
"entropy_conv": {
|
||||
"file": "retrained_int8_static_entropy_conv.onnx",
|
||||
"size_bytes": 2527403,
|
||||
"size_mb": 2.527403,
|
||||
"calibration": {
|
||||
"method": "entropy",
|
||||
"windows": 512,
|
||||
"percentile": null,
|
||||
"seconds": 9.634419918060303
|
||||
},
|
||||
"scope": "conv",
|
||||
"per_channel": true,
|
||||
"activation_type": "QInt8",
|
||||
"weight_type": "QInt8",
|
||||
"node_counts": {
|
||||
"Add": 9,
|
||||
"AveragePool": 1,
|
||||
"BatchNormalization": 12,
|
||||
"Concat": 10,
|
||||
"Conv": 43,
|
||||
"DequantizeLinear": 156,
|
||||
"Einsum": 4,
|
||||
"Gather": 16,
|
||||
"Mul": 39,
|
||||
"QuantizeLinear": 78,
|
||||
"Reshape": 14,
|
||||
"Shape": 2,
|
||||
"Sigmoid": 37,
|
||||
"Slice": 8,
|
||||
"Softmax": 2,
|
||||
"Squeeze": 1,
|
||||
"Transpose": 7,
|
||||
"Unsqueeze": 11
|
||||
},
|
||||
"max_abs_diff_vs_fp32_fixture": 0.012535125017166138,
|
||||
"accuracy": {
|
||||
"samples": 10000,
|
||||
"pck@20": 0.9659599989891052,
|
||||
"pck@50": 0.9918666648864746,
|
||||
"mpjpe": 0.010778637571632861,
|
||||
"wall_seconds": 41.01180171966553
|
||||
}
|
||||
},
|
||||
"percentile_all": {
|
||||
"file": "retrained_int8_static_percentile_all.onnx",
|
||||
"size_bytes": 2604052,
|
||||
"size_mb": 2.604052,
|
||||
"calibration": {
|
||||
"method": "percentile",
|
||||
"windows": 512,
|
||||
"percentile": 99.99,
|
||||
"seconds": 20.221954584121704
|
||||
},
|
||||
"scope": "all",
|
||||
"per_channel": true,
|
||||
"activation_type": "QInt8",
|
||||
"weight_type": "QInt8",
|
||||
"node_counts": {
|
||||
"Add": 9,
|
||||
"AveragePool": 1,
|
||||
"BatchNormalization": 12,
|
||||
"Concat": 10,
|
||||
"Conv": 43,
|
||||
"DequantizeLinear": 283,
|
||||
"Einsum": 4,
|
||||
"Gather": 16,
|
||||
"Mul": 39,
|
||||
"QuantizeLinear": 181,
|
||||
"Reshape": 14,
|
||||
"Shape": 2,
|
||||
"Sigmoid": 37,
|
||||
"Slice": 8,
|
||||
"Softmax": 2,
|
||||
"Squeeze": 1,
|
||||
"Transpose": 7,
|
||||
"Unsqueeze": 11
|
||||
},
|
||||
"max_abs_diff_vs_fp32_fixture": 0.017689883708953857,
|
||||
"accuracy": {
|
||||
"samples": 10000,
|
||||
"pck@20": 0.9639333323478698,
|
||||
"pck@50": 0.9916799991607667,
|
||||
"mpjpe": 0.012176512064039708,
|
||||
"wall_seconds": 49.365190744400024
|
||||
}
|
||||
},
|
||||
"percentile_conv": {
|
||||
"file": "retrained_int8_static_percentile_conv.onnx",
|
||||
"size_bytes": 2527241,
|
||||
"size_mb": 2.527241,
|
||||
"calibration": {
|
||||
"method": "percentile",
|
||||
"windows": 512,
|
||||
"percentile": 99.99,
|
||||
"seconds": 8.223475694656372
|
||||
},
|
||||
"scope": "conv",
|
||||
"per_channel": true,
|
||||
"activation_type": "QInt8",
|
||||
"weight_type": "QInt8",
|
||||
"node_counts": {
|
||||
"Add": 9,
|
||||
"AveragePool": 1,
|
||||
"BatchNormalization": 12,
|
||||
"Concat": 10,
|
||||
"Conv": 43,
|
||||
"DequantizeLinear": 156,
|
||||
"Einsum": 4,
|
||||
"Gather": 16,
|
||||
"Mul": 39,
|
||||
"QuantizeLinear": 78,
|
||||
"Reshape": 14,
|
||||
"Shape": 2,
|
||||
"Sigmoid": 37,
|
||||
"Slice": 8,
|
||||
"Softmax": 2,
|
||||
"Squeeze": 1,
|
||||
"Transpose": 7,
|
||||
"Unsqueeze": 11
|
||||
},
|
||||
"max_abs_diff_vs_fp32_fixture": 0.014725983142852783,
|
||||
"accuracy": {
|
||||
"samples": 10000,
|
||||
"pck@20": 0.9660599988937378,
|
||||
"pck@50": 0.9916066654205322,
|
||||
"mpjpe": 0.010310938355326652,
|
||||
"wall_seconds": 36.89548587799072
|
||||
}
|
||||
}
|
||||
},
|
||||
"latency": {
|
||||
"note": "3 interleaved repetitions per variant, median ms/window; onnx_fp32 / onnx_int8_ort_dynamic are same-session references",
|
||||
"onnx_fp32": {
|
||||
"batch1_reps": [
|
||||
4.5327999996516155,
|
||||
2.535649999117595,
|
||||
2.167549997466267
|
||||
],
|
||||
"batch64_reps": [
|
||||
1.9354515624740998,
|
||||
2.4948054687854437,
|
||||
1.9334703125082342
|
||||
],
|
||||
"batch1_ms_per_window_median": 2.535649999117595,
|
||||
"batch64_ms_per_window_median": 1.9354515624740998
|
||||
},
|
||||
"onnx_int8_ort_dynamic": {
|
||||
"batch1_reps": [
|
||||
5.698599999959697,
|
||||
5.721350000385428,
|
||||
4.805099997611251
|
||||
],
|
||||
"batch64_reps": [
|
||||
4.096601562508795,
|
||||
4.857628124995017,
|
||||
4.583800000006022
|
||||
],
|
||||
"batch1_ms_per_window_median": 5.698599999959697,
|
||||
"batch64_ms_per_window_median": 4.583800000006022
|
||||
},
|
||||
"entropy_all": {
|
||||
"batch1_reps": [
|
||||
6.444149999879301,
|
||||
5.038299999796436,
|
||||
5.713200000172947
|
||||
],
|
||||
"batch64_reps": [
|
||||
4.149468750028973,
|
||||
3.437125000004926,
|
||||
4.410960937491382
|
||||
],
|
||||
"batch1_ms_per_window_median": 5.713200000172947,
|
||||
"batch64_ms_per_window_median": 4.149468750028973
|
||||
},
|
||||
"entropy_conv": {
|
||||
"batch1_reps": [
|
||||
4.874750000453787,
|
||||
5.169099998965976,
|
||||
5.236699998931726
|
||||
],
|
||||
"batch64_reps": [
|
||||
3.010160156236452,
|
||||
3.1175546875203963,
|
||||
3.516850781238645
|
||||
],
|
||||
"batch1_ms_per_window_median": 5.169099998965976,
|
||||
"batch64_ms_per_window_median": 3.1175546875203963
|
||||
},
|
||||
"percentile_all": {
|
||||
"batch1_reps": [
|
||||
5.184749999898486,
|
||||
5.2898499998264015,
|
||||
5.916899999647285
|
||||
],
|
||||
"batch64_reps": [
|
||||
4.305105468745296,
|
||||
4.460741406262514,
|
||||
4.184502343747454
|
||||
],
|
||||
"batch1_ms_per_window_median": 5.2898499998264015,
|
||||
"batch64_ms_per_window_median": 4.305105468745296
|
||||
},
|
||||
"percentile_conv": {
|
||||
"batch1_reps": [
|
||||
4.916449999655015,
|
||||
7.150899999032845,
|
||||
5.284949998895172
|
||||
],
|
||||
"batch64_reps": [
|
||||
3.855813281262499,
|
||||
4.688969531230214,
|
||||
5.220103124997877
|
||||
],
|
||||
"batch1_ms_per_window_median": 5.284949998895172,
|
||||
"batch64_ms_per_window_median": 4.688969531230214
|
||||
},
|
||||
"minmax_all": {
|
||||
"batch1_reps": [
|
||||
6.463300000177696,
|
||||
7.149449998905766,
|
||||
5.3209000016067876
|
||||
],
|
||||
"batch64_reps": [
|
||||
3.9251343750095202,
|
||||
4.033442187505898,
|
||||
3.428199218745931
|
||||
],
|
||||
"batch1_ms_per_window_median": 6.463300000177696,
|
||||
"batch64_ms_per_window_median": 3.9251343750095202
|
||||
},
|
||||
"minmax_conv": {
|
||||
"batch1_reps": [
|
||||
5.9961499991914025,
|
||||
5.236549999608542,
|
||||
4.854399998293957
|
||||
],
|
||||
"batch64_reps": [
|
||||
4.368359375007458,
|
||||
3.249617187492504,
|
||||
3.0238906249735464
|
||||
],
|
||||
"batch1_ms_per_window_median": 5.236549999608542,
|
||||
"batch64_ms_per_window_median": 3.249617187492504
|
||||
}
|
||||
},
|
||||
"accuracy_subset": {
|
||||
"description": "seed-42 file-level 70/15/15 test split, corrupted windows excluded, seed-42 random subset (same as quantize_bench/eval_ort_accuracy)",
|
||||
"subset_size": 10000
|
||||
}
|
||||
},
|
||||
"tiny_variant": {
|
||||
"env": {
|
||||
"torch": "2.12.0+cpu",
|
||||
"onnxruntime": "1.26.0",
|
||||
"platform": "Windows-11-10.0.26200-SP0",
|
||||
"num_threads": 16,
|
||||
"checkpoint": "results\\tiny_best.pth",
|
||||
"checkpoint_size_bytes": 340555,
|
||||
"params": 56290,
|
||||
"variant_config": {
|
||||
"tcn": [
|
||||
68,
|
||||
56,
|
||||
44,
|
||||
32
|
||||
],
|
||||
"conv": [
|
||||
2,
|
||||
4,
|
||||
8,
|
||||
16
|
||||
],
|
||||
"attn_groups": 2,
|
||||
"groups_mode": "depthwise",
|
||||
"input_pw_groups": 4
|
||||
}
|
||||
},
|
||||
"export": {
|
||||
"mode": "dynamic-batch",
|
||||
"exporter": "torchscript",
|
||||
"opset": 17,
|
||||
"file": "tiny_fp32_dynamic.onnx",
|
||||
"size_bytes": 295279,
|
||||
"size_mb": 0.295279,
|
||||
"verified_batches": [
|
||||
1,
|
||||
2,
|
||||
64
|
||||
],
|
||||
"note": "AdaptiveAvgPool2d((15,1)) replaced at export by an exact mean(-1) + constant averaging matmul (final_width 16 is not a multiple of 15, which the TorchScript exporter rejects); exactness proven by the parity check vs the original torch model"
|
||||
},
|
||||
"parity": {
|
||||
"fixture": "results/parity_fixture.npz input (batch 2, seed 42); reference output recomputed with the tiny torch model",
|
||||
"max_abs_diff_vs_torch": 1.4901161193847656e-07,
|
||||
"pass_lt_1e-4": true
|
||||
},
|
||||
"int8_static_percentile_conv": {
|
||||
"file": "tiny_int8_static_percentile_conv.onnx",
|
||||
"size_bytes": 248278,
|
||||
"size_mb": 0.248278,
|
||||
"calibration": {
|
||||
"method": "percentile",
|
||||
"percentile": 99.99,
|
||||
"windows": 512,
|
||||
"scope": "conv-only TRAIN-split corruption-free",
|
||||
"seconds": 1.5347836017608643
|
||||
},
|
||||
"per_channel": true,
|
||||
"activation_type": "QInt8",
|
||||
"weight_type": "QInt8",
|
||||
"max_abs_diff_vs_fp32_fixture": 0.018491357564926147
|
||||
},
|
||||
"latency": {
|
||||
"note": "3 interleaved repetitions per variant, median ms/window; full-model sessions are same-session references",
|
||||
"tiny_onnx_fp32": {
|
||||
"batch1_reps": [
|
||||
0.6312500008789357,
|
||||
0.6834500018157996,
|
||||
0.6595999984710943
|
||||
],
|
||||
"batch64_reps": [
|
||||
0.37747578119251557,
|
||||
0.24196640623586063,
|
||||
0.2314671875183194
|
||||
],
|
||||
"batch1_ms_per_window_median": 0.6595999984710943,
|
||||
"batch64_ms_per_window_median": 0.24196640623586063
|
||||
},
|
||||
"tiny_onnx_int8_static_percentile_conv": {
|
||||
"batch1_reps": [
|
||||
0.7988500001374632,
|
||||
0.9382499993080273,
|
||||
0.8451000030618161
|
||||
],
|
||||
"batch64_reps": [
|
||||
0.9211476562995813,
|
||||
1.3045390625165965,
|
||||
1.026230468767153
|
||||
],
|
||||
"batch1_ms_per_window_median": 0.8451000030618161,
|
||||
"batch64_ms_per_window_median": 1.026230468767153
|
||||
},
|
||||
"full_onnx_fp32_reference": {
|
||||
"batch1_reps": [
|
||||
2.267249998112675,
|
||||
2.80170000041835,
|
||||
2.132149998942623
|
||||
],
|
||||
"batch64_reps": [
|
||||
1.3050578124875756,
|
||||
1.4244992187855132,
|
||||
1.8014164062947202
|
||||
],
|
||||
"batch1_ms_per_window_median": 2.267249998112675,
|
||||
"batch64_ms_per_window_median": 1.4244992187855132
|
||||
},
|
||||
"full_onnx_int8_static_percentile_conv_reference": {
|
||||
"batch1_reps": [
|
||||
5.529599999135826,
|
||||
4.768399998283712,
|
||||
6.215800000063609
|
||||
],
|
||||
"batch64_reps": [
|
||||
3.815724218725336,
|
||||
3.1025562500417436,
|
||||
4.333318749957016
|
||||
],
|
||||
"batch1_ms_per_window_median": 5.529599999135826,
|
||||
"batch64_ms_per_window_median": 3.815724218725336
|
||||
}
|
||||
},
|
||||
"accuracy_subset": {
|
||||
"description": "seed-42 file-level 70/15/15 test split, corrupted windows excluded, seed-42 random subset (same as quantize_bench/eval_ort_accuracy/static_ptq_bench)",
|
||||
"subset_size": 10000
|
||||
},
|
||||
"accuracy": {
|
||||
"tiny_onnx_fp32": {
|
||||
"samples": 10000,
|
||||
"pck@20": 0.941106667804718,
|
||||
"pck@50": 0.99369333152771,
|
||||
"mpjpe": 0.012527281279861927,
|
||||
"wall_seconds": 10.927234888076782
|
||||
},
|
||||
"tiny_onnx_int8_static_percentile_conv": {
|
||||
"samples": 10000,
|
||||
"pck@20": 0.9268133331298828,
|
||||
"pck@50": 0.9932933319091797,
|
||||
"mpjpe": 0.014906252065300942,
|
||||
"wall_seconds": 12.320892333984375
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{"variant": "half", "params": 843834, "tcn_channels": [270, 220, 170, 120], "conv_channels": [4, 8, 16, 32], "attn_groups": 4, "groups_mode": "gcd20", "input_pw_groups": 1, "tcn_groups_per_block": [[20, 10], [10, 20], [20, 10], [10, 20]], "conv_strides": [2, 2, 2, 1], "final_width": 15, "batch_size": 64, "max_epochs": 50, "patience": 5, "lr": 0.0001, "weight_decay": 5e-05, "seed": 42, "precision": "fp32", "epochs_run": 28, "best_epoch": 23, "best_val_mpjpe": 0.008576328293592842, "best_val_pck20": 0.9690593021534107, "train_seconds": 1346.4, "torch": "2.11.0+cu128", "error": null, "finished_utc": "2026-06-11T03:09:47Z", "checkpoint": "/home/ruvultra/wiflow-std-bench/sweep/half_best.pth", "test_full": {"samples": 54000, "mpjpe": 0.009419974447676428, "pck@10": 0.8740543655289544, "pck@20": 0.9610469643628156, "pck@30": 0.9813556064146537, "pck@40": 0.9896086878246731, "pck@50": 0.9934827546013726}, "test_clean": {"samples": 52560, "mpjpe": 0.008980081718602137, "pck@10": 0.8840944136840205, "pck@20": 0.9662253179869514, "pck@30": 0.9847971080282144, "pck@40": 0.9917795997050618, "pck@50": 0.9946956242600532}}
|
||||
{"variant": "quarter", "params": 338600, "tcn_channels": [135, 110, 85, 60], "conv_channels": [2, 4, 8, 16], "attn_groups": 2, "groups_mode": "gcd20", "input_pw_groups": 1, "tcn_groups_per_block": [[20, 5], [5, 10], [10, 5], [5, 20]], "conv_strides": [2, 2, 1, 1], "final_width": 15, "batch_size": 64, "max_epochs": 50, "patience": 5, "lr": 0.0001, "weight_decay": 5e-05, "seed": 42, "precision": "fp32", "epochs_run": 50, "best_epoch": 50, "best_val_mpjpe": 0.008780752391864856, "best_val_pck20": 0.9672531302240159, "train_seconds": 1754.4, "torch": "2.11.0+cu128", "error": null, "finished_utc": "2026-06-11T03:39:06Z", "checkpoint": "/home/ruvultra/wiflow-std-bench/sweep/quarter_best.pth", "test_full": {"samples": 54000, "mpjpe": 0.009705399298005634, "pck@10": 0.8646123917014511, "pck@20": 0.9553815319449813, "pck@30": 0.979827209190086, "pck@40": 0.9887037501511751, "pck@50": 0.9931309027671814}, "test_clean": {"samples": 52560, "mpjpe": 0.009279253277105465, "pck@10": 0.8742288637923323, "pck@20": 0.9605315079427745, "pck@30": 0.9833016723076865, "pck@40": 0.9908206971631566, "pck@50": 0.9942719799017071}}
|
||||
{"variant": "tiny", "params": 56290, "tcn_channels": [68, 56, 44, 32], "conv_channels": [2, 4, 8, 16], "attn_groups": 2, "groups_mode": "depthwise", "input_pw_groups": 4, "tcn_groups_per_block": [[540, 68], [68, 56], [56, 44], [44, 32]], "conv_strides": [2, 1, 1, 1], "final_width": 16, "batch_size": 64, "max_epochs": 50, "patience": 5, "lr": 0.0001, "weight_decay": 5e-05, "seed": 42, "precision": "fp32", "epochs_run": 50, "best_epoch": 47, "best_val_mpjpe": 0.012602971208592256, "best_val_pck20": 0.9397210340146666, "train_seconds": 1540.1, "torch": "2.11.0+cu128", "error": null, "finished_utc": "2026-06-11T04:04:50Z", "checkpoint": "/home/ruvultra/wiflow-std-bench/sweep/tiny_best.pth", "test_full": {"samples": 54000, "mpjpe": 0.012859782406853305, "pck@10": 0.7640358444319831, "pck@20": 0.9364815320968628, "pck@30": 0.9731568422317505, "pck@40": 0.9866444962642811, "pck@50": 0.992488939108672}, "test_clean": {"samples": 52560, "mpjpe": 0.012502924276904246, "pck@10": 0.770895526488985, "pck@20": 0.9411073559313967, "pck@30": 0.9764840687790962, "pck@40": 0.9886695077067278, "pck@50": 0.9936238432039409}}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"checkpoint": "/home/ruvultra/wiflow-std-bench/upstream/test/best_pose_model.pth",
|
||||
"test_full": {
|
||||
"samples": 54000,
|
||||
"mpjpe": 0.009834060806367133,
|
||||
"pck@10": 0.8686346120127925,
|
||||
"pck@20": 0.9608815324571398,
|
||||
"pck@30": 0.9789111610695168,
|
||||
"pck@40": 0.9857975759682832,
|
||||
"pck@50": 0.9898827553325229
|
||||
},
|
||||
"test_clean": {
|
||||
"samples": 52560,
|
||||
"mpjpe": 0.009432755044379373,
|
||||
"pck@10": 0.876996495807189,
|
||||
"pck@20": 0.9661454100405608,
|
||||
"pck@30": 0.9823453060205306,
|
||||
"pck@40": 0.987909734176537,
|
||||
"pck@50": 0.9911238361167036
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"published": {
|
||||
"pck@20": 0.9725,
|
||||
"pck@30": 0.9863,
|
||||
"pck@40": 0.9916,
|
||||
"pck@50": 0.9948,
|
||||
"mpjpe": 0.007
|
||||
},
|
||||
"params_millions": 2.225042,
|
||||
"data_dir": "C:\\Users\\ruv\\.cache\\kagglehub\\datasets\\kaka2434\\wiflow-dataset\\versions\\1\\preprocessed_csi_data",
|
||||
"device": "cpu",
|
||||
"test_full": {
|
||||
"samples": 54000,
|
||||
"mpjpe": NaN,
|
||||
"pck@10": 5.6790124349020145e-05,
|
||||
"pck@20": 0.0007876543271596785,
|
||||
"pck@30": 0.007780246982971827,
|
||||
"pck@40": 0.05529259262923841,
|
||||
"pck@50": 0.1542370371548114,
|
||||
"wall_seconds": 118.03756999969482
|
||||
},
|
||||
"test_drop_last": {
|
||||
"samples": 53952,
|
||||
"mpjpe": NaN,
|
||||
"pck@10": 5.6840649370682976e-05,
|
||||
"pck@20": 0.0007883550872372227,
|
||||
"pck@30": 0.007787168910892621,
|
||||
"pck@40": 0.055318307667895535,
|
||||
"pck@50": 0.15425316342412276,
|
||||
"wall_seconds": 120.87458372116089
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,333 @@
|
||||
"""ADR-152 edge optimization follow-up: ONNX Runtime STATIC post-training
|
||||
quantization (calibration-based QDQ) of the retrained WiFlow-STD model, to
|
||||
improve on the dynamic-int8 result (2.44 MB, PCK@20 96.52%, 6.5 ms/win b1).
|
||||
|
||||
Static PTQ pre-computes activation ranges from calibration data, so inference
|
||||
uses QLinearConv/QDQ kernels instead of dynamic ConvInteger -- typically both
|
||||
faster and (with good calibration) closer to fp32 accuracy.
|
||||
|
||||
Method:
|
||||
- Calibration set: corruption-free windows drawn ONLY from the seed-42
|
||||
file-level TRAINING split (same split as eval_repro.py; corrupted windows
|
||||
excluded via results/nan_windows_mask.npy | big_windows_mask.npy), chosen
|
||||
with np.random.default_rng(42). Never test windows.
|
||||
- quantize_static, QuantFormat.QDQ, per-channel int8 weights, int8
|
||||
activations; calibration methods MinMax / Entropy / Percentile(99.99);
|
||||
scopes "all" (ORT default op set) vs "conv" (op_types_to_quantize=
|
||||
["Conv"] -- leaves the attention path, which exports as Einsum/Softmax
|
||||
and elementwise ops, in fp32).
|
||||
- Model is pre-processed first (quant_pre_process: symbolic shape
|
||||
inference + ORT graph optimization, folds BatchNormalization into Conv).
|
||||
- Accuracy: identical protocol to eval_ort_accuracy.py -- the 10,000-window
|
||||
seed-42 subset of the corruption-free test split (PCK@20/50, MPJPE).
|
||||
- Latency: median ms/window at batch 1 (100 runs) and batch 64 (30 runs),
|
||||
3 interleaved repetitions across all variants (fp32 and dynamic-int8
|
||||
sessions included as same-session reference points).
|
||||
|
||||
Usage:
|
||||
PYTHONUTF8=1 .venv/Scripts/python.exe static_ptq_bench.py \
|
||||
[--data-dir <preprocessed_csi_data>] [--subset 10000]
|
||||
[--calib-minmax 1000] [--calib-hist 512] [--skip-accuracy]
|
||||
|
||||
Writes/merges into results/edge_optimization.json under key "onnx_static_ptq".
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import collections
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import statistics
|
||||
import sys
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, HERE)
|
||||
|
||||
from _bench_common import RESULTS # noqa: E402
|
||||
# quantize_bench sets up upstream imports + the np.load mmap patch
|
||||
# (both via _bench_common.import_upstream)
|
||||
from quantize_bench import build_test_subset # noqa: E402
|
||||
import quantize_bench as qb # noqa: E402
|
||||
from eval_ort_accuracy import evaluate_ort # noqa: E402
|
||||
|
||||
FP32_ONNX = os.path.join(RESULTS, "retrained_fp32_dynamic.onnx")
|
||||
DYN_INT8_ONNX = os.path.join(RESULTS, "retrained_int8_ort_dynamic.onnx")
|
||||
PREPROC_ONNX = os.path.join(RESULTS, "retrained_fp32_preproc.onnx")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# calibration data: corruption-free TRAINING-split windows only
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def build_calibration_windows(data_dir, n_windows):
|
||||
"""Seed-42 file-level 70/15/15 TRAIN split (exactly as eval_repro.py),
|
||||
minus corrupted windows, then a seed-42 random draw of n_windows."""
|
||||
dataset = qb.PreprocessedCSIKeypointsDataset(
|
||||
data_dir=data_dir, keypoint_scale=1000.0, enable_temporal_clean=True)
|
||||
train_loader, _va, _te = qb.create_preprocessed_train_val_test_loaders(
|
||||
dataset=dataset, batch_size=64, num_workers=0, random_seed=42)
|
||||
train_indices = np.asarray(train_loader.dataset.indices)
|
||||
|
||||
corrupted = (np.load(os.path.join(RESULTS, "nan_windows_mask.npy"))
|
||||
| np.load(os.path.join(RESULTS, "big_windows_mask.npy")))
|
||||
clean = train_indices[~corrupted[train_indices]]
|
||||
print(f"train split: {len(train_indices)} windows, "
|
||||
f"{len(train_indices) - len(clean)} corrupted excluded, "
|
||||
f"{len(clean)} clean")
|
||||
|
||||
rng = np.random.default_rng(42)
|
||||
sel = np.sort(rng.choice(clean, size=n_windows, replace=False))
|
||||
xs = np.stack([dataset[int(i)][0].numpy() for i in sel]).astype(np.float32)
|
||||
print(f"calibration tensor: {xs.shape} from {n_windows} clean TRAIN windows")
|
||||
return xs
|
||||
|
||||
|
||||
def make_reader(windows, batch_size=64):
|
||||
from onnxruntime.quantization import CalibrationDataReader
|
||||
|
||||
class WindowReader(CalibrationDataReader):
|
||||
def __init__(self):
|
||||
self._batches = [windows[i:i + batch_size]
|
||||
for i in range(0, len(windows), batch_size)]
|
||||
self._it = iter(self._batches)
|
||||
|
||||
def get_next(self):
|
||||
b = next(self._it, None)
|
||||
return None if b is None else {"input": b}
|
||||
|
||||
def rewind(self):
|
||||
self._it = iter(self._batches)
|
||||
|
||||
def __len__(self):
|
||||
return len(self._batches)
|
||||
|
||||
return WindowReader()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# quantization variants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def preprocess_model():
|
||||
from onnxruntime.quantization.shape_inference import quant_pre_process
|
||||
quant_pre_process(FP32_ONNX, PREPROC_ONNX)
|
||||
return PREPROC_ONNX
|
||||
|
||||
|
||||
def quantize_variant(src, dst, method, scope, calib_windows):
|
||||
from onnxruntime.quantization import (CalibrationMethod, QuantFormat,
|
||||
QuantType, quantize_static)
|
||||
methods = {
|
||||
"minmax": CalibrationMethod.MinMax,
|
||||
"entropy": CalibrationMethod.Entropy,
|
||||
"percentile": CalibrationMethod.Percentile,
|
||||
}
|
||||
# NB: do NOT pass CalibMaxIntermediateOutputs -- in ORT 1.26 the MinMax
|
||||
# calibrater clears its buffer every N batches and then raises
|
||||
# "No data is collected" if the batch count is divisible by N.
|
||||
extra = {}
|
||||
if method == "percentile":
|
||||
extra["CalibPercentile"] = 99.99
|
||||
op_types = ["Conv"] if scope == "conv" else None
|
||||
|
||||
t0 = time.time()
|
||||
quantize_static(
|
||||
src, dst, make_reader(calib_windows),
|
||||
quant_format=QuantFormat.QDQ,
|
||||
op_types_to_quantize=op_types,
|
||||
per_channel=True,
|
||||
activation_type=QuantType.QInt8,
|
||||
weight_type=QuantType.QInt8,
|
||||
calibrate_method=methods[method],
|
||||
extra_options=extra,
|
||||
)
|
||||
secs = time.time() - t0
|
||||
|
||||
import onnx
|
||||
ops = collections.Counter(n.op_type for n in onnx.load(dst).graph.node)
|
||||
return {
|
||||
"file": os.path.basename(dst),
|
||||
"size_bytes": os.path.getsize(dst),
|
||||
"size_mb": os.path.getsize(dst) / 1e6,
|
||||
"calibration": {"method": method,
|
||||
"windows": int(len(calib_windows)),
|
||||
"percentile": extra.get("CalibPercentile"),
|
||||
"seconds": secs},
|
||||
"scope": scope,
|
||||
"per_channel": True,
|
||||
"activation_type": "QInt8",
|
||||
"weight_type": "QInt8",
|
||||
"node_counts": {k: v for k, v in sorted(ops.items())},
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# latency (3 interleaved reps, like the latency_controlled_rerun)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def ort_session(path):
|
||||
import onnxruntime as ort
|
||||
return ort.InferenceSession(path, providers=["CPUExecutionProvider"])
|
||||
|
||||
|
||||
def bench_ort(sess, batch, n_runs):
|
||||
rng = np.random.default_rng(123)
|
||||
x = rng.random((batch, 540, 20), dtype=np.float32)
|
||||
inp = sess.get_inputs()[0].name
|
||||
for _ in range(max(5, n_runs // 10)):
|
||||
sess.run(None, {inp: x})
|
||||
times = []
|
||||
for _ in range(n_runs):
|
||||
t0 = time.perf_counter()
|
||||
sess.run(None, {inp: x})
|
||||
times.append(time.perf_counter() - t0)
|
||||
return statistics.median(times) * 1e3 / batch # ms/window
|
||||
|
||||
|
||||
def interleaved_latency(sessions, reps=3, runs_b1=100, runs_b64=30):
|
||||
lat = {name: {"batch1_reps": [], "batch64_reps": []} for name in sessions}
|
||||
for rep in range(reps):
|
||||
for name, sess in sessions.items():
|
||||
lat[name]["batch1_reps"].append(bench_ort(sess, 1, runs_b1))
|
||||
lat[name]["batch64_reps"].append(bench_ort(sess, 64, runs_b64))
|
||||
print(f" rep {rep + 1}/{reps} {name}: "
|
||||
f"b1={lat[name]['batch1_reps'][-1]:.2f} "
|
||||
f"b64={lat[name]['batch64_reps'][-1]:.3f} ms/win", flush=True)
|
||||
for name in lat:
|
||||
lat[name]["batch1_ms_per_window_median"] = statistics.median(
|
||||
lat[name]["batch1_reps"])
|
||||
lat[name]["batch64_ms_per_window_median"] = statistics.median(
|
||||
lat[name]["batch64_reps"])
|
||||
return lat
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main():
|
||||
import onnxruntime
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--data-dir", default=os.path.join(
|
||||
os.path.expanduser("~"), ".cache", "kagglehub", "datasets", "kaka2434",
|
||||
"wiflow-dataset", "versions", "1", "preprocessed_csi_data"))
|
||||
parser.add_argument("--subset", type=int, default=10000)
|
||||
parser.add_argument("--calib-minmax", type=int, default=1000)
|
||||
parser.add_argument("--calib-hist", type=int, default=512,
|
||||
help="calibration windows for Entropy/Percentile "
|
||||
"(histogram calibraters hold all intermediate "
|
||||
"activations in RAM)")
|
||||
parser.add_argument("--skip-accuracy", action="store_true")
|
||||
parser.add_argument("--methods", default="minmax,entropy,percentile",
|
||||
help="comma list of calibration methods to (re)run; "
|
||||
"results merge into existing onnx_static_ptq")
|
||||
parser.add_argument("--out", default=os.path.join(RESULTS, "edge_optimization.json"))
|
||||
args = parser.parse_args()
|
||||
|
||||
results = {
|
||||
"env": {
|
||||
"onnxruntime": onnxruntime.__version__,
|
||||
"torch": torch.__version__,
|
||||
"platform": platform.platform(),
|
||||
"source_model": os.path.basename(FP32_ONNX),
|
||||
},
|
||||
"variants": {},
|
||||
}
|
||||
|
||||
# ---- calibration data (TRAIN split only) -------------------------------
|
||||
calib_mm = build_calibration_windows(args.data_dir, args.calib_minmax)
|
||||
calib_hist = calib_mm[:args.calib_hist]
|
||||
|
||||
# ---- preprocess + quantize ---------------------------------------------
|
||||
print("\n=== quant_pre_process (shape inference + graph optimization) ===")
|
||||
src = preprocess_model()
|
||||
results["env"]["preprocessed_model"] = {
|
||||
"file": os.path.basename(src),
|
||||
"size_mb": os.path.getsize(src) / 1e6,
|
||||
}
|
||||
|
||||
matrix = [(m, s) for m in args.methods.split(",")
|
||||
for s in ("all", "conv")]
|
||||
for method, scope in matrix:
|
||||
name = f"{method}_{scope}"
|
||||
dst = os.path.join(RESULTS, f"retrained_int8_static_{name}.onnx")
|
||||
calib = calib_mm if method == "minmax" else calib_hist
|
||||
print(f"\n=== quantize_static: {name} "
|
||||
f"({len(calib)} calib windows) ===", flush=True)
|
||||
try:
|
||||
results["variants"][name] = quantize_variant(
|
||||
src, dst, method, scope, calib)
|
||||
print(f" {results['variants'][name]['size_mb']:.3f} MB")
|
||||
except Exception as e: # noqa: BLE001
|
||||
results["variants"][name] = {"error": f"{type(e).__name__}: {e}"}
|
||||
print(f" FAILED: {e}")
|
||||
|
||||
# ---- fixture parity (sanity, batch 2) ----------------------------------
|
||||
fixture = np.load(os.path.join(RESULTS, "parity_fixture.npz"))
|
||||
fx, fy = fixture["input"], fixture["output"]
|
||||
sessions = {}
|
||||
for name, info in results["variants"].items():
|
||||
if "error" in info:
|
||||
continue
|
||||
path = os.path.join(RESULTS, info["file"])
|
||||
try:
|
||||
sess = ort_session(path)
|
||||
yq = sess.run(None, {sess.get_inputs()[0].name: fx})[0]
|
||||
info["max_abs_diff_vs_fp32_fixture"] = float(np.abs(yq - fy).max())
|
||||
sessions[name] = sess
|
||||
except Exception as e: # noqa: BLE001
|
||||
info["run_error"] = f"{type(e).__name__}: {e}"
|
||||
print("\nfixture max-abs-diff vs fp32:",
|
||||
{n: round(results["variants"][n].get("max_abs_diff_vs_fp32_fixture",
|
||||
float("nan")), 5)
|
||||
for n in results["variants"]})
|
||||
|
||||
# ---- latency: 3 interleaved reps incl. fp32 + dynamic-int8 reference ----
|
||||
print("\n=== latency (3 interleaved reps) ===")
|
||||
lat_sessions = {"onnx_fp32": ort_session(FP32_ONNX),
|
||||
"onnx_int8_ort_dynamic": ort_session(DYN_INT8_ONNX)}
|
||||
lat_sessions.update(sessions)
|
||||
results["latency"] = {
|
||||
"note": "3 interleaved repetitions per variant, median ms/window; "
|
||||
"onnx_fp32 / onnx_int8_ort_dynamic are same-session references",
|
||||
**interleaved_latency(lat_sessions),
|
||||
}
|
||||
|
||||
# ---- accuracy on the standard 10k corruption-free test subset ----------
|
||||
if not args.skip_accuracy:
|
||||
loader, n_clean = build_test_subset(args.data_dir, args.subset)
|
||||
results["accuracy_subset"] = {
|
||||
"description": "seed-42 file-level 70/15/15 test split, corrupted "
|
||||
"windows excluded, seed-42 random subset (same as "
|
||||
"quantize_bench/eval_ort_accuracy)",
|
||||
"subset_size": min(args.subset, n_clean) if args.subset else n_clean,
|
||||
}
|
||||
for name, sess in sessions.items():
|
||||
print(f"\n=== accuracy: {name} ===")
|
||||
results["variants"][name]["accuracy"] = evaluate_ort(
|
||||
sess, loader, name)
|
||||
print(json.dumps(results["variants"][name]["accuracy"], indent=2))
|
||||
|
||||
# ---- merge into edge_optimization.json ----------------------------------
|
||||
merged = {}
|
||||
if os.path.exists(args.out):
|
||||
with open(args.out) as f:
|
||||
merged = json.load(f)
|
||||
prev = merged.get("onnx_static_ptq")
|
||||
if prev: # nested merge so partial --methods reruns don't clobber
|
||||
prev["env"] = results["env"]
|
||||
prev["variants"].update(results["variants"])
|
||||
prev.setdefault("latency", {}).update(results["latency"])
|
||||
if "accuracy_subset" in results:
|
||||
prev["accuracy_subset"] = results["accuracy_subset"]
|
||||
else:
|
||||
merged["onnx_static_ptq"] = results
|
||||
with open(args.out, "w") as f:
|
||||
json.dump(merged, f, indent=2)
|
||||
print(f"\nwrote {args.out}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,313 @@
|
||||
"""ADR-152 efficiency-sweep follow-up: edge pipeline for the TINY compact
|
||||
WiFlow-STD variant (56,290 params, results/tiny_best.pth, trained overnight
|
||||
2026-06-10/11 -- see RESULTS.md "Efficiency sweep").
|
||||
|
||||
Headline question: what does the smallest deployable WiFlow-class model look
|
||||
like (KB + ms + PCK)? Reuses the onnx_bench.py / static_ptq_bench.py
|
||||
machinery on the tiny checkpoint:
|
||||
|
||||
1. Load tiny_best.pth with remote/sweep/model_compact.py
|
||||
(depthwise TCN groups, input_pw_groups=4, conv [2,4,8,16], attn groups 2).
|
||||
2. Export ONNX: dynamic batch, opset 17, TorchScript exporter (dynamo=False)
|
||||
-- same recipe that worked for the full model; verified at batch 1/2/64.
|
||||
One forced deviation: tiny's stride schedule [2,1,1,1] leaves final_width
|
||||
16, and the TorchScript exporter cannot export AdaptiveAvgPool2d((15,1))
|
||||
when 15 is not a factor of the input height (the full model never hit
|
||||
this -- its width was exactly 15). The adaptive pool over a fixed-size
|
||||
feature map is a fixed linear map, so the export wrapper replaces it with
|
||||
an exact matmul equivalent (PyTorch adaptive-pool bin semantics:
|
||||
bin i averages rows floor(i*H/K)..ceil((i+1)*H/K)); the W axis (20->1,
|
||||
a factor) becomes mean(-1). Exactness is proven by the parity check
|
||||
below, which compares against the ORIGINAL torch model with the real
|
||||
AdaptiveAvgPool2d.
|
||||
3. Torch-vs-ORT parity on the stored fixture input
|
||||
(results/parity_fixture.npz, batch 2, seed 42 -- same 540x20 input layout;
|
||||
reference output recomputed with the tiny torch model). PASS < 1e-4.
|
||||
4. Static QDQ conv-only int8 (quant_pre_process + quantize_static,
|
||||
per-channel QInt8 weights+activations, Percentile(99.99) calibration on
|
||||
512 corruption-free TRAIN-split windows -- the winning recipe and
|
||||
calibration count from static_ptq_bench.py. 512, not "about 500":
|
||||
ORT 1.26's histogram collector np.asarray()'s the per-batch maxima, so
|
||||
the calibration count must be a multiple of the batch size 64 or the
|
||||
ragged last batch crashes it).
|
||||
5. Disk size + CPU latency b1/b64 (3 interleaved reps, median ms/window)
|
||||
for tiny fp32 + tiny int8, with the full-model ONNX fp32 + static-int8
|
||||
sessions interleaved as same-session references.
|
||||
6. Accuracy (PCK@20/50 + MPJPE) on the identical 10k-window seed-42
|
||||
corruption-free test subset for tiny fp32 + tiny int8.
|
||||
|
||||
Usage:
|
||||
PYTHONUTF8=1 .venv/Scripts/python.exe tiny_edge_bench.py \
|
||||
[--data-dir <preprocessed_csi_data>] [--subset 10000] [--calib 512]
|
||||
(--calib must be a multiple of 64; see step 4 above)
|
||||
|
||||
Writes/merges into results/edge_optimization.json under key "tiny_variant".
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import sys
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
RESULTS = os.path.join(HERE, "results")
|
||||
sys.path.insert(0, HERE)
|
||||
sys.path.insert(0, os.path.join(HERE, "remote", "sweep"))
|
||||
|
||||
# quantize_bench sets up upstream imports + the np.load mmap patch
|
||||
from quantize_bench import build_test_subset # noqa: E402
|
||||
from eval_ort_accuracy import evaluate_ort # noqa: E402
|
||||
from static_ptq_bench import ( # noqa: E402
|
||||
build_calibration_windows,
|
||||
interleaved_latency,
|
||||
make_reader,
|
||||
ort_session,
|
||||
)
|
||||
from model_compact import CompactWiFlowPoseModel, describe # noqa: E402
|
||||
|
||||
TINY_CKPT = os.path.join(RESULTS, "tiny_best.pth")
|
||||
TINY_FP32_ONNX = os.path.join(RESULTS, "tiny_fp32_dynamic.onnx")
|
||||
TINY_PREPROC_ONNX = os.path.join(RESULTS, "tiny_fp32_preproc.onnx")
|
||||
TINY_INT8_ONNX = os.path.join(RESULTS, "tiny_int8_static_percentile_conv.onnx")
|
||||
FULL_FP32_ONNX = os.path.join(RESULTS, "retrained_fp32_dynamic.onnx")
|
||||
FULL_INT8_ONNX = os.path.join(RESULTS, "retrained_int8_static_percentile_conv.onnx")
|
||||
|
||||
# Exact tiny config from remote/sweep/run_sweep.py VARIANTS (measured 56,290
|
||||
# params, clean-test PCK@20 94.11% -- results/efficiency_sweep.jsonl).
|
||||
TINY = dict(tcn=[68, 56, 44, 32], conv=[2, 4, 8, 16], attn_groups=2,
|
||||
groups_mode="depthwise", input_pw_groups=4)
|
||||
|
||||
|
||||
def load_tiny_model():
|
||||
model = CompactWiFlowPoseModel(
|
||||
tcn_channels=TINY["tcn"], conv_channels=TINY["conv"],
|
||||
attn_groups=TINY["attn_groups"], groups_mode=TINY["groups_mode"],
|
||||
input_pw_groups=TINY["input_pw_groups"], dropout=0.5)
|
||||
state = torch.load(TINY_CKPT, map_location="cpu", weights_only=True)
|
||||
model.load_state_dict(state, strict=True)
|
||||
model.eval()
|
||||
return model
|
||||
|
||||
|
||||
def adaptive_pool_matrix(h_in, h_out):
|
||||
"""Exact AdaptiveAvgPool1d as a (h_out, h_in) averaging matrix, using
|
||||
PyTorch's bin rule: bin i covers rows floor(i*h_in/h_out) ..
|
||||
ceil((i+1)*h_in/h_out)."""
|
||||
w = torch.zeros(h_out, h_in)
|
||||
for i in range(h_out):
|
||||
s = (i * h_in) // h_out
|
||||
e = -((-(i + 1) * h_in) // h_out) # ceil division
|
||||
w[i, s:e] = 1.0 / (e - s)
|
||||
return w
|
||||
|
||||
|
||||
class ExportWrapper(torch.nn.Module):
|
||||
"""CompactWiFlowPoseModel forward with the AdaptiveAvgPool2d((K,1))
|
||||
replaced by an exact fixed linear map (mean over the factor W axis, then
|
||||
a constant averaging matmul over the non-factor H axis) so the
|
||||
TorchScript ONNX exporter accepts it. Bit-equivalent up to float
|
||||
round-off; proven by the parity check against the original model."""
|
||||
|
||||
def __init__(self, m, num_keypoints=15):
|
||||
super().__init__()
|
||||
self.m = m
|
||||
self.register_buffer(
|
||||
"pool_w_t", adaptive_pool_matrix(m.final_width, num_keypoints).t())
|
||||
|
||||
def forward(self, x):
|
||||
m = self.m
|
||||
x = m.tcn(x)
|
||||
x = x.transpose(1, 2).unsqueeze(1)
|
||||
x = m.up(x)
|
||||
for block in m.residual_blocks:
|
||||
x = block(x)
|
||||
x = x.permute(0, 1, 3, 2)
|
||||
x = m.attention(x)
|
||||
x = m.decoder(x) # [B, 2, H=final_width, T=20]
|
||||
x = x.mean(-1) # W-axis pool (20 -> 1, a factor)
|
||||
x = x.matmul(self.pool_w_t) # exact adaptive H pool: [B, 2, K]
|
||||
return x.transpose(1, 2) # [B, K, 2]
|
||||
|
||||
|
||||
def export_onnx(model):
|
||||
"""Dynamic-batch TorchScript export (the recipe that worked for the full
|
||||
model in onnx_bench.py), verified at batch 1/2/64. Uses ExportWrapper
|
||||
(see docstring) because final_width 16 is not a multiple of 15."""
|
||||
wrapper = ExportWrapper(model).eval()
|
||||
x = torch.rand(2, 540, 20)
|
||||
with torch.no_grad():
|
||||
torch.onnx.export(
|
||||
wrapper, (x,), TINY_FP32_ONNX, opset_version=17,
|
||||
input_names=["input"], output_names=["output"], dynamo=False,
|
||||
dynamic_axes={"input": {0: "batch"}, "output": {0: "batch"}})
|
||||
sess = ort_session(TINY_FP32_ONNX)
|
||||
inp = sess.get_inputs()[0].name
|
||||
for b in (1, 2, 64):
|
||||
y = sess.run(None, {inp: np.zeros((b, 540, 20), dtype=np.float32)})[0]
|
||||
assert y.shape == (b, 15, 2), y.shape
|
||||
return {
|
||||
"mode": "dynamic-batch", "exporter": "torchscript", "opset": 17,
|
||||
"file": os.path.basename(TINY_FP32_ONNX),
|
||||
"size_bytes": os.path.getsize(TINY_FP32_ONNX),
|
||||
"size_mb": os.path.getsize(TINY_FP32_ONNX) / 1e6,
|
||||
"verified_batches": [1, 2, 64],
|
||||
"note": "AdaptiveAvgPool2d((15,1)) replaced at export by an exact "
|
||||
"mean(-1) + constant averaging matmul (final_width 16 is not "
|
||||
"a multiple of 15, which the TorchScript exporter rejects); "
|
||||
"exactness proven by the parity check vs the original torch "
|
||||
"model",
|
||||
}
|
||||
|
||||
|
||||
def quantize_tiny(calib_windows):
|
||||
"""quant_pre_process + static QDQ conv-only Percentile(99.99) int8 --
|
||||
the winning recipe from static_ptq_bench.py."""
|
||||
from onnxruntime.quantization import (CalibrationMethod, QuantFormat,
|
||||
QuantType, quantize_static)
|
||||
from onnxruntime.quantization.shape_inference import quant_pre_process
|
||||
|
||||
quant_pre_process(TINY_FP32_ONNX, TINY_PREPROC_ONNX)
|
||||
t0 = time.time()
|
||||
quantize_static(
|
||||
TINY_PREPROC_ONNX, TINY_INT8_ONNX, make_reader(calib_windows),
|
||||
quant_format=QuantFormat.QDQ,
|
||||
op_types_to_quantize=["Conv"],
|
||||
per_channel=True,
|
||||
activation_type=QuantType.QInt8,
|
||||
weight_type=QuantType.QInt8,
|
||||
calibrate_method=CalibrationMethod.Percentile,
|
||||
extra_options={"CalibPercentile": 99.99},
|
||||
)
|
||||
return {
|
||||
"file": os.path.basename(TINY_INT8_ONNX),
|
||||
"size_bytes": os.path.getsize(TINY_INT8_ONNX),
|
||||
"size_mb": os.path.getsize(TINY_INT8_ONNX) / 1e6,
|
||||
"calibration": {"method": "percentile", "percentile": 99.99,
|
||||
"windows": int(len(calib_windows)),
|
||||
"scope": "conv-only TRAIN-split corruption-free",
|
||||
"seconds": time.time() - t0},
|
||||
"per_channel": True,
|
||||
"activation_type": "QInt8",
|
||||
"weight_type": "QInt8",
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
import onnxruntime
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--data-dir", default=os.path.join(
|
||||
os.path.expanduser("~"), ".cache", "kagglehub", "datasets", "kaka2434",
|
||||
"wiflow-dataset", "versions", "1", "preprocessed_csi_data"))
|
||||
parser.add_argument("--subset", type=int, default=10000)
|
||||
parser.add_argument("--calib", type=int, default=512,
|
||||
help="calibration windows; must be a multiple of the "
|
||||
"64-window calibration batch (ORT histogram "
|
||||
"collector rejects ragged batches)")
|
||||
parser.add_argument("--skip-accuracy", action="store_true")
|
||||
parser.add_argument("--out", default=os.path.join(RESULTS, "edge_optimization.json"))
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.calib % 64 != 0:
|
||||
parser.error(
|
||||
f"--calib must be a multiple of 64 (got {args.calib}): ORT 1.26's "
|
||||
f"histogram calibration collector np.asarray()'s the per-batch "
|
||||
f"maxima and crashes on a ragged final batch (calibration batch "
|
||||
f"size is 64)")
|
||||
|
||||
model = load_tiny_model()
|
||||
info = describe(model)
|
||||
print(f"tiny model: {info['params']:,} params, tcn_groups={info['tcn_groups_per_block']}, "
|
||||
f"strides={info['conv_strides']}, final_width={info['final_width']}")
|
||||
assert info["params"] == 56290, info["params"]
|
||||
|
||||
results = {
|
||||
"env": {
|
||||
"torch": torch.__version__,
|
||||
"onnxruntime": onnxruntime.__version__,
|
||||
"platform": platform.platform(),
|
||||
"num_threads": torch.get_num_threads(),
|
||||
"checkpoint": os.path.relpath(TINY_CKPT, HERE),
|
||||
"checkpoint_size_bytes": os.path.getsize(TINY_CKPT),
|
||||
"params": info["params"],
|
||||
"variant_config": TINY,
|
||||
},
|
||||
}
|
||||
|
||||
# ---- export + parity ----------------------------------------------------
|
||||
print("\n=== ONNX export (dynamic batch, opset 17, torchscript) ===")
|
||||
results["export"] = export_onnx(model)
|
||||
print(f" {results['export']['size_mb']:.3f} MB, batches {results['export']['verified_batches']} OK")
|
||||
|
||||
fixture = np.load(os.path.join(RESULTS, "parity_fixture.npz"))
|
||||
fx = fixture["input"] # (2, 540, 20), seed 42 -- same input layout as full model
|
||||
sess_fp32 = ort_session(TINY_FP32_ONNX)
|
||||
y_ort = sess_fp32.run(None, {sess_fp32.get_inputs()[0].name: fx})[0]
|
||||
with torch.no_grad():
|
||||
y_torch = model(torch.from_numpy(fx)).numpy()
|
||||
results["parity"] = {
|
||||
"fixture": "results/parity_fixture.npz input (batch 2, seed 42); "
|
||||
"reference output recomputed with the tiny torch model",
|
||||
"max_abs_diff_vs_torch": float(np.abs(y_ort - y_torch).max()),
|
||||
"pass_lt_1e-4": bool(np.abs(y_ort - y_torch).max() < 1e-4),
|
||||
}
|
||||
print("parity:", json.dumps(results["parity"], indent=2))
|
||||
assert results["parity"]["pass_lt_1e-4"], "torch-vs-ORT parity FAILED"
|
||||
|
||||
# ---- static PTQ int8 ------------------------------------------------------
|
||||
print(f"\n=== static QDQ int8 (Percentile conv-only, {args.calib} calib windows) ===")
|
||||
calib = build_calibration_windows(args.data_dir, args.calib)
|
||||
results["int8_static_percentile_conv"] = quantize_tiny(calib)
|
||||
print(f" {results['int8_static_percentile_conv']['size_mb']:.3f} MB")
|
||||
sess_int8 = ort_session(TINY_INT8_ONNX)
|
||||
yq = sess_int8.run(None, {sess_int8.get_inputs()[0].name: fx})[0]
|
||||
results["int8_static_percentile_conv"]["max_abs_diff_vs_fp32_fixture"] = float(
|
||||
np.abs(yq - y_torch).max())
|
||||
|
||||
# ---- latency (3 interleaved reps, full-model sessions as references) -----
|
||||
print("\n=== latency (3 interleaved reps) ===")
|
||||
lat_sessions = {
|
||||
"tiny_onnx_fp32": sess_fp32,
|
||||
"tiny_onnx_int8_static_percentile_conv": sess_int8,
|
||||
"full_onnx_fp32_reference": ort_session(FULL_FP32_ONNX),
|
||||
"full_onnx_int8_static_percentile_conv_reference": ort_session(FULL_INT8_ONNX),
|
||||
}
|
||||
results["latency"] = {
|
||||
"note": "3 interleaved repetitions per variant, median ms/window; "
|
||||
"full-model sessions are same-session references",
|
||||
**interleaved_latency(lat_sessions),
|
||||
}
|
||||
|
||||
# ---- accuracy on the standard 10k corruption-free test subset ------------
|
||||
if not args.skip_accuracy:
|
||||
loader, n_clean = build_test_subset(args.data_dir, args.subset)
|
||||
results["accuracy_subset"] = {
|
||||
"description": "seed-42 file-level 70/15/15 test split, corrupted "
|
||||
"windows excluded, seed-42 random subset (same as "
|
||||
"quantize_bench/eval_ort_accuracy/static_ptq_bench)",
|
||||
"subset_size": min(args.subset, n_clean) if args.subset else n_clean,
|
||||
}
|
||||
results["accuracy"] = {}
|
||||
for name, sess in (("tiny_onnx_fp32", sess_fp32),
|
||||
("tiny_onnx_int8_static_percentile_conv", sess_int8)):
|
||||
print(f"\n=== accuracy: {name} ===")
|
||||
results["accuracy"][name] = evaluate_ort(sess, loader, name)
|
||||
print(json.dumps(results["accuracy"][name], indent=2))
|
||||
|
||||
# ---- merge into edge_optimization.json -----------------------------------
|
||||
merged = {}
|
||||
if os.path.exists(args.out):
|
||||
with open(args.out) as f:
|
||||
merged = json.load(f)
|
||||
merged["tiny_variant"] = results
|
||||
with open(args.out, "w") as f:
|
||||
json.dump(merged, f, indent=2)
|
||||
print(f"\nwrote {args.out}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -57,7 +57,7 @@ This witness separates what was **empirically observed on real silicon today** f
|
||||
|
||||
| # | Claim | Why it's not verified |
|
||||
|---|---|---|
|
||||
| **B1** | "Wi-Fi 6 HE-LTF: 242 subcarriers per HE20 frame" | The only AP in range (`ruv.net`) is 11n-only. Every captured frame is 128 bytes = 64 subcarriers (HT-LTF, `ppdu_type=0`). No HE-SU/HE-MU/HE-TB observed. Even if an 11ax AP were available, **whether ESP-IDF v5.4's CSI callback exposes HE-LTF subcarriers via `wifi_csi_info_t.buf` is an open question** — the public API was designed for HT-LTF, and the driver may quietly downconvert. **Validate by capturing CSI against an 11ax AP and comparing `info->len` between HT and HE frames.** |
|
||||
| **B1** | "Wi-Fi 6 HE-LTF: 242 subcarriers per HE20 frame" | The only AP in range (`ruv.net`) is 11n-only. Every captured frame is 128 bytes = 64 subcarriers (HT-LTF, `ppdu_type=0`). No HE-SU/HE-MU/HE-TB observed. Even if an 11ax AP were available, **whether ESP-IDF v5.4's CSI callback exposes HE-LTF subcarriers via `wifi_csi_info_t.buf` is an open question** — the public API was designed for HT-LTF, and the driver may quietly downconvert. **Validate by capturing CSI against an 11ax AP and comparing `info->len` between HT and HE frames.**<br><br>**RESOLVED WITH MEASUREMENT (2026-06-11, external — issue #1005, production deployment by @stuinfla):** the open question is answered in both directions. **IDF v5.4's driver blob downconverts** (148 B / 64-subcarrier HT frames, PPDU byte 0x00, on a confirmed-HE link); **IDF v5.5.2 delivers true HE-LTF** — 532 B frames = 256 bins (242 active HE20 tones), PPDU byte 0x01 (HE-SU), ~90% of frames, same board/AP/link. Setup: XIAO ESP32-C6 → hostapd on Intel AX210, 2.4 GHz ch 6, `ieee80211ax=1`. No firmware change required (`acquire_csi_su=1` was already set); the gate was purely the IDF driver version. Three C6 nodes ran this mode simultaneously with ADR-110 ESP-NOW sync. Requires the issue-#1005 version-guard fix in `c6_sync_espnow.c` to build on v5.5.x. |<br><br>**REPLICATED IN-HOUSE (2026-06-11):** same source + fix, fresh IDF v5.5.2 toolchain, original COM12 board (`20:6e:f1:17:00:84`), AP `ruv.net` (11ax 2.4 GHz): **84% of 1,525 captured frames at 532 B / PPDU 0x01 (HE-SU)**, HT minority 148 B / 0x00. Evidence grade: MEASURED (two independent rigs). |
|
||||
| **B2** | "TWT-bounded deterministic CSI cadence (10 ms wake)" | No 11ax AP in range. The TWT setup *call* was exercised live and the graceful fallback path is now correct (A9), but the agreement itself was never accepted. **Validate by associating with an 11ax AP that has TWT Responder=1, then capturing the timestamped CSI cadence vs the wall clock.** |
|
||||
| **B3** | "±100 µs cross-node alignment over 802.15.4" | 3 boards initialized their radios with correct EUIs (A4/A5), but **none stepped down from candidate-leader to follower** during repeated 35-second multi-board captures. <br><br>**Coex hypothesis REJECTED**: rebuilt + reflashed all 3 boards with `CONFIG_C6_TIMESYNC_CHANNEL=26` (2480 MHz, non-overlapping with WiFi ch 5 at 2432 MHz). Result identical: 3× candidate, 0× "stepping down". So 2.4 GHz radio coex was NOT the cause. <br><br>**Current leading hypothesis**: OpenThread (CONFIG_OPENTHREAD_ENABLED=y) owns the 802.15.4 radio when its stack is initialized — our weak-symbol overrides of `esp_ieee802154_receive_done` / `_transmit_done` may never be called because OpenThread registers strong handlers. Validation in progress: rebuilding with `CONFIG_OPENTHREAD_ENABLED=n` (raw 802.15.4 only, our beacon protocol is private — no need for the Thread stack). If leader election fires under raw-15.4-only, hypothesis confirmed. <br><br>If raw-only also fails, next move is to dump the actual PHY frame bytes via the IEEE 802.15.4 sniffer mode on a 4th board and diagnose at the frame level. |
|
||||
| **B4** | "~5 µA hibernation for battery seed nodes" | No INA / Joulescope current measurement available on this bench. The shipped code uses `esp_deep_sleep_enable_gpio_wakeup` (ext1 path, ESP-IDF default ~10 µA), not a true LP-core polling program. The 5 µA number is the C6 datasheet figure for ULP-level hibernation, not a measured value. **Validate by hooking an INA219/INA226 between the dev board's 3V3 rail and the regulator output, then averaging current over a 60-second cycle with the LP-core armed.** |
|
||||
|
||||
@@ -19,7 +19,7 @@ The production CSI node firmware (`firmware/esp32-csi-node`) was built around th
|
||||
|
||||
| C6 capability | What it enables for sensing | Why we can't get it on S3 |
|
||||
|---|---|---|
|
||||
| **802.11ax (Wi-Fi 6) HE-LTF CSI** | 242 subcarriers per HE20 frame (vs 52 for HT-LTF), HE-MU/HE-TB PPDU types, OFDMA-aware channel sounding | S3 radio is HT-only (n) |
|
||||
| **802.11ax (Wi-Fi 6) HE-LTF CSI** | 242 subcarriers per HE20 frame (vs 52 for HT-LTF), HE-MU/HE-TB PPDU types, OFDMA-aware channel sounding. **Hardware-confirmed 2026-06-11** (issue #1005, external production deployment): requires **ESP-IDF ≥ 5.5** — the v5.4 driver blob silently downconverts to 64-subcarrier HT even on a confirmed-HE link; v5.5.2 delivers 532 B frames = 256 bins (242 active tones), PPDU 0x01 (HE-SU). See WITNESS-LOG-110 §B1 (resolved). | S3 radio is HT-only (n) |
|
||||
| **802.15.4 (Thread / Zigbee)** | Cross-node time-sync over a separate radio — frees Wi-Fi airtime for CSI, ±100 µs alignment possible without coordination traffic on the sensing channel | S3 has no 802.15.4 |
|
||||
| **TWT (Target Wake Time)** | Sensor negotiates a deterministic wake slot with the AP; CSI cadence becomes scheduler-bounded instead of opportunistic | Requires 802.11ax — S3 can't speak it |
|
||||
| **LP-core + hibernation (~5 µA)** | Always-on motion gate runs on a separate RISC-V LP core in deep sleep; HP core stays off until a real event | S3 ULP is FSM-only, ~10 µA floor |
|
||||
|
||||
@@ -47,13 +47,16 @@ Adopt four changes, ordered by effort-vs-gain:
|
||||
|
||||
1. **Record transceiver geometry at enrollment.** `EnrollmentProtocol` gains an optional `NodeGeometry` record per node (position estimate, antenna orientation, inter-node distances where known). Stored alongside the room baseline in the bank; schema-versioned so existing banks remain readable.
|
||||
2. **Fuse geometry embeddings into specialist training.** Where a specialist head consumes the (future, ADR-150) backbone embedding, concatenate a small learned embedding of `NodeGeometry` — the PerceptAlign mechanism, transplanted to our per-room banks. Statistical specialists (current) ignore it; LoRA heads (ADR-151 P6) consume it.
|
||||
3. **Adopt the two-checkerboard alignment for the camera-supervised path (ADR-079).** When MediaPipe supervision is used, calibrate camera↔WiFi into one shared 3D frame before regression (<5 min, two checkerboards, a few photos). This is the direct defense against F1 for our 92.9%-PCK@20 pipeline.
|
||||
3. **Adopt the two-checkerboard alignment for the camera-supervised path (ADR-079).** When MediaPipe supervision is used, calibrate camera↔WiFi into one shared 3D frame before regression (<5 min, two checkerboards, a few photos). This is the direct defense against F1 for our camera-supervised pipeline. ~~92.9%-PCK@20~~ — *that figure was retracted during measurement (b) (2026-06-10): the surviving holdout shows a constant-output model under an absolute (non-torso) threshold on 69 near-static frames; mean predictor scores 100% under the same protocol. The §2.2 no-citation rule now applies to it.*
|
||||
4. **Evaluate on the PerceptAlign cross-domain dataset** (21 subjects / 7 layouts) as the MERIDIAN cross-layout benchmark — *gated on confirming its license and downloadability* (open question; repo per paper: github.com/Trymore-lab/PerceptAlign).
|
||||
> **Gate resolved (2026-06-10, MEASURED by repo inspection):** repo exists, **MIT license**, dataset downloadable from HuggingFace (5 per-scene repos, raw CSI + separate vision keypoints; Intel 5300, 1TX×3RX×3 ant, 57 subcarriers — same order as ESP32 subcarrier counts; Scene3 ships 3 distinct layouts). Code present, no pretrained weights. Benchmark adoption unblocked; dataset-side license terms inherit HF dataset terms (not separately stated — check at download time).
|
||||
|
||||
### 2.2 Benchmark against WiFlow-STD (DY2434) — ACCEPTED
|
||||
|
||||
Pull the Apache-2.0 weights + 360k-sample dataset; run three measurements: (a) their model on their data (reproduce 97.25% claim), (b) their model fine-tuned on our ESP32 17-keypoint eval set, (c) our internal WiFlow on their dataset (15-keypoint subset mapping). Until (a)–(c) are measured, **no RuView doc may cite 97.25% as a comparable number** — different dataset, subjects, keypoints.
|
||||
|
||||
> **Status (2026-06-10, measurement (a) complete — `benchmarks/wiflow-std/RESULTS.md`):** shipped checkpoint REFUTED (0.08% PCK@20 — wrong keypoint normalization, predates published code); released code does not run as published (6 defects, incl. broken package import and an unreachable test phase); released dataset's last 13 files are corrupted (9,072 windows: NaN + float32-max garbage, diverges fp16 training via BatchNorm poisoning). After repairing both, retraining with upstream defaults reproduced **96.09% PCK@20 full-test / 96.61% corruption-free / MPJPE 0.0094–0.0098** (published: 97.25% / 0.007) on an RTX 5080. Accuracy claims graded MEASURED-EQUIVALENT; params (2.23M) and FLOPs (~0.055G) verified. (b)/(c) remain open.
|
||||
|
||||
### 2.3 Apply the UNSW recipe to the ADR-150 encoder — ACCEPTED (amends ADR-150 §2.3)
|
||||
|
||||
- Pretraining corpus: start from the same 14 public datasets (1.3M samples) + our home/MM-Fi frames; data aggregation takes priority over architecture work.
|
||||
@@ -62,7 +65,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.
|
||||
|
||||
@@ -71,6 +74,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.
|
||||
@@ -82,6 +108,7 @@ Pull the Apache-2.0 weights + 360k-sample dataset; run three measurements: (a) t
|
||||
## 4. Open questions (carried from the research run)
|
||||
|
||||
1. Does WiFlow-STD retain accuracy when fine-tuned on ESP32-S3/C6 CSI (fewer subcarriers, lower SNR), scored on our 17-keypoint set? (§2.2 answers this.)
|
||||
> **Partial answer (MEASURED 2026-06-11, measurement (b) on 2,046 single-room windows — `benchmarks/wiflow-std/RESULTS.md`):** pretrained init shows strong *optimization* transfer (65% PCK@20 vs scratch's 0% collapse under the same budget) but **no feature transfer** (frozen-trunk + linear adapter ≈ 0%). And no run beat the mean-pose baseline (95.9% PCK@20 — single subject, near-static normalized coords), so no CSI→pose capability is citable from this data. A definitive answer needs multi-subject/multi-position data where the mean pose is weak.
|
||||
2. Is the PerceptAlign dataset downloadable under a usable license, and does the two-checkerboard procedure work with ESP32 transceiver geometry? (§2.1.4 gate.)
|
||||
3. Will esp_wifi_sensing evolve toward 802.11bf compliance, replacing opportunistic CSI extraction?
|
||||
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
# ADR-153: IEEE 802.11bf-2025 Forward-Compatibility Protocol Model for wifi-densepose-hardware
|
||||
|
||||
- **Status**: accepted
|
||||
- **Date**: 2026-06-10
|
||||
- **Deciders**: ruv
|
||||
- **Tags**: hardware, protocol, sensing, 802.11bf, forward-compatibility
|
||||
|
||||
## Context
|
||||
|
||||
IEEE 802.11bf-2025 (WLAN Sensing) is an **Active Standard**: board approval
|
||||
2025-05-28, published 2025-09-26 (verified against the IEEE SA record,
|
||||
<https://standards.ieee.org/ieee/802.11bf/11574/>). Its scope modifies the
|
||||
MAC, HE and EHT PHY service interfaces, plus DMG and EDMG PHYs, for WLAN
|
||||
sensing in **1–7.125 GHz** and **above 45 GHz** bands, with formal sensing
|
||||
measurement setup, measurement instance, feedback/reporting, and
|
||||
sensing-by-proxy (SBP) procedures (ADR-152 F4, evidence grade MEASURED).
|
||||
|
||||
No commodity silicon implements the standard yet — ESP32 parts included.
|
||||
ADR-152 §2.4 therefore decided "track silicon; no code now", with RuView's
|
||||
opportunistic CSI extraction remaining the mechanism. That left a gap: when
|
||||
silicon does land, RuView would have no typed model of the standard's
|
||||
procedures to bind to, and the integration would start from zero.
|
||||
|
||||
ADR-152 §2.4 originally classified 802.11bf as a hardware watch item with no
|
||||
implementation work until commodity silicon exposes standardized sensing
|
||||
measurements. This ADR amends that clause: OTA binding remains deferred, but
|
||||
a pure Rust protocol model, session FSM, transport seam, and opportunistic
|
||||
CSI bridge will be implemented now so RuView consumers can target a stable
|
||||
standardized sensing interface before silicon arrives.
|
||||
|
||||
The user directed (2026-06-10) that this **forward-compatibility protocol
|
||||
model** — a protocol surface, not a conformance implementation — be built
|
||||
now.
|
||||
|
||||
## Decision
|
||||
|
||||
Implement an `ieee80211bf` **forward-compatibility protocol model** in
|
||||
`wifi-densepose-hardware` (pure Rust, no internal deps, simulation-testable,
|
||||
no OTA path):
|
||||
|
||||
> This module is not a certified 802.11bf implementation. It models the
|
||||
> public procedure shape needed by RuView and RuvSense, while intentionally
|
||||
> avoiding OTA frame binding until chipset support and vendor APIs exist.
|
||||
|
||||
1. **`types.rs`** — typed structures for the standard's sensing procedures
|
||||
(sub-7 GHz focus; DMG stubbed): Sensing Measurement Setup (setup ID,
|
||||
initiator/responder and transmitter/receiver roles, bandwidth,
|
||||
periodicity, threshold-based reporting parameters), Sensing Measurement
|
||||
Instance, Sensing Measurement Report (CSI-variant payload), SBP
|
||||
request/response, termination. Two future-proofing requirements:
|
||||
|
||||
- **Version gates** — every negotiated surface is tagged with a spec
|
||||
profile, because vendors will expose partial or renamed capabilities
|
||||
first:
|
||||
|
||||
```rust
|
||||
pub enum SpecProfile {
|
||||
DraftCompatible,
|
||||
Ieee80211Bf2025,
|
||||
VendorExtension(String),
|
||||
}
|
||||
```
|
||||
|
||||
- **Capability negotiation** — no hardcoded ESP32 assumptions in the
|
||||
future-silicon path:
|
||||
|
||||
```rust
|
||||
pub struct SensingCapabilities {
|
||||
pub sub_7_ghz: bool,
|
||||
pub dmg: bool,
|
||||
pub edmg: bool,
|
||||
pub csi_report: bool,
|
||||
pub threshold_reporting: bool,
|
||||
pub sensing_by_proxy: bool,
|
||||
pub max_bandwidth_mhz: u16,
|
||||
pub max_period_ms: u32,
|
||||
pub max_active_setups: u16,
|
||||
}
|
||||
```
|
||||
|
||||
- **Privacy and governance fields** — sensing is presence inference, not
|
||||
just radio telemetry. Every `SensingMeasurementSetup` carries policy
|
||||
metadata (required, not optional), for enterprise, elderly-care,
|
||||
retail, workplace, and municipal deployments:
|
||||
|
||||
```rust
|
||||
pub enum ConsentMode {
|
||||
LabOnly,
|
||||
ExplicitConsent,
|
||||
ManagedEnterprisePolicy,
|
||||
Disabled,
|
||||
}
|
||||
```
|
||||
|
||||
2. **`session.rs`** — deterministic event-driven session state machine:
|
||||
`Idle → SetupNegotiating → Active → Terminating → Idle`, with explicit
|
||||
rejection paths (unsupported parameters, setup-ID collision) and timeout
|
||||
handling.
|
||||
3. **`transport.rs`** — a `SensingTransport` trait abstracting frame
|
||||
exchange; a `SimTransport` test double; and an `OpportunisticCsiBridge`
|
||||
adapter mapping today's ESP32 CSI extraction onto the report path
|
||||
(measurement instances ≈ CSI frame batches), so current hardware sits
|
||||
behind the standardized interface. **Replaceability benchmark
|
||||
(acceptance test):** RuvSense must consume either ESP32 opportunistic CSI
|
||||
or future 802.11bf chipset reports through the same `SensingTransport`
|
||||
and `SensingMeasurementReport` path, with no consumer-side rewrite — a
|
||||
future chipset adapter replaces `OpportunisticCsiBridge` without changing
|
||||
consumers.
|
||||
|
||||
Constraints: input validation at boundaries (typed errors, no panics on
|
||||
adversarial input), files under 500 lines, all protocol tests runnable
|
||||
without hardware.
|
||||
|
||||
### Acceptance checklist
|
||||
|
||||
| Area | Acceptance test |
|
||||
| --------------- | -------------------------------------------------------------------- |
|
||||
| Types | Serde round trip for setup, instance, report, SBP, termination |
|
||||
| FSM | Idle → setup → active → terminating → idle |
|
||||
| Rejection | Unsupported bandwidth, invalid period, duplicate setup ID |
|
||||
| Timeout | Negotiation timeout returns typed error and resets to Idle |
|
||||
| Threshold | Report emitted only when threshold condition is crossed |
|
||||
| SBP | Proxy request maps to responder path without direct sensor coupling |
|
||||
| Bridge | ESP32 CSI batch becomes standardized measurement report |
|
||||
| Safety | No panics on malformed inputs |
|
||||
| CI | All protocol tests run without hardware |
|
||||
| Maintainability | Each file under 500 lines |
|
||||
|
||||
### Non-Goals
|
||||
|
||||
This ADR does not claim IEEE 802.11bf conformance, certification, or OTA
|
||||
interoperability. It creates a typed protocol compatibility layer so RuView
|
||||
can consume standardized sensing reports when commodity silicon exposes
|
||||
them. Vendor-specific frame exchange, firmware hooks, trigger-frame
|
||||
sounding, and certification test vectors remain future ADRs.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
- RuView can adopt standardized WLAN sensing the day any chipset exposes
|
||||
802.11bf measurements — the data model, session FSM, and transport seam
|
||||
already exist and are tested.
|
||||
- The `OpportunisticCsiBridge` gives current ESP32 nodes a standardized-shape
|
||||
interface now, decoupling RuvSense consumers from the extraction mechanism.
|
||||
- Simulation transport enables protocol-level tests in CI without hardware.
|
||||
- `SpecProfile` + `SensingCapabilities` give a clean escape hatch for the
|
||||
partial/renamed vendor capabilities that will certainly arrive first.
|
||||
- Consent/policy metadata is structural from day one, not retrofitted.
|
||||
|
||||
### Negative
|
||||
- Code written against a standard with zero silicon risks drift: vendor
|
||||
implementations may interpret parameters differently; the layer may need
|
||||
rework at first real binding (drift risk scored 7/10 at acceptance).
|
||||
- Adds maintenance surface to wifi-densepose-hardware before any
|
||||
user-visible benefit (maintenance cost scored 3/10 — small without OTA).
|
||||
|
||||
### Neutral
|
||||
- ADR-152 §2.4's "watch item" remains: revisit when silicon/certification
|
||||
appears (re-check by 2026-12). This ADR changes only the "no code now"
|
||||
clause.
|
||||
|
||||
## Links
|
||||
|
||||
- ADR-152 — WiFi-Pose SOTA 2026 Intake (F4, §2.4 — amended by this ADR)
|
||||
- ADR-028 — ESP32 capability audit (opportunistic CSI extraction baseline)
|
||||
- ADR-029 — RuvSense multistatic sensing mode (consumer of sensing reports)
|
||||
- IEEE 802.11bf-2025 — Active Standard, board approval 2025-05-28, published
|
||||
2025-09-26: <https://standards.ieee.org/ieee/802.11bf/11574/>
|
||||
+15
-10
@@ -50,7 +50,7 @@ See [PR #405](https://github.com/ruvnet/RuView/pull/405) for full details.
|
||||
### What's New in v0.7.0
|
||||
|
||||
<details>
|
||||
<summary><strong>Camera Ground-Truth Training — 92.9% PCK@20</strong></summary>
|
||||
<summary><strong>Camera Ground-Truth Training</strong></summary>
|
||||
|
||||
**v0.7.0 adds camera-supervised pose training** using MediaPipe + real ESP32 CSI data:
|
||||
|
||||
@@ -76,15 +76,20 @@ node scripts/train-wiflow-supervised.js --data data/paired/*.jsonl --scale lite
|
||||
node scripts/eval-wiflow.js --model models/wiflow-real/wiflow-v1.json --data data/paired/*.jsonl
|
||||
```
|
||||
|
||||
**Result: 92.9% PCK@20** from a 5-minute data collection session with one ESP32-S3 and one webcam.
|
||||
> **Accuracy retraction (2026-06-10):** the "92.9% PCK@20" figure previously
|
||||
> shown here is retracted. A forensic recheck of the surviving eval holdout
|
||||
> (69 samples) found a constant-output model scored with an absolute
|
||||
> (non-torso-normalized) threshold on nearly-static frames — a protocol under
|
||||
> which a trivial mean-pose predictor scores 100%. Torso-normalized PCK@20 on
|
||||
> the same holdout is ~19% (from that degenerate predictor). No measured
|
||||
> camera-supervised PCK@20 is currently published (CHANGELOG, PR #535).
|
||||
|
||||
| Metric | Before (proxy) | After (camera-supervised) |
|
||||
|--------|----------------|--------------------------|
|
||||
| PCK@20 | 0% | **92.9%** |
|
||||
| Eval loss | 0.700 | **0.082** |
|
||||
| Bone constraint | N/A | **0.008** |
|
||||
| Training time | N/A | **19 minutes** |
|
||||
| Model size | N/A | **974 KB** |
|
||||
| Metric | Camera-supervised run (protocol retracted) |
|
||||
|--------|--------------------------------------------|
|
||||
| Eval loss | 0.082 |
|
||||
| Bone constraint | 0.008 |
|
||||
| Training time | 19 minutes |
|
||||
| Model size | 974 KB |
|
||||
|
||||
Pre-trained model: [HuggingFace ruv/ruview/wiflow-v1](https://huggingface.co/ruv/ruview)
|
||||
|
||||
@@ -868,7 +873,7 @@ Download a pre-built binary — no build toolchain needed:
|
||||
|
||||
| Release | What's included | Tag |
|
||||
|---------|-----------------|-----|
|
||||
| [v0.7.0](https://github.com/ruvnet/RuView/releases/tag/v0.7.0) | **Latest** — Camera-supervised WiFlow model (92.9% PCK@20), ground-truth training pipeline, ruvector optimizations | `v0.7.0` |
|
||||
| [v0.7.0](https://github.com/ruvnet/RuView/releases/tag/v0.7.0) | **Latest** — Camera-supervised WiFlow model (accuracy figure retracted 2026-06-10, see above), ground-truth training pipeline, ruvector optimizations | `v0.7.0` |
|
||||
| [v0.6.0](https://github.com/ruvnet/RuView/releases/tag/v0.6.0-esp32) | [Pre-trained models on HuggingFace](https://huggingface.co/ruv/ruview), 17 sensing apps, 51.6% contrastive improvement, 0.008ms inference | `v0.6.0-esp32` |
|
||||
| [v0.5.5](https://github.com/ruvnet/RuView/releases/tag/v0.5.5-esp32) | SNN + MinCut (#348 fix) + CNN spectrogram + WiFlow + multi-freq mesh + graph transformer | `v0.5.5-esp32` |
|
||||
| [v0.5.4](https://github.com/ruvnet/RuView/releases/tag/v0.5.4-esp32) | Cognitum Seed integration ([ADR-069](docs/adr/ADR-069-cognitum-seed-csi-pipeline.md)), 8-dim feature vectors, RVF store, witness chain, security hardening | `v0.5.4-esp32` |
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
# RuView System Review — Capability Audit (Beyond-SOTA Series, Doc 00)
|
||||
|
||||
**Date:** 2026-06-09
|
||||
**Scope:** The RuView product surface (ADR-031) and the 38-crate Rust workspace under `v2/crates/` that implements it, plus the ADR corpus (`docs/adr/`, 150 numbered ADRs) and the prior research corpus (`docs/research/sota-2026-05-22/`).
|
||||
**Method:** Direct reads of `lib.rs`/`mod.rs` and key ADRs; static test counts via `grep -r '#[test]'` / `#[tokio::test]` per crate (counts are *static occurrences in source*, not CI pass counts). No metrics in this document are estimated — everything cited was read or measured in the working tree.
|
||||
|
||||
---
|
||||
|
||||
## 1. Executive Summary — What RuView IS Today
|
||||
|
||||
RuView is **not a crate**. Per ADR-136 §2.1 (`docs/adr/ADR-136-ruview-streaming-engine-frame-contracts.md`), RuView is the sensing-first *product surface and brand* (ADR-031, status: Proposed) layered on the existing `wifi-densepose-*` / `homecore*` / `cog-*` workspace. ADR-136 explicitly **rejects** a `ruview_*` crate rename and pins a normative ten-role mapping (ingest / signal / fusion / world / models / privacy / store / api / eval / observe) onto the existing crates.
|
||||
|
||||
What concretely exists:
|
||||
|
||||
1. **A deep, heavily-tested signal-processing layer.** `wifi-densepose-signal` contains 473 static `#[test]` occurrences, including a 22-file `ruvsense/` bounded context (`v2/crates/wifi-densepose-signal/src/ruvsense/`) implementing the ADR-029 six-stage multistatic pipeline plus ADR-030/032a/134/135/137/138/142/143 extensions (~14,000 lines, 330 in-module tests measured by per-file grep).
|
||||
2. **A trust-traceable composition root.** `wifi-densepose-engine` (`src/lib.rs`, 752 lines, 11 tests) wires fusion quality (ADR-137), array coordination (ADR-138), evolution change-points (ADR-142), RF-SLAM anchors (ADR-143), the WorldGraph (ADR-139), and the BFLD privacy control plane (ADR-141) into one `StreamingEngine::process_cycle` (`lib.rs:285`) that emits a `TrustedOutput` (`lib.rs:80`) carrying evidence + model version + calibration version + privacy decision + a BLAKE3 witness (`lib.rs:437`).
|
||||
3. **A privacy layer with structural invariants.** `wifi-densepose-bfld` (20 modules, 369 tests) implements ADR-118–123/141: raw BFI never exits the node (I1), identity embeddings are RAM-only (I2), cross-site identity correlation is cryptographically impossible (I3) — stated at `wifi-densepose-bfld/src/lib.rs:7-11`.
|
||||
4. **A Home-Assistant-class world/state layer.** `homecore` + 9 sibling crates (state machine, event bus, plugins, automation, REST/WS API, recorder, HAP bridge, assist) — explicitly a "P1 scaffold" per `homecore/src/lib.rs:7` with deferred items listed at `lib.rs:24-31`.
|
||||
5. **A drone-swarm extension.** `ruview-swarm` (17 modules, ~9,000 lines in subdirectories, 115 + 19 async tests), ADR-148 self-reports ~98% complete with the remaining 15% of M3 gated on real ESP32-S3 hardware (`ADR-148:940-953`).
|
||||
6. **A large prior research corpus.** The 2026-05-22 autonomous SOTA loop: 41 ticks, 19 research threads (R1–R20), 22 numpy reference implementations, 7 ADRs, and a 6-tier production roadmap (`docs/research/sota-2026-05-22/00-summary.md`, `PRODUCTION-ROADMAP.md`).
|
||||
|
||||
The critical caveat, stated by the project itself: the ADR-136–146 series is *"a skeleton and nervous system, not a shipping product… Most of the series is not yet wired into the live 20 Hz pipeline"* (ADR-136 §8). The engine crate's own docs confirm what is absent: *"the live 20 Hz I/O loop (sensing-server), UWB hardware (ADR-144), and model training (ADR-146)"* (`wifi-densepose-engine/src/lib.rs:27-29`).
|
||||
|
||||
---
|
||||
|
||||
## 2. Capability Matrix — Pipeline Role → Crates → Maturity
|
||||
|
||||
Role mapping is normative per ADR-136 §2.1; maturity is this review's judgment from code + ADR status. Test counts: static `#[test]` + `#[tokio::test]` greps (2026-06-09).
|
||||
|
||||
| Role | Crate(s) | Key modules | Tests (sync+async) | Maturity | Evidence |
|
||||
|---|---|---|---|---|---|
|
||||
| **ingest** | `wifi-densepose-sensing-server`, `wifi-densepose-hardware`, `wifi-densepose-wifiscan` | `csi.rs`, `multistatic_bridge.rs`, `tracker_bridge.rs`, ESP32 TDM | 557+14, 137, 150 | **Production** (hardware-validated per ADR-028/039) | `sensing-server/src/` has 30+ modules incl. MQTT, Matter, RVF pipeline |
|
||||
| **signal** | `wifi-densepose-signal` (incl. `ruvsense/`) | 6-stage pipeline (`ruvsense/mod.rs:9-23`), `cir.rs`, `calibration.rs`, `hampel.rs`, `fresnel.rs`, `phase_sanitizer.rs` | 473 | **Production** (unit level); live multistatic wiring **beta** | §3 below; ADR-014 Accepted, ADR-029 Proposed |
|
||||
| **fusion** | `ruvsense/multistatic.rs`, `ruvsense/fusion_quality.rs`, `wifi-densepose-ruvector/src/viewpoint/` | `MultistaticFuser`, `QualityScore`, `CrossViewpointAttention`, GDI/Cramér-Rao (`viewpoint/geometry.rs`) | 20 (multistatic.rs), 3 (fusion_quality.rs), 136 (ruvector crate) | **Beta** — tested building blocks, composed only in `wifi-densepose-engine` tests | `viewpoint/mod.rs:1-30`; engine `lib.rs:317-319` |
|
||||
| **world** | `homecore`, `wifi-densepose-worldgraph`, `wifi-densepose-geo`, `wifi-densepose-worldmodel` | `StateMachine`, `EventBus`, `WorldGraph` (rooms/sensors/person-tracks/semantic states), ENU geo registration | 9+11, 7, 16+1, 12+1 | **Beta** — homecore is explicit "P1 scaffold"; persistence/service dispatch deferred to P2 | `homecore/src/lib.rs:7, 24-31`; ADR-127 Proposed |
|
||||
| **models** | `cog-pose-estimation`, `cog-person-count`, `wifi-densepose-nn`, `wifi-densepose-train`, `wifi-densepose-occworld-candle` | ONNX/Candle inference, training pipeline, OccWorld bridge | 7, 15, 30+1, 312, 12 | **Experimental** — no trained RF foundation encoder exists; ADR-147 benchmarked OccWorld with **random weights** | `ADR-147-benchmark-proof.md` ("random weights — pre-domain-fine-tuning baseline"); ADR-146/150 Proposed |
|
||||
| **privacy** | `wifi-densepose-bfld` | `privacy_gate.rs`, `privacy_mode.rs` (mode registry + hash-chained attestation), `identity_risk.rs`, `signature_hasher.rs`, `embedding_ring.rs` | 369 | **Beta** — strongest-tested layer, but lib header still says "Status: P1 in progress" (`lib.rs:12`, stale vs 20 implemented modules) | ADR-118–123, 141 all Proposed |
|
||||
| **store** | `homecore-recorder` | trajectory/event recording | 8+12 | **Experimental** | ADR-136 §2.1 |
|
||||
| **api** | `homecore-api`, `homecore-server`, `cog-ha-matter`, `homecore-hap` | REST/WS, HA discovery, Matter, HomeKit | 7+11, 0, 63+1, 15+2 | **Experimental→Beta** (`homecore-server` has zero tests) | ADR-130/125/115 Proposed |
|
||||
| **eval** | `wifi-densepose-train/src/ablation.rs`, `ruview-swarm/src/evals/` | ablation harness (ADR-145), swarm eval suite (ADR-149) | included in 312 / 115 | **Experimental** — ADR-145 self-labels "skeleton/scaffolding, mostly not yet on the live 20 Hz path" | `ablation.rs` exists; ADR-149 (swarm benchmarking) Accepted |
|
||||
| **observe** | `homecore-automation`, `homecore-assist` | automation engine, assistant/Ruflo bridge | 20+14, 3+20 | **Experimental** | ADR-129/133 Proposed |
|
||||
| **(integration root)** | `wifi-densepose-engine` | `StreamingEngine`, `TrustedOutput`, privacy demotion, witness | 11 | **Beta** — the only crate that proves cross-role composition; not on a live I/O path | `engine/src/lib.rs:1-29, 457-751` |
|
||||
| **(swarm)** | `ruview-swarm` | Raft/gossip topology, RRT-APF planning, Candle PPO MARL, CSI sensing payload, failsafe, Ruflo | 115+19 | **Experimental/simulation** — M3 needs real ESP32-S3 hardware | ADR-148:940-953 ("Overall ~98%", M3 85%) |
|
||||
| **(adjacent)** | `nvsim`, `nvsim-server`, `ruv-neural`, `wifi-densepose-wasm-edge`, `wifi-densepose-mat`, `wifi-densepose-vitals` | NV-diamond sim, neural lib, WASM edge, MAT disaster tool, vitals | 50, 0, 364, 643, 165+9, 52 | Mixed — `mat`/`vitals`/`wasm-edge` mature unit-wise | crate listing |
|
||||
|
||||
**Workspace totals (measured):** 3,890 `#[test]` + 121 `#[tokio::test]` static occurrences across `v2/crates/`. (CLAUDE.md's "1,031+ tests" figure refers to an earlier `cargo test --workspace` run count; this review did not execute the suite.)
|
||||
|
||||
External vendored runtimes also present: `vendor/rvcsi` (ADR-095/096 edge RF runtime, own repo), `vendor/ruvector`, `vendor/midstream`, `vendor/sublinear-time-solver`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Signal-Processing Capability Inventory — `ruvsense/`
|
||||
|
||||
Location: `v2/crates/wifi-densepose-signal/src/ruvsense/`. CLAUDE.md says "16 modules"; the directory now contains **22 `.rs` files** (21 modules + `mod.rs`) — the table below is the ground truth. Lines/tests measured per file (2026-06-09).
|
||||
|
||||
| Module | Lines | Tests | ADR | What it does |
|
||||
|---|---:|---:|---|---|
|
||||
| `mod.rs` | 510 | 14 | 029 | Pipeline shell, COCO-17 keypoint constants, `RuvSensePipeline` (concrete fields + `tick()`), re-exports |
|
||||
| `multiband.rs` | 442 | 14 | 029 | Channel-hopping CSI → wideband virtual snapshot per node (`MultiBandCsiFrame`) |
|
||||
| `phase_align.rs` | 460 | 13 | 029 | LO phase-offset estimation via circular mean + `ruvector-solver::NeumannSolver` |
|
||||
| `multistatic.rs` | 957 | 20 | 029 | Attention-weighted N-node fusion → `FusedSensingFrame`; timestamp-spread guards |
|
||||
| `coherence.rs` | 474 | 19 | 029 | Per-subcarrier z-score coherence vs rolling template; `DriftProfile` |
|
||||
| `coherence_gate.rs` | 380 | 17 | 029 | Accept / PredictOnly / Reject / Recalibrate gate decisions |
|
||||
| `pose_tracker.rs` | 1,577 | 38 | 029/026/082 | 17-keypoint Kalman tracker, lifecycle state machine, AETHER re-ID embeddings, skeleton constraints, temporal keypoint attention |
|
||||
| `field_model.rs` | 1,417 | 22 | 030 | SVD room eigenstructure (persistent field model), perturbation extraction |
|
||||
| `tomography.rs` | 751 | 12 | 030 | RF tomography, ISTA L1 voxel solver |
|
||||
| `longitudinal.rs` | 1,020 | 20 | 030 | Welford long-horizon stats, biomechanics drift detection |
|
||||
| `intention.rs` | 511 | 12 | 030 | Pre-movement lead signals (200–500 ms) |
|
||||
| `cross_room.rs` | 626 | 13 | 030 | Environment fingerprinting + room-transition graph |
|
||||
| `gesture.rs` | 579 | 14 | 030 | DTW template-matching gesture classifier |
|
||||
| `adversarial.rs` | 586 | 13 | 030/032 | Physically-impossible-signal detection, multi-link consistency |
|
||||
| `attractor_drift.rs` | 566 | 15 | 032a | Midstream-enhanced attractor drift detection |
|
||||
| `temporal_gesture.rs` | 540 | 15 | 032a | Midstream temporal gesture stream |
|
||||
| `cir.rs` | 1,025 | 10 | 134 | CSI→CIR via ISTA L1 sparse recovery, NeumannSolver warm-start, `Complex32` sub-DFT Φ |
|
||||
| `calibration.rs` | 717 | 8 | 135 | Empty-room baseline (Welford amplitude + von Mises phase), drift-triggered recalibration |
|
||||
| `fusion_quality.rs` | 188 | 3 | 137 | `QualityScore` with `EvidenceRef`s, `ContradictionFlag`s, `CalibrationId`, privacy-demotion predicate |
|
||||
| `array_coordinator.rs` | 343 | 5 | 138 | Clock-quality gating + `DirectionalEvidence` (geometric admission) |
|
||||
| `evolution.rs` | 406 | 7 | 142 | Cross-link change-point detection, Bayesian `TemporalVoxelMap` (privacy-gated) |
|
||||
| `rf_slam.rs` | 301 | 6 | 143 | Persistent reflector discovery → static anchor learning (Wall/Furniture/Mobile classes) |
|
||||
|
||||
Subtotal: ~14,400 lines, 310 tests inside `ruvsense/` alone. The non-ruvsense signal layer adds Hampel filtering, CSI-ratio, phase sanitisation, Fresnel modeling, BVP, spectrograms, subcarrier selection, and hardware normalisation (`signal/src/*.rs`).
|
||||
|
||||
**Cross-viewpoint fusion** (`wifi-densepose-ruvector/src/viewpoint/`, 5 files): scaled dot-product attention with geometric bias (`attention.rs`), Geometric Diversity Index + Cramér-Rao bounds (`geometry.rs`), phase-phasor coherence with hysteresis + clock-quality gate (`coherence.rs`), and the `MultistaticArray` aggregate root (`fusion.rs`). 136 tests crate-wide.
|
||||
|
||||
---
|
||||
|
||||
## 4. The Trust Chain — What Actually Composes Today
|
||||
|
||||
`wifi-densepose-engine/src/lib.rs` is the proof-of-composition. One `process_cycle` (`lib.rs:285-368`):
|
||||
|
||||
1. ADR-138 array coordination (only if every node's geometry is registered, `lib.rs:372-389`)
|
||||
2. ADR-137 `fuse_scored_calibrated` with **per-node calibration epochs** — mismatching `CalibrationId`s raise a contradiction (`lib.rs:304-319`)
|
||||
3. ADR-142 change-point → WorldGraph `Event` node (`lib.rs:393-430`)
|
||||
4. ADR-141 monotonic privacy demotion on any contradiction (`demote_one`, `lib.rs:452-455`)
|
||||
5. ADR-139/140 `SemanticState` with mandatory provenance (evidence ‖ model ‖ calibration ‖ privacy decision) (`lib.rs:336-352`)
|
||||
6. BLAKE3 witness over the trust decision (`witness_of`, `lib.rs:437-448`)
|
||||
|
||||
The 11 engine tests verify exactly the right invariants: full provenance flow (`cycle_carries_full_provenance`, `lib.rs:487`), contradiction→demotion (`lib.rs:517`), determinism (`lib.rs:535`), calibration-mismatch→Restricted+stable-witness (`lib.rs:648`), privacy-mode attestation chain (`lib.rs:741`), and persist→reload round-trip with **no raw RF in the snapshot** (`live_frame_to_reload_same_contents`, `lib.rs:696-736`).
|
||||
|
||||
This is genuinely strong design. But all inputs are synthetic `MultiBandCsiFrame`s constructed in the test module; no ingest crate calls `StreamingEngine` yet.
|
||||
|
||||
---
|
||||
|
||||
## 5. Strengths
|
||||
|
||||
1. **Deterministic witness chain, end to end in design.** ADR-028 proof (`archive/v1/data/proof/verify.py` + SHA-256), ADR-119 BLAKE3 frame witnesses (`bfld/src/signature_hasher.rs`), ADR-136 `CanonicalFrame`/`ComplexSample` LE contracts, and the engine's per-cycle trust witness form a coherent auditability story few sensing systems attempt.
|
||||
2. **Privacy as a control plane, not a feature.** BFLD's three structural invariants (`bfld/src/lib.rs:7-11`), hash-rotation (ADR-120), identity-risk scoring (ADR-121), mode registry with hash-chained attestations, and *monotonic* demotion wired to fusion contradictions (engine `lib.rs:327-328`) — uncertainty automatically reduces information release.
|
||||
3. **Multistatic fusion with physics-grounded quality.** Attention fusion + GDI + Cramér-Rao bounds + clock-quality gating means geometry and synchronisation deficits are first-class, measurable contradiction sources rather than silent failure modes.
|
||||
4. **Test density at the unit level.** 3,890 static test functions; the signal core (473), BFLD (369), and sensing-server (571) are the deepest. ruvsense files average ~14 tests/module.
|
||||
5. **Honest self-assessment culture.** ADR-136 §8's "skeleton, not a shipping product" framing, ADR-147's explicit "random weights" disclosure, and homecore's in-source TODO-P2 ledger (`homecore/src/lib.rs:24-31`) make the gap analysis below mostly a matter of reading what the project already admits.
|
||||
6. **A real prior research base with negative results.** The sota-2026-05-22 loop catalogued negatives by resolution path (missing-tool / architecture-error / physics-floor) and produced a ship-recipe (N=5 chest-centric placement, 100% coverage for 1–4 occupants) consolidated into ADR-113.
|
||||
7. **Hardware path exists and was audited.** ADR-028 (Accepted) and ADR-039 (Accepted, hardware-validated) anchor the ESP32-S3/C6 ingest tier; firmware release process includes real-CSI verification on COM ports.
|
||||
|
||||
---
|
||||
|
||||
## 6. Honest Gap Analysis — ADR vs Implemented vs Integrated
|
||||
|
||||
| Capability | ADR status | Code status | Integrated on live path? |
|
||||
|---|---|---|---|
|
||||
| Six-stage ruvsense pipeline | ADR-029 **Proposed** | Implemented + tested (310 tests) | Partially — sensing-server has `multistatic_bridge.rs`/`tracker_bridge.rs`, but `RuvSensePipeline` still holds concrete fields with `tick()` only (`mod.rs`); no uniform `Stage<I,O>` chain runs live |
|
||||
| Frame contracts (`ComplexSample`, provenance fields, `Stage` traits) | ADR-136 Proposed | Built + 9 acceptance tests (per ADR-136 §8, commit `11f89727f`) | **No** — AC6 600-frame replay witness key and AC7 cross-arch CI matrix not done; provenance fields not populated by live calibration/model stages |
|
||||
| Fusion quality / contradictions | ADR-137 Proposed | `fusion_quality.rs` (188 lines, 3 tests) + engine wiring | Engine-tests only |
|
||||
| WorldGraph digital twin | ADR-139 Proposed | `wifi-densepose-worldgraph` (4 files, 7 tests) | Engine-tests only; no recorder-backed persistence loop |
|
||||
| Privacy control plane | ADR-141 Proposed | `privacy_mode.rs` registry + attestation chain, tested | Engine-tests only; MQTT/HA exposure exists in BFLD but the *engine→BFLD sink* live path is unwired |
|
||||
| UWB range fusion | ADR-144 Proposed | **No hardware, no crate** — acknowledged absent (`engine/src/lib.rs:28`) | No |
|
||||
| Ablation/leakage eval harness | ADR-145 Proposed | `wifi-densepose-train/src/ablation.rs` exists | Self-labelled "skeleton/scaffolding" (ADR-145 §status) |
|
||||
| RF encoder multi-task heads | ADR-146 Proposed | Not trained; `model_id`/`model_version` registry unowned | No — engine stamps `rfenc-v1` as a placeholder string (`lib.rs:338`) |
|
||||
| RF foundation encoder | ADR-150 **Proposed** | ADR only | No |
|
||||
| World-model forecasting (OccWorld) | ADR-147 (benchmark doc) | Runs on RTX 5080, 72.39M params — **random weights**, no domain checkpoint | No |
|
||||
| HomeCore HA port | ADR-125–133 all Proposed | P1 scaffold + siblings; `homecore-server` has **0 tests**; persistence, service mpsc dispatch, device registry, witness integration all deferred (`homecore/src/lib.rs:24-31`) | Partially (API surfaces exist) |
|
||||
| BFLD capture path (Nexmon/ESP32 BFI) | ADR-123 Proposed | rvCSI vendored runtime exists for nexmon `.pcap`; BFI-specific capture unverified in this review | Unclear |
|
||||
| Drone swarm | ADR-148 In Progress | 17 modules, sim + Candle PPO complete per milestones | **Simulation only** — M3's 15% requires physical ESP32-S3 CSI capture (ADR-148:946) |
|
||||
| Federation / DP-SGD / PQC | ADR-105–109 Proposed | `ruview-fed` crate **does not exist** (roadmap Tier 2 item) | No |
|
||||
| Antenna-placement CLI (`plan-antennas`) | ADR-113 Proposed; Roadmap Tier 1.1 HIGH | numpy references only; not found as a Rust CLI subcommand | No |
|
||||
|
||||
**Pattern:** the unit layer is real and deep; the *integration* layer is one crate (`wifi-densepose-engine`) exercised solely by its own synthetic tests; the *model* layer (anything learned: RF encoder, pose model fine-tuned on CSI, OccWorld domain weights) is the emptiest tier. Nearly every ADR ≥118 carries status **Proposed** even where substantial tested code exists — ADR status hygiene lags implementation in both directions (BFLD code outruns its "P1 in progress" header; ADR-148's "98%" outruns its hardware evidence).
|
||||
|
||||
---
|
||||
|
||||
## 7. Risk Register
|
||||
|
||||
| # | Risk | Likelihood | Impact | Evidence / Notes |
|
||||
|---|---|---|---|---|
|
||||
| R1 | **Integration gap**: trust chain proven only against synthetic in-test frames; live 20 Hz ingest→engine→BFLD-sink path unwired, so the headline guarantee (auditable provenance on every emission) is unverified in production conditions | High | Critical | `engine/src/lib.rs:27-29`; ADR-136 §8 |
|
||||
| R2 | **No trained model**: every learned component (RF encoder ADR-146/150, OccWorld ADR-147) is random-weight or absent; sensing claims beyond coherence/occupancy heuristics cannot ship | High | Critical | ADR-147 "random weights"; ADR-146/150 Proposed |
|
||||
| R3 | **Synthetic-validation bias**: ruvsense/engine/swarm tests and the sota-loop results (e.g., R3 "100% (synthetic)", ADR-113 placement numbers) are simulation-derived; real-room domain gap unquantified | High | High | `00-summary.md:45`; PRODUCTION-ROADMAP 2.3 ("turns synthetic numbers into validated numbers") |
|
||||
| R4 | **Witness chain incomplete at frame level**: `CsiFrame.data` is still `serde(skip)` (ADR-136 Gap 2); AC6 replay-witness key and AC7 cross-architecture matrix not landed — deterministic replay is a design, not a property | Medium | High | ADR-136 §1.1, §8 |
|
||||
| R5 | **Float nondeterminism in fusion** across thread counts could silently break the witness/replay contract once wired | Medium | High | ADR-136 §3.3 risk table (project's own assessment) |
|
||||
| R6 | **Privacy bypass via unwired paths**: BFLD invariants are enforced per-module, but until the engine is the *only* route from ingest to API, a sensing-server endpoint can emit ungated state (sensing-server already has 30+ modules incl. pose/vitals APIs predating the control plane) | Medium | Critical | `sensing-server/src/` module list vs engine isolation |
|
||||
| R7 | **Hardware dependence + scale**: multistatic TDMA/channel-hopping timing validated on small ESP32 sets; ADR-148 M3 explicitly blocked on real hardware; clock-quality model in engine uses a hardcoded `ClockQualityScore` (`engine/src/lib.rs:384`) | Medium | High | ADR-148:946; hardcoded 50 µs stdev |
|
||||
| R8 | **ADR/doc/status drift**: 150 ADRs with near-universal "Proposed" status, stale in-source status headers (`bfld/src/lib.rs:12`), CLAUDE.md "16 ruvsense modules" vs 22 on disk, duplicate ADR numbers (two ADR-050s, two ADR-147s, two ADR-149s, ADR-052 ×2) — institutional-memory value degrades | High | Medium | `ls docs/adr/`; this review §3 |
|
||||
| R9 | **Workspace breadth vs maintenance capacity**: 38 workspace crates + 4 vendored subtrees + Python archive + firmware; several crates have 0 tests (`homecore-server`, `nvsim-server`, `wifi-densepose-wasm`, `homecore-plugin-example`); bus factor appears to be ~1 | High | Medium | crate test-count table §2 |
|
||||
| R10 | **Eval debt**: no end-to-end accuracy benchmark on real CSI with ground truth exists in-repo (ADR-145 harness is scaffolding; ADR-079 camera ground truth not exercised here) — "beyond SOTA" claims are currently unfalsifiable | High | High | ADR-145 status note; absence of ground-truth datasets in tree |
|
||||
|
||||
---
|
||||
|
||||
## 8. Measurement Appendix
|
||||
|
||||
- Test counts: `grep -r '#[test]'` / `#[tokio::test]` per crate directory, 2026-06-09. Workspace totals: 3,890 / 121. Top crates: `wasm-edge` 643, `sensing-server` 557+14, `signal` 473, `bfld` 369, `ruv-neural` 364, `train` 312, `mat` 165+9, `wifiscan` 150, `hardware` 137, `ruvector` 136, `ruview-swarm` 115+19.
|
||||
- ruvsense per-file lines/tests: `wc -l` + per-file `grep -c '#[test]'` (table in §3).
|
||||
- Crate inventory: `ls v2/crates/` → 38 directories.
|
||||
- ADR inventory: `ls docs/adr/` → 150 numbered files (with the duplicate numbers noted in R8); `docs/adr/README.md` self-reports "45 ADRs" (stale).
|
||||
- Caveats: static `#[test]` counts include `#[cfg(feature = ...)]`-gated and ignored tests; they are an upper bound on what `cargo test --workspace --no-default-features` runs. No cargo build/test was executed for this review.
|
||||
|
||||
*Next in series: 01+ documents should target the R1/R2/R10 axis — wiring the live path, training the RF encoder, and standing up a falsifiable real-CSI benchmark — before any "beyond SOTA" claim is made.*
|
||||
@@ -0,0 +1,191 @@
|
||||
# SOTA Landscape 2026 — The Bar a Beyond-SOTA RuView Must Clear
|
||||
|
||||
**Series**: ruview-beyond-sota (01)
|
||||
**Date**: 2026-06-09
|
||||
**Status**: Research survey / target definition
|
||||
**Builds on (does not duplicate)**: `docs/research/sota-2026-05-22/00-summary.md` (physics floors, placement, privacy chain), `docs/research/BFLD/01-sota-survey.md` (beamforming-feedback leakage SOTA), `docs/research/neural-decoding/21-sota-neural-decoding-landscape.md` (sensor-fidelity framing), `docs/research/rf-topological-sensing/00-rf-topological-sensing-index.md` (mincut/topology resolution limits), ADR-150 (RF foundation encoder + measured MM-Fi campaign), ADR-147 (OccWorld benchmark proof).
|
||||
|
||||
## 0. Evidence legend
|
||||
|
||||
Every claim in this document carries one of three tags. **No RuView benchmark number in this document is invented**; all RuView numbers come from repo-internal measured artifacts.
|
||||
|
||||
| Tag | Meaning |
|
||||
|-----|---------|
|
||||
| **[V]** | Verified in this session via web search (June 2026); source linked in §8 |
|
||||
| **[K]** | Training-knowledge claim (pre-2026 literature); plausible but **not re-verified** — treat as needing citation check before external publication |
|
||||
| **[I]** | Internal RuView measurement or artifact (ADR, issue, witness bundle) — measured, not literature |
|
||||
|
||||
---
|
||||
|
||||
## 1. SOTA reference table per capability axis
|
||||
|
||||
### 1.1 Pose estimation (WiFi CSI)
|
||||
|
||||
| Method | Year | Metric | Dataset / protocol | Tag |
|
||||
|--------|------|--------|--------------------|-----|
|
||||
| DensePose From WiFi (Geng, Huang, De la Torre) | 2023 | Dense-pose UV regions from CSI, "comparable to image-based approaches" (same-layout); commonly cited AP≈43.5 / AP@50≈87.2 | 3×3 antenna, single-layout lab | exact AP numbers **[K]**; paper existence **[V]** (arXiv 2301.00250) |
|
||||
| MetaFi++ (Zhou et al.) | 2023 | PCK@50 = **97.30%** same-domain real-world (MetaFi: 95.23%); drops to **81.7–86.5%** under stricter protocols | Own capture; protocol-sensitive | **[V]** |
|
||||
| Person-in-WiFi 3D (CVPR 2024) | 2024 | End-to-end multi-person 3D; 20.4 M params, **54 FPS**; MPJPE ≈ 90–100 mm on own dataset | Own multi-person dataset | FPS/params **[V]**; MPJPE range **[K]** |
|
||||
| GraphPose-Fi (arXiv 2511.19105) | 2025 | SOTA on MM-Fi random split: **MPJPE 160.6 mm**, best PCK at all thresholds | MM-Fi, random split (S1) | **[V]** |
|
||||
| CSDS (Electronics 14(4):756) | 2025 | Wi-Pose: PCK@5 = **0.6407**, PCK@50 = **0.8824** | Wi-Pose | **[V]** |
|
||||
| PerceptAlign (arXiv 2601.12252) | 2026 | Cross-layout 3D: MPJPE **222.4 mm** (Scene 4) / **317.1 mm** (Scene 5), >54% better than prior cross-layout SOTA; in easier settings MPJPE 181.5 mm, PCK@20/50 = 44.2/79.5 | Cross-layout protocol | **[V]** |
|
||||
| WiFlow (arXiv 2602.08661) | 2026 | Lightweight continuous HPE, spatio-temporal decoupling | — | **[V]** (existence; numbers not extracted) |
|
||||
| **RuView / AetherArena** | 2026 | **81.63% torso-PCK@20 in-domain (random split), beating MultiFormer's 72.25%** on metric/protocol-matched MM-Fi; **leakage-free cross-subject collapses to ~11.6% torso-PCK zero-shot**; official-split harness baseline ~63–65% PCK@20; **11 KB LoRA few-shot calibration → 72.5%** | MM-Fi (issue #876, ADR-150 §3) | **[I]** |
|
||||
|
||||
**The honest reading of the pose axis**: same-domain WiFi pose is "solved-looking" (PCK@50 in the 90s) and meaningless for deployment. The 2025–2026 literature has shifted to cross-layout/cross-subject protocols, where numbers collapse (PerceptAlign PCK@20 = 44.2 cross-layout **[V]**; RuView cross-subject zero-shot 11.6% **[I]**). ADR-150's measured finding — that the cross-subject gap is **subject-distribution shift, not an algorithmic gap**, and that **few-shot in-room calibration (5–200 frames) closes it** — is ahead of where the published literature is: no published WiFi-pose paper we found ships a per-room ~11 KB adapter calibration mechanism. **[I]**
|
||||
|
||||
### 1.2 Presence / person count
|
||||
|
||||
| Method | Year | Metric | Tag |
|
||||
|--------|------|--------|-----|
|
||||
| Large-scale commodity router deployment (>10 M routers) | 2025 | **92.6% motion-detection accuracy** across diverse homes | **[V]** (ISAC survey, arXiv 2510.14358) |
|
||||
| LeakyBeam (NDSS 2025) | 2025 | Occupancy through walls at 20 m from **plaintext BFI alone**: TPR 82.7%, TNR 96.7% | **[V]** (also in BFLD survey §4.2) |
|
||||
| Time-Selective RNN multi-room presence (arXiv 2304.13107) | 2023 | Device-free multi-room presence from CSI | **[V]** (existence) |
|
||||
| Academic person counting (0–5 occupants, lab) | 2020–2024 | typically 90–97% exact-count accuracy, degrading sharply >5 people | **[K]** |
|
||||
| **RuView** | 2026 | `cog-person-count` ships with calibrated uncertainty (`count_p95_low/high`); multistatic placement recipe with **100% coverage for 1–4 occupants at N=5 nodes (synthetic physics)** | **[I]** (sota-2026-05-22 R6.2.5, ADR-113) |
|
||||
|
||||
### 1.3 Vital signs (HR / BR)
|
||||
|
||||
| Method | Year | Metric | Tag |
|
||||
|--------|------|--------|-----|
|
||||
| PhaseBeat (ACM Health) | 2020 | HR median error **1.19 bpm**; BR median error **0.25 breaths/min** | **[V]** |
|
||||
| MDPI Sensors 24(7):2111 non-contact HR | 2024 | HR accuracy 96.8%, **median error 0.8 bpm** | **[V]** |
|
||||
| PulseFi (arXiv 2510.24744) | 2025 | Low-cost ML cardiopulmonary + **apnea** monitoring from CSI | **[V]** (existence; numbers not extracted) |
|
||||
| mmWave FMCW vitals (60 GHz class) | 2023–2026 | HR MAE typically 1–3 bpm at 1–3 m, single subject; age-balanced reference dataset published (Sci Data 2026) | dataset **[V]**; MAE range **[K]** |
|
||||
| Contactless blood pressure (WiFi-band) | — | **NEGATIVE** — below classical physics floor; recoverable only via quantum magnetometry path | **[I]** (R13/R20 arc, ADR-114) |
|
||||
| **RuView** | 2026 | `wifi-densepose-vitals` (ADR-021) extracts HR/BR from ESP32 CSI; chest-centric placement gives **+27 pp coverage** for vitals cogs (synthetic) | **[I]** — **no accuracy-vs-ECG validation number exists in-repo yet; do not claim one** |
|
||||
|
||||
**Bar**: published single-subject, line-of-sight, 1–3 m WiFi HR is ~0.8–1.2 bpm median error **[V]**. Nobody credibly publishes multi-person, through-wall, walking-subject HR at that accuracy — that is open territory.
|
||||
|
||||
### 1.4 Localization (ToA / CRLB)
|
||||
|
||||
| Method | Year | Metric | Tag |
|
||||
|--------|------|--------|-----|
|
||||
| 802.11mc FTM | shipped | 1–2 m typical accuracy | **[V]** (FTM survey, arXiv 2509.03901) |
|
||||
| 802.11az (+ 802.11bk) | released | **sub-1 m**, 160 MHz channels, secured ranging, HE-LTF repetitions | **[V]** |
|
||||
| AI single-link decimeter localization | 2025 | **0.63 m average error** single-link, beating Widar2.0 / Dynamic-MUSIC | **[V]** |
|
||||
| SpotFi / Chronos / Widar lineage | 2015–2021 | 0.4–1 m with multi-AP CSI AoA/ToF | **[K]** |
|
||||
| **RuView** | 2026 | CRLB / Fisher-information machinery in `ruvector/src/viewpoint/geometry.rs`; tomography ISTA voxel grid; **theoretical** limits derived internally: 30–60 cm at 16 nodes/1 m spacing, 8.8 cm information-theoretic dense limit | **[I]** (rf-topological-sensing doc 09 — synthetic derivations, no bench numbers) |
|
||||
|
||||
### 1.5 Through-wall
|
||||
|
||||
| Method | Year | Metric | Tag |
|
||||
|--------|------|--------|-----|
|
||||
| RF-Pose / RF-Pose3D (MIT, FMCW 5.4–7.2 GHz) | 2018 | Through-wall skeletal pose, ~specialized radar not commodity WiFi | **[K]** |
|
||||
| Commodity 2.4 GHz through-wall imaging (arXiv 1903.03895) | 2019 | Coarse imaging through walls with commodity WiFi | **[V]** (existence) |
|
||||
| Radio tomographic imaging (RTI) lineage | 2010–2013 | Through-wall tracking via RSS networks, ~0.5–1 m tracking error | **[V]** (papers) / error figure **[K]** |
|
||||
| LeakyBeam (NDSS 2025) | 2025 | Through-wall **occupancy** at 20 m, passive, commodity | **[V]** |
|
||||
| **RuView** | 2026 | RF tomography module (`tomography.rs`, ISTA L1 voxel solver) + CIR (ADR-134) exist as code; **PABS structure detection: 1,161× static / 9.36× dynamic intruder lift (synthetic)** | **[I]** |
|
||||
|
||||
Notably, the 2025–2026 web literature shows through-wall *pose* (not just presence) on commodity WiFi remains essentially where it was in 2019 — no verified commodity-WiFi through-wall pose benchmark surfaced in our searches. The frontier moved to privacy attacks (BFI) instead.
|
||||
|
||||
### 1.6 Identity / re-ID (capability and threat simultaneously)
|
||||
|
||||
| Method | Year | Metric | Tag |
|
||||
|--------|------|--------|-----|
|
||||
| BFId (KIT, ACM CCS 2025) | 2025 | **~99.5% (near-100%) re-ID across 197 subjects** from beamforming feedback alone, ≥5 s of BFI | **[V]** (also BFLD survey §4.1) |
|
||||
| Transformer CSI identification | 2025 | **99.82%** on stationary subjects | **[V]** |
|
||||
| WhoFi (arXiv 2507.12869) | 2025 | Deep person re-ID via WiFi channel encoding, ~95% rank-1 class results | existence **[V]**; exact number **[K]** |
|
||||
| Wi-Gait | 2023 | 92.9% over 10 subjects, robust to walking cofactors | **[V]** |
|
||||
| **RuView** | 2026 | AETHER contrastive re-ID embeddings (ADR-024) in pose tracker; **BFLD**: first *defensive* identity-leak detector (identity_risk_score) — the literature attacks, RuView audits | **[I]** |
|
||||
|
||||
### 1.7 Adjacent modality: mmWave radar (the accuracy ceiling WiFi is chasing)
|
||||
|
||||
| Method | Year | Metric | Tag |
|
||||
|--------|------|--------|-----|
|
||||
| mmChainPose | 2025 | **27.0 mm MPJPE** / 0.8706 OKS on MARS (mmWave point cloud) | **[V]** |
|
||||
| ProbRadarM3F (arXiv 2405.05164) | 2024–25 | SOTA AP across joints, probability-map fusion | **[V]** |
|
||||
| Seeed MR60BHA2-class 60 GHz FMCW | shipped | Commodity $15 HR/BR/presence module — already in RuView's hardware table | **[I]** |
|
||||
|
||||
mmWave is ~6× better than the best WiFi MPJPE (27 mm vs 160 mm) **[V]**. The strategic implication: WiFi will not beat mmWave on raw geometry; it wins on ubiquity, cost, through-wall propagation, and standardized waveforms (§2). RuView already hedges with the ESP32-C6 + MR60BHA2 fusion node. **[I]**
|
||||
|
||||
---
|
||||
|
||||
## 2. IEEE 802.11bf — status and implications
|
||||
|
||||
**Status (verified)**: IEEE **802.11bf-2025 is ratified and published** (IEEE SA lists the amendment; ratification late 2024 / publication 2025) **[V]**. It amends MAC/PHY of HE (Wi-Fi 6) and EHT (Wi-Fi 7) plus DMG/EDMG (60 GHz) to support WLAN sensing in 1–7.125 GHz and >45 GHz bands **[V]**. The Wi-Fi Alliance has Wi-Fi Sensing as an active certification work area built on 802.11bf (presence/proximity, gestures, vital signs) **[V]**. Market reports claim >47 chipset vendors with 802.11bf-compatible programs as of early 2026 — single weak source, treat as directional **[V, low confidence]**.
|
||||
|
||||
**What it implies for RuView**:
|
||||
|
||||
1. **Sounding-on-demand becomes standard.** 802.11bf defines a sensing-measurement procedure (sensing initiator/responder, trigger-based sounding, threshold-based reporting). Today RuView relies on Espressif's vendor CSI API and Nexmon firmware patches; post-bf, commodity Wi-Fi 7 silicon will expose scheduled sensing measurements without firmware hacks. The rvCSI normalized `CsiFrame` schema is the right abstraction layer to absorb a future bf adapter (`rvcsi-adapter-*`). **[I]**
|
||||
2. **The moat moves up the stack.** When every router can sense, raw CSI access stops being differentiating. Differentiators become: multistatic fusion, coherence gating / anti-hallucination, calibration mechanisms, witness-grade verification, and privacy auditing — exactly RuView's existing bets (ADR-029/135/150/028, BFLD). **[I]**
|
||||
3. **Privacy pressure intensifies.** 802.11bf standardizes the capability that BFId/LeakyBeam exploit. BFLD's identity-leak detection and the ADR-105–109 privacy/PQC chain become regulatory assets, not nice-to-haves. **[V]+[I]**
|
||||
4. **Threshold-based reporting** in bf (report only when channel changes exceed threshold) is architecturally the same idea as RuView's coherence gate — validation that the gate belongs at the protocol layer. **[K]** (bf reporting detail from training knowledge)
|
||||
|
||||
---
|
||||
|
||||
## 3. RF foundation model landscape ("GPT for RF")
|
||||
|
||||
Verified 2025–2026 attempts, all young, none dominant:
|
||||
|
||||
| Model | Approach | Downstream tasks | Tag |
|
||||
|-------|----------|------------------|-----|
|
||||
| **LWM (Large Wireless Model)** | Pretrained on large-scale CSI → general channel embeddings | LoS/NLoS, beats raw features in low-data regimes | **[V]** |
|
||||
| **LatentWave** (arXiv 2606.06373) | JEPA pretraining on wireless spectrograms + CSI | RF classification, 5G NR positioning, beam prediction, LoS/NLoS | **[V]** |
|
||||
| **WirelessJEPA** (arXiv 2601.20190) | Multi-antenna spatio-temporal latent prediction | Cross-task transfer | **[V]** |
|
||||
| **IQFM** | Contrastive SSL on raw I/Q | Modulation classification, beam prediction, RF fingerprinting, few-shot | **[V]** |
|
||||
| **Multimodal Wireless FMs** (arXiv 2511.15162), **WMFM** (arXiv 2512.23897), **SoM** (arXiv 2506.07647) | Vision + RF multimodal for 6G ISAC | Sensing-communication integration | **[V]** |
|
||||
| **DeepSig OmniSIG** | Commercial AI-native RF sensing, 500 MHz/GPU spectrum | Signal ID (LTE/5G/Wi-Fi) | **[V]** |
|
||||
|
||||
**Critical observation**: every verified RF foundation model targets *communication-side* tasks (beam prediction, LoS/NLoS, modulation, positioning). **None of them is a human-sensing foundation model** — none pretrains for pose/vitals/identity invariances. ADR-150's measured negative result is the sharpest data point in this space: pose-contrastive pretraining across subjects **failed on MM-Fi because the invariance is not in the data** (loss never left the ln(B) floor) **[I]**. The literature has not yet published this failure mode; the field's "GPT for RF sensing" narrative is ahead of its evidence. The defensible foundation-model objective (per ADR-150 §3.5–3.6) is **reduce few-shot calibration cost**, not zero-shot invariance. **[I]**
|
||||
|
||||
---
|
||||
|
||||
## 4. "Beyond SOTA" for RuView — precise definition
|
||||
|
||||
Targets below are **bar definitions**, not claims. RuView numbers in the "current" column are measured [I]; targets must be proven via the AetherArena witness protocol (ADR-149) before being asserted anywhere.
|
||||
|
||||
| Capability | Published SOTA (2026) | RuView measured today | RuView beyond-SOTA target | Key obstacle |
|
||||
|------------|----------------------|----------------------|---------------------------|--------------|
|
||||
| Pose, in-domain (MM-Fi) | GraphPose-Fi 160.6 mm MPJPE; MultiFormer 72.25% torso-PCK@20 **[V]** | **81.63% torso-PCK@20** (already > published) **[I]** | Hold #1 under leakage-free audit + per-joint tables published with witness rows | Protocol fragmentation; reviewers distrust WiFi-pose numbers |
|
||||
| Pose, cross-subject zero-shot | ~collapse everywhere; PerceptAlign PCK@20 44.2 cross-layout **[V]** | 11.6% torso zero-shot; 63–65% in-harness official split **[I]** | Stop chasing it (measured dead end); instead **few-shot frontier** below | Subject-distribution shift is in the data, not the model (ADR-150 §3.2) |
|
||||
| Pose, deployment calibration | **No published per-room adapter mechanism found** | **11 KB LoRA, 100–200 frames → 72.5%; cross-env K=5 → 60.1%** **[I]** | ≤20 frames → ≥70% PCK@20, adapter ≤11 KB, 30 s on-site; publish as the first calibration-service benchmark | Needs diverse-room capture fleet to validate beyond MM-Fi |
|
||||
| Presence/motion (commodity) | 92.6% across 10 M routers **[V]** | Synthetic placement recipe 100% coverage N=5 **[I]** | ≥99% presence with calibrated p95 bounds on $6–15 ESP32 mesh, bench-validated | All placement numbers are synthetic; Tier-2.3 bench validation outstanding |
|
||||
| Person count | ~90–97% lab, ≤5 people **[K]** | cog ships uncertainty intervals **[I]** | Exact count 1–6 people ≥95% with honest intervals, multistatic, real bench | Multi-person CSI superposition; no public multi-occupancy benchmark |
|
||||
| Vital signs HR | 0.8–1.2 bpm median, single subject, LoS, 1–3 m **[V]** | No in-repo ECG-validated number — **must not be claimed** | ≤1.5 bpm MAE vs ECG ground truth, *multi-person or through-wall*, witness-bundled | R13 physics floor: ~5 dB shortfall at distance; needs chest-centric placement + PABS |
|
||||
| Vital signs BP | NEGATIVE at WiFi band (matches internal R13) | nvsim quantum path only **[I]** | First validated quantum-classical fused bedside vitals (ADR-114) | NV-diamond hardware maturity, 2028+ |
|
||||
| Localization | 0.63 m single-link AI; sub-1 m 802.11az **[V]** | CRLB machinery, no bench number **[I]** | ≤30 cm multistatic on ESP32 mesh (internal theory says feasible at N=16) | ESP32 clock sync / phase offset (TDM protocol exists, unproven at this accuracy) |
|
||||
| Through-wall | Occupancy yes (LeakyBeam); commodity pose: nothing credible **[V]** | tomography + CIR code, PABS 9.36× lift (synthetic) **[I]** | First witnessed commodity-WiFi through-wall *person localization* (not pose) ≤1 m | Wall attenuation eats the R6.1 4.7 dB multi-scatterer budget |
|
||||
| Identity / re-ID | ~99.5% @ 197 subjects (attack) **[V]** | AETHER + **BFLD defensive auditing** (no published competitor) **[I]** | Ship the first identity-leak risk score with DP budget hook; keep re-ID opt-in only | Calibrating risk score at 802.11ax 4/2-bit quantization (BFLD open Q2) |
|
||||
| Verification | **Nothing comparable published** — no WiFi-sensing paper ships deterministic re-verification | ADR-028 witness bundles, SHA-256 proof, 7/7 self-verify, 1,031+ tests **[I]** | Make witness-grade reproduction the *expected* standard: every public claim = one-command verification | Community adoption, not technology |
|
||||
| Foundation encoder | Comms-task FMs only (LWM/JEPA family) **[V]** | Masked-CSI + coherence head planned; pose-contrastive refuted **[I]** | First *sensing* FM whose acceptance metric is calibration-sample reduction (frames-to-72% halved) | SSL must match production CSI pipeline (ADR-149 resampling risk) |
|
||||
|
||||
---
|
||||
|
||||
## 5. Where RuView already matches/exceeds published work
|
||||
|
||||
1. **In-domain MM-Fi pose** — 81.63% torso-PCK@20 vs MultiFormer 72.25%, metric- and protocol-matched (issue #876). **[I]**
|
||||
2. **Deployment-calibration mechanism** — the 11 KB LoRA per-room adapter with measured frames-to-accuracy curves (§3.4–3.6 of ADR-150) has no published equivalent; the literature is still arguing about zero-shot generalization that ADR-150 measured to be a data property.
|
||||
3. **Deterministic witness verification** — ADR-028's SHA-256 pipeline proof + self-verifying bundles exceeds the reproducibility practice of every WiFi-sensing paper surveyed (none ship deterministic re-verification).
|
||||
4. **Multistatic cost point** — $6–15/node ESP32 mesh with TDM sync, channel hopping, placement recipes (ADR-113) vs literature setups using Intel 5300/AX210 laptops or USRPs; ~$30/bed vs $3,000 clinical monitor framing (R16).
|
||||
5. **Defensive identity auditing (BFLD)** — the field publishes attacks (BFId, LeakyBeam, WhoFi); RuView is building the only detector/auditor, plus a PQC-hardened federation privacy chain (ADR-105–109) with no published counterpart.
|
||||
6. **Anti-hallucination coherence gating** — confidence gated by RF integrity (ADR-135, ADR-150 §2.4); WiFi-pose papers uniformly lack a "the model knows when the channel is bad" signal.
|
||||
7. **Negative-result discipline** — physics floors (R13 BP, R6.1 4.7 dB), refuted pose-contrastive pretraining — published SOTA papers do not report these, which inflates the apparent literature bar.
|
||||
|
||||
## 6. Where RuView lags
|
||||
|
||||
1. **Bench validation** — nearly all multistatic/placement/tomography numbers are synthetic-physics; the 92.6%-on-10M-routers deployment **[V]** is real-world evidence at a scale RuView cannot approach.
|
||||
2. **Vital-sign ground truth** — no in-repo ECG/respiration-belt validated HR/BR error; published work has 0.8 bpm median **[V]**. This is the most urgent claim gap.
|
||||
3. **Raw geometric accuracy** — mmWave (27 mm MPJPE **[V]**) and even best-WiFi MPJPE (160.6 mm **[V]**) have no RuView MPJPE counterpart published; AetherArena reports PCK only.
|
||||
4. **802.11bf-native capture** — RuView is on vendor CSI APIs and Nexmon patches; no bf sensing-procedure adapter exists yet in rvCSI.
|
||||
5. **Multi-person pose** — Person-in-WiFi-3D does end-to-end multi-person at 54 FPS **[V]**; RuView's pose path is effectively single-person (multi-person exists only in count/placement work).
|
||||
6. **Dataset scale and diversity** — MM-Fi only; ADR-150 §3.3 shows the binding constraint is room/device/protocol diversity, which requires the capture fleet that doesn't exist yet.
|
||||
|
||||
## 7. Strategic synthesis
|
||||
|
||||
The 2026 bar is bimodal: **lab in-domain numbers are saturated** (PCK@50 > 95%, HR < 1 bpm) and **deployment numbers are collapsed** (cross-layout PCK@20 ≈ 44, zero-shot cross-subject ≈ 11%). 802.11bf-2025 commoditizes raw sensing; foundation models commoditize comms-side embeddings. "Beyond SOTA" for RuView is therefore *not* a leaderboard delta — it is owning the three layers the field hasn't built: **(a)** witnessed, deterministic, leakage-audited evaluation; **(b)** the few-shot calibration service (11 KB adapters) as the deployment answer the zero-shot literature lacks; **(c)** the privacy/integrity layer (BFLD + coherence gate) that 802.11bf-era regulation will demand. Each row in §4's target table is gated on the AetherArena witness protocol — a target becomes a claim only when it ships with a one-command reproduction.
|
||||
|
||||
---
|
||||
|
||||
## 8. Verified sources (accessed 2026-06-09 via web search)
|
||||
|
||||
Pose: [GraphPose-Fi](https://arxiv.org/html/2511.19105v1) · [PerceptAlign / cross-layout](https://arxiv.org/html/2601.12252) · [CSDS](https://www.mdpi.com/2079-9292/14/4/756) · [Person-in-WiFi 3D](https://aiotgroup.github.io/Person-in-WiFi-3D/) · [DensePose From WiFi](https://arxiv.org/abs/2301.00250) · [MetaFi++](https://www.researchgate.net/publication/369644995_MetaFi_WiFi-Enabled_Transformer-based_Human_Pose_Estimation_for_Metaverse_Avatar_Simulation) · [WiFlow](https://arxiv.org/html/2602.08661v2)
|
||||
Vitals: [PhaseBeat](https://dl.acm.org/doi/abs/10.1145/3377165) · [Non-contact HR (Sensors 24:2111)](https://www.mdpi.com/1424-8220/24/7/2111) · [PulseFi](https://arxiv.org/pdf/2510.24744) · [mmWave vitals dataset (Sci Data)](https://www.nature.com/articles/s41597-026-07172-9)
|
||||
Localization: [FTM survey 802.11mc/az/bk](https://arxiv.org/abs/2509.03901) · [Decimeter single-link](https://www.ncbi.nlm.nih.gov/pmc/articles/PMC12846125/) · [SelfLoc 802.11az](https://www.mdpi.com/2079-9292/14/13/2675)
|
||||
802.11bf: [IEEE SA 802.11bf-2025](https://standards.ieee.org/ieee/802.11bf/11574/) · [TGbf](https://www.ieee802.org/11/Reports/tgbf_update.htm) · [NIST overview](https://www.nist.gov/publications/ieee-80211bf-enabling-widespread-adoption-wi-fi-sensing) · [Wi-Fi Alliance work areas](https://www.wi-fi.org/current-work-areas) · [ISAC survey (10M-router 92.6%)](https://arxiv.org/pdf/2510.14358)
|
||||
Identity: [BFId / KIT CCS 2025 coverage](https://www.gblock.app/articles/wifi-signal-person-identification-surveillance-study-may-2026) · [WhoFi](https://arxiv.org/html/2507.12869v1) · [Wi-Gait](https://www.sciencedirect.com/science/article/abs/pii/S1389128623001962) · [LeakyBeam NDSS 2025](https://www.ndss-symposium.org/ndss-paper/lend-me-your-beam-privacy-implications-of-plaintext-beamforming-feedback-in-wifi/)
|
||||
Through-wall: [RTI through-wall](https://ieeexplore.ieee.org/document/6214374/) · [Commodity 2.4 GHz imaging](https://arxiv.org/pdf/1903.03895) · [Multi-room presence](https://arxiv.org/pdf/2304.13107)
|
||||
Foundation models: [LatentWave](https://arxiv.org/html/2606.06373) · [WirelessJEPA](https://arxiv.org/pdf/2601.20190) · [Multimodal Wireless FMs](https://arxiv.org/pdf/2511.15162) · [WMFM](https://arxiv.org/html/2512.23897) · [SoM](https://arxiv.org/pdf/2506.07647) · [RF-native AI / LWM, IQFM, OmniSIG](https://aicompetence.org/rf-native-ai-models-for-the-invisible-spectrum/)
|
||||
mmWave: [mmChainPose](https://www.sciencedirect.com/science/article/abs/pii/S0925231225026918) · [ProbRadarM3F](https://arxiv.org/html/2405.05164v3)
|
||||
|
||||
Internal [I] sources: ADR-150 (§1, §3.2–3.6), ADR-147, ADR-028, ADR-113/114, issue #876, `docs/research/sota-2026-05-22/00-summary.md`, `docs/research/BFLD/01-sota-survey.md`, `docs/research/rf-topological-sensing/`.
|
||||
@@ -0,0 +1,282 @@
|
||||
# RuView Beyond-SOTA Target Architecture
|
||||
|
||||
**Series:** ruview-beyond-sota (02)
|
||||
**Date:** 2026-06-09
|
||||
**Status:** Research design — components marked **PROPOSED** do not exist yet; everything else cites real code.
|
||||
**Governing constraint:** ADR-136 §2.1 explicitly rejects renaming/rewriting the workspace. This document designs an **evolution** of the existing 38-crate `v2/` workspace (`v2/Cargo.toml`), not a new system. Every beyond-SOTA layer attaches to the ADR-136 `Stage<I,O>` / `FrameMeta` / `CanonicalFrame` contracts (`docs/adr/ADR-136-ruview-streaming-engine-frame-contracts.md` §2.2–2.5) and preserves the ADR-028 witness chain.
|
||||
|
||||
---
|
||||
|
||||
## 1. Where the system is today (grounding)
|
||||
|
||||
The ADR-136 ten-role pipeline (ingest → signal → fusion → world → models → privacy → store → api → eval → observe) is already mapped 1:1 onto existing crates (ADR-136 §2.1, normative table). The composition root exists: `v2/crates/wifi-densepose-engine/src/lib.rs` wires ADR-135..146 blocks into one `StreamingEngine::process_cycle` that emits a `TrustedOutput` carrying fusion `QualityScore`, privacy class, `SemanticProvenance`, RF-SLAM (`RfSlam` field), and a BLAKE3 `witness: [u8; 32]`.
|
||||
|
||||
Key existing substrate this design builds on:
|
||||
|
||||
| Substrate | Path | What it gives us |
|
||||
|---|---|---|
|
||||
| Frame contracts + witness | `v2/crates/wifi-densepose-core/src/types.rs` (`CsiFrame`, `CsiMetadata` + `calibration_id`/`model_id`/`model_version`), ADR-136 `ComplexSample`/`CanonicalFrame` | Deterministic LE bytes, BLAKE3 witness, provenance-append-only boundary rule |
|
||||
| Six-stage signal pipeline | `v2/crates/wifi-densepose-signal/src/ruvsense/mod.rs` (+22 modules incl. `cir.rs`, `calibration.rs`, `tomography.rs`, `rf_slam.rs`, `fusion_quality.rs`, `array_coordinator.rs`) | CSI→CIR, baseline calibration, multistatic fusion, coherence gating |
|
||||
| Fusion quality + evidence | ADR-137; `ruvsense/multistatic.rs`, `ruvsense/fusion_quality.rs`, `wifi-densepose-ruvector/src/viewpoint/fusion.rs` | `QualityScore` with `EvidenceRef`/`ContradictionFlag`, privacy demotion on contradiction |
|
||||
| Digital twin | `v2/crates/wifi-densepose-worldgraph/src/lib.rs` (typed `StableDiGraph`, mandatory `SemanticProvenance`) | Persistent room/sensor/track/belief graph |
|
||||
| World model bridge | `v2/crates/wifi-densepose-worldmodel/src/lib.rs` (`OccWorldBridge`, `TrajectoryPrior`, ADR-147) | Occupancy prediction priors into the Kalman tracker |
|
||||
| NN + training | `v2/crates/wifi-densepose-train/src/{model.rs,rapid_adapt.rs,ablation.rs,proof.rs,eval.rs,ruview_metrics.rs}`, `wifi-densepose-nn` | Shared backbone + 2 heads, `AdaptationLoss::ContrastiveTTT`, ADR-145 ablation matrix, seeded proof harness |
|
||||
| Swarm | `v2/crates/ruview-swarm/src/` (`sensing/{multiview.rs,payload.rs,occworld_bridge.rs}`, `marl/`, `topology.rs`) | Raft/hierarchical-mesh drone coordination with CSI payload (ADR-148) |
|
||||
| Edge WASM | `v2/crates/wifi-densepose-wasm-edge/src/lib.rs` (WASM3 on ESP32-S3, `on_frame` host ABI), `wifi-densepose-wasm` | Hot-loadable on-device sensing modules |
|
||||
| Quantum-adjacent sim | `v2/crates/nvsim/src/lib.rs` (deterministic NV-magnetometry forward pipeline, SHA-256 witness, WASM-ready) | Honest classical-quantum hybrid substrate (ADR-089) |
|
||||
| Semantic record + agents | ADR-140 (`wifi-densepose-sensing-server/src/semantic/`), `homecore-assist` | Provenance-bearing semantic states, Ruflo agent bridge |
|
||||
|
||||
---
|
||||
|
||||
## 2. Target architecture diagram
|
||||
|
||||
The beyond-SOTA layers (★ = new/PROPOSED, ☆ = exists-but-not-wired) wrap the ADR-136 pipeline; nothing replaces it.
|
||||
|
||||
```
|
||||
╔═══════════════════ BEYOND-SOTA CONTROL PLANE ═══════════════════╗
|
||||
║ P6 Continual adaptation loop (TTT + EWC★) P5 Swarm aperture ║
|
||||
║ rapid_adapt.rs → encoder LoRA deltas planner★ (Raft) ║
|
||||
╚════════════▲══════════════════════▲══════════════▲══════════════╝
|
||||
│ adaptation deltas │ quality │ tasking
|
||||
[ingest] [signal] │ [fusion] │ [world] │ [models]
|
||||
ESP32/Pi mesh ─► RuvSensePipeline ──────┴──► fuse_scored ──────┴─► WorldGraph ┴──► RF Foundation
|
||||
+ drone payload multiband→phase_align (ADR-137 (ADR-139 │ Encoder (P1)
|
||||
(ruview-swarm →calibration(135) QualityScore, twin) ◄───────┘ 7 heads + UQ
|
||||
sensing/payload) →cir(134)→multistatic EvidenceRef, ▲ │ (ADR-146/150)
|
||||
│ →coherence→gate Contradiction) │ ▼ │
|
||||
│ │ │ RF-SLAM(143)──OccWorld │
|
||||
▼ ▼ │ rf_slam.rs worldmodel ▼
|
||||
P7 WASM edge P2 Differentiable RF │ (P3 closed loop ☆) P4 cross-modal
|
||||
inference★ forward model★ │ distilled student★
|
||||
(wasm-edge, (tomography.rs + │ (camera-free deploy)
|
||||
deterministic cir.rs ISTA as seed) │
|
||||
replay) │ residuals feed fusion as EvidenceRef★
|
||||
│ ▼
|
||||
│ P8 NV-magnetometry fusion★ (nvsim forward model as a sensing node class)
|
||||
▼
|
||||
─────────────────────── ADR-136 CONTRACT SPINE (unchanged) ───────────────────────────────────
|
||||
CsiFrame{ComplexSample, FrameMeta{calibration_id, model_id, model_version}} → Stage<I,O>
|
||||
→ CanonicalFrame::witness_hash() at EVERY stage boundary (BLAKE3, LE-deterministic)
|
||||
───────────────────────────────────────────────────────────────────────────────────────────────
|
||||
│ │ │ │
|
||||
[privacy] [store] [api] [eval] [observe]
|
||||
wifi-densepose-bfld homecore-recorder homecore-api ADR-145 ablation homecore-
|
||||
gate + demotion + replay corpus★ /HA/Matter/HAP (train/ablation.rs automation,
|
||||
(ADR-141) + P1-P8 variants) Ruflo (ADR-140)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. The eight pillars
|
||||
|
||||
Each pillar: what / why beyond-SOTA / builds-on / contract sketch / feasibility. All trait sketches are **PROPOSED** unless a path is cited.
|
||||
|
||||
### P1 — RF Foundation Encoder with multitask uncertainty heads (ADR-146 + ADR-150)
|
||||
|
||||
**What.** One shared, self-supervised RF encoder (`wifi-densepose-nn`) with seven typed heads (pose, presence, count, activity, vitals, gait, identity-embedding), each emitting calibrated uncertainty via the ADR-136 `QualityScored` trait, trained with the ADR-150 pose-contrastive objective (same-pose-across-subjects = positive) plus a coherence head that exposes channel instability.
|
||||
|
||||
**Why beyond SOTA.** Published WiFi-pose systems (MultiFormer, GraphPose-Fi lineage) report in-domain accuracy and hallucinate under domain shift. ADR-150 documents the real measured frontier: 81.63% torso-PCK@20 in-domain on MM-Fi vs ~11.6% leakage-free cross-subject, and that DANN and bigger capacity both failed (ADR-150 §1). A foundation encoder whose loss stack explicitly separates pose / identity / room / device factors *and* emits an RF-integrity signal per prediction is not in the published literature as a deployed, auditable artifact. Target (not a claim): close the cross-subject gap materially while every head output carries `confidence_bounds()`.
|
||||
|
||||
**Builds on.** `v2/crates/wifi-densepose-train/src/model.rs` (`WiFiDensePoseModel`, `kp_head`/`dp_head`); `v2/crates/wifi-densepose-sensing-server/src/embedding.rs` (`ProjectionHead` + LoRA + `info_nce_loss` — the existing seventh head, ADR-146 §1.1); `v2/crates/wifi-densepose-train/src/rapid_adapt.rs` (ContrastiveTTT precedent); ADR-146 §1.4 head fan-out; ADR-150 §2 loss stack.
|
||||
|
||||
**Contract sketch** (lands in `wifi-densepose-nn`, per ADR-146 §1.3):
|
||||
```rust
|
||||
pub trait RfEncoder: Send + Sync {
|
||||
fn encode(&self, window: &CsiWindowTensor) -> Embedding; // z ∈ R^d_model
|
||||
fn model_id(&self) -> u16; // FrameMeta binding (ADR-136 §2.2)
|
||||
}
|
||||
pub trait TaskHead<O: QualityScored>: Send + Sync {
|
||||
fn name(&self) -> &'static str;
|
||||
fn forward(&self, z: &Embedding) -> O; // value + uncertainty bounds
|
||||
}
|
||||
pub struct MultiTaskOutput { /* per-head QualityScored outputs + coherence: f32 */ }
|
||||
```
|
||||
|
||||
**Feasibility: HIGH for the architecture, MEDIUM for the headline result.** The pure-Rust f32 ABI is proven (`embedding.rs`), the head taxonomy is specified (ADR-146), and the ablation harness to measure it exists (`wifi-densepose-train/src/ablation.rs`). The risk is scientific, not engineering: ADR-150's own data shows naive approaches fail; the pose-contrastive objective is plausible but unproven at scale. Mitigation: ADR-150 §3's frozen-decoder three-variant experiment gates promotion.
|
||||
|
||||
### P2 — Physics-informed differentiable RF forward model (PROPOSED)
|
||||
|
||||
**What.** A differentiable forward model `render(scene, link_geometry) -> predicted CSI/CIR` used three ways: (1) as a regularizer in encoder training (predictions must be consistent with a Born-approximation scattering model), (2) as an analysis-by-synthesis residual at inference (`|observed − rendered|` becomes an ADR-137 `EvidenceRef`), (3) as a synthetic-data generator complementing MM-Fi (ADR-015).
|
||||
|
||||
**Why beyond SOTA.** Published WiFi sensing is almost entirely discriminative; physics-informed neural fields exist for vision (NeRF) and acoustics but no deployed RF-human-sensing stack closes the loop *forward model → residual → fusion evidence → privacy decision*. Making physics disagreement a first-class, witnessed contradiction flag is novel system design, not just a model.
|
||||
|
||||
**Builds on.** The codebase already contains the seed of the forward model: `v2/crates/wifi-densepose-signal/src/ruvsense/tomography.rs` (`RfTomographer`, `LinkGeometry`, `OccupancyVolume` — a linear shadowing forward model inverted by ISTA), `ruvsense/cir.rs` (sub-DFT sensing matrix Φ, ISTA L1 — ADR-134), ADR-143 §1.3 (bistatic excess-delay geometry, the exact ray equations), and `nvsim` as the in-repo precedent for a *deterministic, witness-hashed forward physics pipeline* (`v2/crates/nvsim/src/{propagation.rs,pipeline.rs,proof.rs}`).
|
||||
|
||||
**Contract sketch** (new module `wifi-densepose-signal/src/ruvsense/forward_model.rs`, PROPOSED):
|
||||
```rust
|
||||
pub trait RfForwardModel: Versioned {
|
||||
/// Predict per-link CSI given a voxel scene + body primitive set.
|
||||
fn render(&self, scene: &OccupancyVolume, links: &[LinkGeometry]) -> Vec<PredictedCsi>;
|
||||
/// Physics residual in [0,1]; 0 = perfectly Maxwell/Born-consistent.
|
||||
fn residual(&self, observed: &CsiFrame, rendered: &PredictedCsi) -> PhysicsResidual; // → EvidenceRef
|
||||
}
|
||||
```
|
||||
|
||||
**Feasibility: MEDIUM, with one honest line drawn.** A full Maxwell FDTD-in-the-loop solver is **infeasible** at 20 Hz on this hardware and is a non-goal (§6). What is feasible: a first-order Born / ray-tracing bistatic model (the ADR-143 spheroid geometry generalized), differentiable through finite differences or a small Candle graph, validated against recorded calibration captures (ADR-135 baselines give per-link empty-room ground truth for free). "Maxwell-consistent" should be read as "consistent with a stated first-order approximation, with the approximation order recorded in the witness metadata."
|
||||
|
||||
### P3 — RF-SLAM × WorldGraph × OccWorld closed loop (exists in parts, wiring is the work)
|
||||
|
||||
**What.** Close the loop: RF-SLAM discovers reflectors/anchors → WorldGraph persists them as `object_anchor` nodes → OccWorld consumes graph occupancy → `TrajectoryPrior`s feed the Kalman tracker → improved tracks refine SLAM association. The environment model becomes self-acquiring and self-correcting (furniture moved ⇒ `BaselineTopologyChange` ⇒ recalibration trigger, ADR-143 §1.4).
|
||||
|
||||
**Why beyond SOTA.** Published RF-SLAM work maps *or* tracks; no published consumer system maintains a persistent, provenance-bearing, privacy-rolled-up environmental digital twin (`PrivacyRollup` in `wifi-densepose-worldgraph/src/graph.rs`) that is simultaneously the SLAM map, the automation substrate, and the audit record. The differentiator is the closed loop with evidence edges (`supports`/`contradicts`).
|
||||
|
||||
**Builds on.** All three vertices exist: `v2/crates/wifi-densepose-signal/src/ruvsense/rf_slam.rs` (`RfSlam::observe`, line 176, already a field of `StreamingEngine` — `wifi-densepose-engine/src/lib.rs:116`); `v2/crates/wifi-densepose-worldgraph/src/lib.rs`; `v2/crates/wifi-densepose-worldmodel/src/{bridge.rs,occupancy.rs}` (`worldgraph_to_occupancy`, `OccWorldBridge::predict`). The engine already upserts SLAM output and person tracks into the graph. Missing: prior-injection back into `ruvsense/pose_tracker.rs`, and the topology-change → ADR-135 recalibration edge.
|
||||
|
||||
**Contract sketch** (extends existing types):
|
||||
```rust
|
||||
impl StreamingEngine {
|
||||
/// PROPOSED: inject OccWorld priors into the next tracker cycle.
|
||||
pub fn apply_trajectory_priors(&mut self, priors: &[TrajectoryPrior]) -> Vec<WorldId>;
|
||||
}
|
||||
// WorldEdge gains (PROPOSED): PredictedBy { model_id: u16 } — prior provenance edge
|
||||
```
|
||||
|
||||
**Feasibility: HIGH.** This is mostly integration glue between tested crates. The two real risks are already named by ADR-143: no ground-truth oracle in a live home (mitigated by the v1-fixed / v2-flagged rollout, `#[cfg(feature = "rf-slam-v2")]`), and OccWorld's Python subprocess (ADR-147: 375 ms/inference) being off the deterministic path — priors must be treated as advisory, never witness-bearing (§5).
|
||||
|
||||
### P4 — Cross-modal distillation: camera-teacher → RF-student, privacy-preserving deployment (PROPOSED)
|
||||
|
||||
**What.** Train-time-only camera supervision: a vision pose teacher labels synchronized CSI (MM-Fi already provides paired modalities, ADR-015), distilling dense pose + uncertainty into the P1 encoder. Deployed systems ship **no camera and no camera-derived identity features**; the ADR-145 privacy-leakage metric (membership-inference score in `wifi-densepose-train/src/ablation.rs`) gates that the student does not retain identity.
|
||||
|
||||
**Why beyond SOTA.** Camera-supervised WiFi pose is the original DensePose-WiFi recipe; what is *not* published is distillation with a measured, CI-enforced privacy-leakage budget and a witnessed claim that the deployed artifact is camera-free. The beyond-SOTA move is making "privacy-preserving" a *measured property of the release pipeline*, not a marketing adjective.
|
||||
|
||||
**Builds on.** `v2/crates/wifi-densepose-train/src/{trainer.rs,losses.rs,dataset.rs}` (training substrate); ADR-015 paired datasets; ADR-145 `FeatureSet` matrix + privacy-leakage scalar; `v2/crates/wifi-densepose-bfld` (`privacy_gate.rs`, `signature_hasher.rs` — runtime identity controls, ADR-120 invariants I1–I3).
|
||||
|
||||
**Contract sketch** (in `wifi-densepose-train`, PROPOSED):
|
||||
```rust
|
||||
pub struct DistillationLoss { pub teacher: TeacherSource, pub temperature: f32, pub uq_transfer: bool }
|
||||
pub enum TeacherSource { CachedPoseLabels(PathBuf), /* never a live camera in the serving graph */ }
|
||||
/// Release gate: leakage(student) ≤ budget, asserted by the ADR-145 harness per variant.
|
||||
pub struct PrivacyBudget { pub max_mia_score: f32 }
|
||||
```
|
||||
|
||||
**Feasibility: HIGH.** All ingredients exist; the work is a loss term, a label cache format, and a CI gate. The honest caveat: MIA-based leakage scores are a lower bound on real leakage; the budget is a regression tripwire, not a formal guarantee.
|
||||
|
||||
### P5 — Swarm-distributed multistatic sensing with Raft-coordinated apertures (ADR-148, partially built)
|
||||
|
||||
**What.** Treat the drone swarm + fixed ESP32 mesh as one *reconfigurable multistatic aperture*: a Raft-elected cluster head plans node positions/channel assignments to maximize geometric diversity (GDI) for the current sensing task; per-node frames flow into the same `MultistaticFuser` path as fixed nodes.
|
||||
|
||||
**Why beyond SOTA.** Published multistatic WiFi sensing assumes fixed geometry. Closed-loop aperture optimization — moving the sensors to where the Fisher information is — driven by the GDI/Cramér–Rao machinery that already exists in `v2/crates/wifi-densepose-ruvector/src/viewpoint/geometry.rs` (per CLAUDE.md module table: `GeometricDiversityIndex`, Cramér-Rao bounds) is a genuinely new system class for SAR/MAT scenarios.
|
||||
|
||||
**Builds on.** `v2/crates/ruview-swarm/src/sensing/{multiview.rs,payload.rs,occworld_bridge.rs}`, `topology.rs`, `planning.rs`, `marl/` (MAPPO, `candle_ppo.rs`); `ruvsense/multistatic.rs` + `array_coordinator.rs` (ADR-138 clock-quality gating — moving nodes will stress exactly this); `wifi-densepose-mat` (the MAT use case).
|
||||
|
||||
**Contract sketch** (in `ruview-swarm`, PROPOSED):
|
||||
```rust
|
||||
pub trait AperturePlanner: Send + Sync {
|
||||
/// Given current twin + task, propose node placements maximizing expected GDI.
|
||||
fn plan(&self, twin: &WorldGraphSnapshot, task: &SwarmTask) -> Vec<(NodeId, Position3D)>;
|
||||
}
|
||||
// Output flows through Raft (topology.rs) as a normal SwarmTask; frames return as ArrayNodeInput.
|
||||
```
|
||||
|
||||
**Feasibility: MEDIUM.** Coordination, MARL, and fusion code exist and are tested; the hard physical problems are honest unknowns: airborne CSI phase stability (rotor vibration), clock sync across mobile nodes (ADR-138 gate will reject a lot initially), and ADR-148 §1.3's own regulatory scoping. Simulation-first via `ruview-swarm/src/evals.rs` + `bench_support.rs`; hardware validation is Phase 3.
|
||||
|
||||
### P6 — Continual / test-time adaptation with EWC-style forgetting control (PROPOSED on existing TTT)
|
||||
|
||||
**What.** Promote `rapid_adapt.rs` from a per-deployment trick to a managed continual-learning loop: TTT/entropy adaptation produces LoRA deltas on the P1 encoder; an EWC (elastic weight consolidation) penalty — **which does not exist in the workspace today** (no EWC match in `wifi-densepose-train/src/rapid_adapt.rs`) — anchors weights important to previously-validated environments; every adaptation step is versioned as a new `model_version` (u16, ADR-136 §2.2) and must re-pass the ADR-145 acceptance matrix before activation.
|
||||
|
||||
**Why beyond SOTA.** TTT papers adapt and hope; nothing published couples adaptation to a *deterministic regression gate with witness hashes*, where an adapted model that regresses tier or leaks identity is automatically rejected and the `model_version` provenance lets any semantic state be traced to the exact adaptation step.
|
||||
|
||||
**Builds on.** `v2/crates/wifi-densepose-train/src/rapid_adapt.rs` (`AdaptationLoss::ContrastiveTTT`, entropy-minimization variant — lines 8–16); LoRA adapters in `sensing-server/src/embedding.rs` (rank-4 `lora_1`/`lora_2`); ADR-027 MERIDIAN evaluator (`train/src/eval.rs`); ADR-146 §2 calibration-robustness loss.
|
||||
|
||||
**Contract sketch** (in `wifi-densepose-train`, PROPOSED):
|
||||
```rust
|
||||
pub struct EwcPenalty { pub fisher_diag: Vec<f32>, pub anchor: Vec<f32>, pub lambda: f32 }
|
||||
pub struct AdaptationStep {
|
||||
pub parent_model_version: u16, pub new_model_version: u16,
|
||||
pub loss: AdaptationLoss, pub ewc: Option<EwcPenalty>,
|
||||
pub acceptance: RuViewAcceptanceResult, // must be ≥ parent tier
|
||||
pub witness: [u8; 32], // hash of delta + acceptance
|
||||
}
|
||||
```
|
||||
|
||||
**Feasibility: HIGH.** EWC over a small LoRA delta is cheap (Fisher diagonal over the replay corpus); the acceptance gate and proof seeds exist (`proof.rs`, `PROOF_SEED = 42`). Risk: online Fisher estimation from unlabeled home data is noisy — start with adaptation restricted to LoRA parameters only, backbone frozen.
|
||||
|
||||
### P7 — On-device WASM edge inference with deterministic replay (extends existing Tier-3)
|
||||
|
||||
**What.** Push P1 head subsets (presence, vitals, coarse activity) into hot-loadable WASM modules on ESP32-S3, and onto browsers/workers via `wifi-densepose-wasm`. Every edge module's output is replayable: the same `CanonicalFrame` input bytes through the same module hash produce the same output bytes, verified in CI on x86_64/aarch64/wasm32.
|
||||
|
||||
**Why beyond SOTA.** Edge WiFi-sensing exists; *bit-deterministic, witness-hashed edge inference with hot-swap and replay parity against the server pipeline* does not appear in published systems. It turns the edge from a trust hole into a witness-chain extension.
|
||||
|
||||
**Builds on.** `v2/crates/wifi-densepose-wasm-edge/src/lib.rs` (WASM3 host ABI: `csi_get_*`, `on_frame` at ~20 Hz, ADR-040 Tier 3); `nvsim` as the proof that a no-std-time, no-OS-entropy, seeded-PRNG crate runs identically on wasm32 (`nvsim/src/lib.rs` doc); ADR-136 AC7 cross-architecture byte-stability test.
|
||||
|
||||
**Contract sketch** (PROPOSED additions to the wasm-edge host ABI):
|
||||
```rust
|
||||
// exports added to module lifecycle:
|
||||
// on_replay_begin(seed: u64) — pins any module-internal PRNG
|
||||
// witness_digest(buf_ptr: i32) -> i32 — module returns BLAKE3 of its output stream
|
||||
pub trait EdgeStage: Stage<CsiFrameView, EdgeEvent> { fn module_hash(&self) -> [u8; 32]; }
|
||||
```
|
||||
|
||||
**Feasibility: HIGH for presence/vitals heads, LOW for full pose on-ESP32.** WASM3 interpretation on Xtensa caps throughput; full 7-head inference stays on Pi/Hailo/browser. Float determinism across native vs WASM needs care (no fast-math, fixed reduction order — same obligation ADR-136 §3.2 already accepts).
|
||||
|
||||
### P8 — NV-magnetometry fusion: an honest classical-quantum hybrid (PROPOSED, simulation-first)
|
||||
|
||||
**What.** Add `nvsim`-modeled NV-magnetometer nodes as a *fourth sensing modality class* (after CSI, mmWave/ADR-021, BFLD) in the multistatic fusion: near-range (≤ tens of cm, per the physics review) cardiac/respiratory magnetic signatures fused with CSI/mmWave vitals under the ADR-137 evidence contract. Simulation-first: the modality lands end-to-end against `nvsim` before any hardware exists.
|
||||
|
||||
**Why beyond SOTA.** Not range — the Ghost Murmur review (`docs/research/quantum-sensing/16-ghost-murmur-ruview-spec.md`) documents why multi-mile cardiac magnetometry contradicts published physics, and this design adopts that conclusion. The beyond-SOTA element is architectural honesty: a fusion engine that can ingest a quantum-sensor modality with explicit, witnessed physics bounds (`nvsim`'s forward model states its approximations and hashes its output, `nvsim/src/proof.rs`), so that when real NV hardware matures, the integration path and the anti-hype guardrails already exist. No published consumer sensing stack has this.
|
||||
|
||||
**Builds on.** `v2/crates/nvsim/src/` (scene→source→attenuation→NV ensemble→digitiser, SHA-256 witness, ADR-089); `nvsim-server`; `wifi-densepose-vitals` (mmWave HR/BR — the modality NV would cross-validate); `ruvsense/multistatic.rs` fusion + ADR-137 `EvidenceRef`.
|
||||
|
||||
**Contract sketch** (PROPOSED): a `SensorModality::NvMagnetometer` variant on the existing `wifi-densepose-worldgraph` `SensorModality` enum, plus an `ArrayNodeInput` adapter from `nvsim` frames; vitals agreement/disagreement between NV and mmWave becomes an `EvidenceRef`/`ContradictionFlag` pair.
|
||||
|
||||
**Feasibility: HIGH in simulation, SPECULATIVE on hardware.** The sim path is days of glue; COTS NV magnetometers with the required sensitivity at consumer cost do not exist in 2026. This pillar's deliverable is the *contract and the simulated validation*, explicitly labeled as such.
|
||||
|
||||
---
|
||||
|
||||
## 4. Phased implementation plan
|
||||
|
||||
Phases are gated by the Pre-Merge Checklist (CLAUDE.md) and the witness chain (§5). Crate names per the ADR-136 §2.1 normative map — no new `ruview_*` crates except where a crate already exists (`ruview-swarm`).
|
||||
|
||||
**Phase 0 — Hardening (close the ADR-136 "integration glue" debt).**
|
||||
- `wifi-densepose-signal`: wire the full 600-frame `Stage`-chain replay (ADR-136 AC6) and register `streaming_engine_replay_v1` in `archive/v1/data/proof/expected_features.sha256`.
|
||||
- CI: cross-architecture witness matrix x86_64/aarch64 (AC7); add wasm32 lane for `nvsim` + `wifi-densepose-wasm`.
|
||||
- `wifi-densepose-engine`: populate `FrameMeta.calibration_id`/`model_id` from the live calibration and model-binding stages (currently defaulted — ADR-136 §8).
|
||||
- `homecore-recorder`: define the **replay corpus** format (canonical-bytes frame streams + witness manifest) that P4/P6 training and all ablations consume.
|
||||
|
||||
**Phase 1 — Encoder + measurement (P1, P4 groundwork, P6 skeleton).**
|
||||
- `wifi-densepose-nn`: `RfEncoder`/`TaskHead` traits, seven-head fan-out, UQ layer (ADR-146); relocate `ProjectionHead` from `sensing-server/src/embedding.rs`.
|
||||
- `wifi-densepose-train`: `ContrastiveBatcher`, ADR-150 loss stack, distillation loss + cached-teacher format (P4), `EwcPenalty` + `AdaptationStep` (P6); extend `ablation.rs` `FeatureSet` with per-head and per-pillar variants; pin `expected_ablation_*.sha256`.
|
||||
- Run the ADR-150 three-variant frozen-decoder experiment; promotion gate on cross-subject delta.
|
||||
|
||||
**Phase 2 — Closed loop + edge (P3, P7).**
|
||||
- `wifi-densepose-engine`: `apply_trajectory_priors` (OccWorld → `pose_tracker.rs`); `PredictedBy` provenance edge in `wifi-densepose-worldgraph`; topology-change → ADR-135 recalibration trigger.
|
||||
- `wifi-densepose-wasm-edge`: replay ABI (`on_replay_begin`, `witness_digest`), presence/vitals head modules; parity test vs server pipeline on identical canonical bytes.
|
||||
- Enable `rf-slam-v2` feature on the 7-day validation dataset (ADR-143 gate).
|
||||
|
||||
**Phase 3 — Frontier (P2, P5, P8).**
|
||||
- `wifi-densepose-signal/src/ruvsense/forward_model.rs`: Born/ray forward model seeded from `tomography.rs`; `PhysicsResidual` → `EvidenceRef`; synthetic-data generator into `train/src/dataset.rs`.
|
||||
- `ruview-swarm`: `AperturePlanner` over GDI (`ruvector/src/viewpoint/geometry.rs`); simulation evals in `evals.rs`; airborne CSI stability study before any hardware claim.
|
||||
- `nvsim` ↔ `wifi-densepose-engine`: `SensorModality::NvMagnetometer` adapter, simulated NV+mmWave vitals cross-validation in the ablation matrix.
|
||||
|
||||
---
|
||||
|
||||
## 5. Determinism & witness-chain preservation
|
||||
|
||||
The non-negotiable invariant (ADR-136 §2.5–2.6, ADR-028): replaying recorded canonical bytes through the pipeline twice yields byte-identical outputs and equal BLAKE3 witness hashes. Strategy per component class:
|
||||
|
||||
1. **Everything on the trust path implements `CanonicalFrame`.** New frame types (`MultiTaskOutput`, `PhysicsResidual`, `AdaptationStep`, edge events, NV frames) get fixed-field-order LE encodings and `witness_hash()`; encoders are the only serializers (no ad-hoc serde on the witness path).
|
||||
2. **Inference is witnessed by (input hash, model hash, output hash).** `model_id`/`model_version` on `FrameMeta` already bind frames to models; P1 adds a weights digest so the triple is closed. Pure-Rust f32 inference (ADR-146 ABI) with fixed reduction order; no GPU nondeterminism on the witness path — GPU/libtorch is training-only, and training determinism is pinned by the existing seeds (`proof.rs`: `PROOF_SEED = 42`, `MODEL_SEED = 0`).
|
||||
3. **Advisory vs witnessed split.** Components that cannot be made deterministic — the OccWorld Python subprocess (ADR-147), live MARL exploration, any future LLM/agent output (ADR-140 Ruflo) — are **advisory**: their outputs may bias estimates but never enter `to_canonical_bytes()` directly; instead the *decision to use them* is recorded (prior id + content hash) so replay reproduces the decision even if the producer cannot be re-run. The Kalman tracker consumes priors as explicit inputs recorded in the replay corpus.
|
||||
4. **Adaptation is a chain of witnessed steps.** P6's `AdaptationStep.witness` hashes (parent version ‖ delta ‖ acceptance result); the active model at any timestamp is reconstructible from the step chain — the model-weights analogue of the frame witness chain.
|
||||
5. **Edge parity.** P7 modules must produce the same `witness_digest` as the server-side reference implementation on the AC6 fixture; the module hash joins the firmware `source-hashes.txt` in the ADR-028 witness bundle.
|
||||
6. **Witness bundle growth is mechanical.** Each pillar adds expected-hash keys (`forward_model_residual_v1`, `edge_presence_replay_v1`, `nvsim` already ships `proof.rs`) to the existing `verify.py` chain rather than inventing new verification mechanisms.
|
||||
|
||||
---
|
||||
|
||||
## 6. Explicit non-goals
|
||||
|
||||
- **No workspace rename or rewrite.** Reaffirms ADR-136 §2.1/§4.1: no `ruview_*` crate prefix migration, no umbrella crate; pillars land inside the existing crates listed above.
|
||||
- **No full-wave Maxwell solver in the runtime loop.** P2 is first-order Born/ray, with the approximation order declared. "Physics-informed" never means FDTD at 20 Hz.
|
||||
- **No long-range cardiac magnetometry claims.** P8 is bounded by the physics review in `docs/research/quantum-sensing/16-ghost-murmur-ruview-spec.md`; ranges beyond published MCG physics are out of scope permanently, not just deferred.
|
||||
- **No camera in any deployed serving graph** (P4 teachers are train-time, cached-label only) and **no identity recognition as a product feature** — identity embeddings remain in-RAM, hash-rotated (ADR-120 invariants).
|
||||
- **No weaponization or LAWS capability in P5**, per ADR-148 §1.3; swarm work targets SAR/MAT and stays behind the ADR-148 regulatory gates.
|
||||
- **No fabricated benchmarks.** All pillar performance statements in this document are targets; promotion of any pillar requires the ADR-145 ablation matrix delta plus pinned determinism hashes, in CI, before any external claim.
|
||||
- **No new verification mechanisms.** The witness chain extends `verify.py` / BLAKE3 / `expected_*.sha256`; we do not introduce a second, parallel proof system.
|
||||
|
||||
---
|
||||
|
||||
## 7. Open questions for the next document in this series
|
||||
|
||||
1. Airborne CSI phase stability (P5): what does the ADR-138 clock-quality gate measure on a real quadrotor payload?
|
||||
2. Forward-model fidelity floor (P2): what Born-residual magnitude on the ADR-135 empty-room captures is "good enough" to be a useful contradiction signal?
|
||||
3. Replay-corpus governance (Phase 0): retention, privacy class of recorded canonical bytes, and consent — the recorder stores signal evidence, which is itself sensitive.
|
||||
@@ -0,0 +1,384 @@
|
||||
# Beyond-SOTA Validation, Test & Benchmark Methodology
|
||||
|
||||
**Series:** `docs/research/ruview-beyond-sota/` · Document 03
|
||||
**Date:** 2026-06-09
|
||||
**Scope:** How RuView proves (and gates) beyond-SOTA claims using the verification
|
||||
infrastructure that already exists in this repository. Every number below is sourced
|
||||
from a cited file in this repo; nothing is invented.
|
||||
|
||||
---
|
||||
|
||||
## 1. The Layered Validation Pyramid
|
||||
|
||||
Six layers, cheapest/most-deterministic at the bottom, most expensive/most-credible at
|
||||
the top. A beyond-SOTA claim must survive **every layer below it** before it may be
|
||||
published from the layer it lives at.
|
||||
|
||||
| Layer | What it proves | Tooling | Frequency | Determinism |
|
||||
|-------|----------------|---------|-----------|-------------|
|
||||
| **L0** Unit/integration tests | Code correctness | `cargo test --workspace --no-default-features` + pytest | per commit | exact |
|
||||
| **L1** Deterministic proof + witness bundle | Pipeline is real, unchanged, reproducible | `archive/v1/data/proof/verify.py`, `scripts/generate-witness-bundle.sh` | per merge / release | exact (SHA-256) |
|
||||
| **L2** Criterion micro-benchmarks | Compute latency only — never quality (ADR-149 §2) | 15 bench targets across `v2/crates/*/benches/` | nightly / pre-release | statistical |
|
||||
| **L3** Dataset-level accuracy eval | Pose/presence/vitals quality vs published SOTA | MM-Fi / Wi-Pose (ADR-015), `ruview_metrics.rs` tiers, ADR-145 ablation harness | per model release | seeded |
|
||||
| **L4** Hardware-in-loop | Real CSI on real ESP32, no mocks | COM9 (S3) / COM12 (C6) protocol, witness firmware hashes | per firmware release | A/B controlled |
|
||||
| **L5** Field trials / live capture | End-to-end behavior in a real room | live-session captures (e.g. `benchmark_baseline.json`) | campaign | statistical |
|
||||
|
||||
### 1.1 L0 — Workspace tests (current counts)
|
||||
|
||||
- ADR-028 audit (2026-03-01): **1,031 passed, 0 failed, 8 ignored** for
|
||||
`cargo test --workspace --no-default-features`
|
||||
(`docs/adr/ADR-028-esp32-capability-audit.md` §2).
|
||||
- Current `CHANGELOG.md` (Unreleased, cross-platform fix entry): **2,682 workspace
|
||||
tests pass / 0 fail on Windows** — the suite has more than doubled since the audit.
|
||||
- `CLAUDE.md` pre-merge gate still cites "1,031+ passed, 0 failed" as the floor.
|
||||
|
||||
**Rule:** the post-change test count may never be lower than the pre-change count, and
|
||||
failures must be 0. The witness bundle records the full log
|
||||
(`test-results/rust-workspace-tests.log`) and an aggregated `summary.txt`
|
||||
(`scripts/generate-witness-bundle.sh` step 3).
|
||||
|
||||
### 1.2 L1 — Deterministic proof ("Trust Kill Switch") + witness bundle
|
||||
|
||||
`archive/v1/data/proof/verify.py` (header comment): feeds 1,000 synthetic CSI frames
|
||||
(seed=42, `sample_csi_data.json`) through the **production** `CSIProcessor`
|
||||
(`src/core/csi_processor.py`), hashes the first 100 frames' feature output
|
||||
(`VERIFICATION_FRAME_COUNT = 100`), and compares against
|
||||
`archive/v1/data/proof/expected_features.sha256`.
|
||||
|
||||
- **Current published hash (file contents, verified during this investigation):**
|
||||
`f8e76f21a0f9852b70b6d9dd5318239f6b20cbcb4cdd995863263cecdc446f7a`
|
||||
- The hash is **environment-coupled** and has been legitimately regenerated before:
|
||||
ADR-028 §5.3 recorded `8c0680d7…` under numpy 2.4.2/scipy 1.17.1; `CHANGELOG.md`
|
||||
(#560 fix) recorded `667eb054…` after 6-decimal quantization + single-thread BLAS
|
||||
pinning (`OMP_NUM_THREADS=1` etc.). Each regeneration must follow the documented
|
||||
procedure: `python verify.py --generate-hash` then `python verify.py` → `VERDICT: PASS`.
|
||||
|
||||
`scripts/generate-witness-bundle.sh` packages: witness log + ADR-028, the Python proof
|
||||
(verify.py + expected hash + reference-signal metadata), full Rust test log + summary,
|
||||
the ADR-134 CIR proof, firmware source/binary SHA-256s, crate version manifest, npm
|
||||
tarball SHA-256, and a recipient-side `VERIFY.sh`.
|
||||
|
||||
**Accuracy note on check counts:** `CLAUDE.md` describes the recipient verification as
|
||||
"7/7 PASS"; the current `VERIFY.sh` embedded in the script performs **10** `check()`
|
||||
assertions (witness log, ADR, proof-hash file, tests, firmware hashes, crate manifest,
|
||||
npm manifest, Python proof, CIR proof, CIR hash file) but prints a hardcoded
|
||||
`"ALL CHECKS PASSED (8/8)"` string (`generate-witness-bundle.sh` line 293). The
|
||||
hardcoded count is stale relative to the actual check list — fix it to print
|
||||
`${PASS_COUNT}/${PASS_COUNT+FAIL_COUNT}` so the verdict can never silently desynchronize
|
||||
from the check inventory.
|
||||
|
||||
### 1.3 L2 — Criterion micro-benchmark inventory (all 15 targets)
|
||||
|
||||
All bench sources read directly. Per ADR-149 §2 these are **latency regression gates
|
||||
only, never quality evidence**.
|
||||
|
||||
| Bench target | Crate | Benchmark functions / groups | What it measures | Recorded value or in-source target (citation) |
|
||||
|---|---|---|---|---|
|
||||
| `engine_cycle.rs` | wifi-densepose-engine | `process_cycle_4nodes_56sc` | One full `StreamingEngine::process_cycle` (fuse + quality + calibration provenance + privacy gate + WorldGraph node), 4-node/56-subcarrier ESP32-S3 HT20 mesh | Budget: **50 ms** (20 Hz) — bench header |
|
||||
| `signal_bench.rs` | wifi-densepose-signal | `CSI Preprocessing`, `Phase Sanitization`, `Feature Extraction`, `Motion Detection`, `Full Pipeline` | SOTA signal stages (ADR-014) at varying frame sizes | no recorded baseline |
|
||||
| `cir_bench.rs` | wifi-densepose-signal | `cir_estimate` (HT20/HT40/HE20/HE40), `cir_estimate_12link`, `cir_estimator_new` | ADR-134 `CirEstimator::estimate()` per tier; 12-link multistatic amortization; cold-start | no recorded baseline |
|
||||
| `calibration_bench.rs` | wifi-densepose-signal | `bench_recorder_record`, `bench_recorder_finalize`, `bench_deviation`, `bench_record_600`, `bench_to_bytes` (K=52/114/242/484) | ADR-135 empty-room baseline recorder + deviation scoring | no recorded baseline |
|
||||
| `aether_prefilter_bench.rs` | wifi-densepose-signal | `aether_search_d…_n…_k…` (search vs prefilter) | ADR-084 Pass-2: `EmbeddingHistory::search_prefilter` vs brute force, prefilter_factor=8 | Pass: **≥4× at n=1024** — bench header |
|
||||
| `sketch_bench.rs` | wifi-densepose-ruvector | `compare_d128/256/512` × `float_l2`/`float_cosine`/`sketch_hamming` | ADR-084 sketch-vs-float per-pair compare cost (AETHER 128-d, spectrogram 256-d) | Pass: **sketch ≥8× faster** at every dim (ADR-084 threshold 8×–30×) — bench header |
|
||||
| `crv_bench.rs` | wifi-densepose-ruvector | `gestalt_classify_single/batch_100`, `sensory_encode_single`, `pipeline_full_session`, `convergence_two_sessions`, `crv_session_create`, `crv_embedding_dimension_scaling` (32/128/384), `crv_stage_vi_partition` | CRV integration throughput | no recorded baseline |
|
||||
| `inference_bench.rs` | wifi-densepose-nn | `tensor_ops` (relu/sigmoid/tanh), `densepose_inference`, `translator_inference`, `mock_inference`, `batch_inference` | NN forward-pass cost by input/batch size | no recorded baseline; **`mock_inference` group must never be quoted as a pipeline number** (§6) |
|
||||
| `training_bench.rs` | wifi-densepose-train | `interp_114_to_56_batch32`, `interp_scaling`, `compute_interp_weights_114_56`, `synthetic_dataset_get`, `synthetic_epoch`, `config_validate`, PCK over 100 samples | Training preprocessing + metrics hot paths; fixtures fully deterministic (no `rand`) — header | no recorded baseline |
|
||||
| `detection_bench.rs` | wifi-densepose-mat | `breathing_detection`, `heartbeat_detection`, `movement_classification`, `detection_pipeline`, localization (triangulation/depth), alert generation | MAT survivor-detection algorithms at varying signal lengths / noise | no recorded baseline |
|
||||
| `transport_bench.rs` | wifi-densepose-hardware | `beacon_serialize_16byte/28byte_auth/quic_framed`, `auth_beacon_verify`, `replay_window`, `framed_message` encode/decode, `secure_tdm_cycle` (manual vs QUIC) | TDM beacon crypto + transport | no recorded baseline |
|
||||
| `mqtt_throughput.rs` | wifi-densepose-sensing-server | `discovery::build_*`, `state::*`, `rate_limiter::allow_*`, `privacy::decide_*`, `semantic::bus_tick_all_10_primitives` | ADR-115 MQTT hot path | Targets (header): discovery **<5 µs**, state encode **<2 µs**, rate limit **<100 ns**, privacy **<50 ns**, bus tick **<10 µs** |
|
||||
| `swarm_bench.rs` | ruview-swarm | `marl_actor_inference`, `rrt_apf_100iter`, `multiview_fusion_3drones`, `demo_coverage_estimate`, `ppo_update_64transitions` | ADR-148 swarm control-loop compute | Measured: **3.3 µs / 43 µs / 54–58.5 ns / 100 ps / 248 µs** (ADR-149 §4.3; `CHANGELOG.md` Performance section) |
|
||||
| `pipeline_throughput.rs` | nvsim | `pipeline_run` (sample-count sweep), `witness::run` vs `run_with_witness` | NV-diamond sim throughput + witness overhead | Acceptance: **≥1 kHz** simulated samples/s on Cortex-A53-class CPU — bench header |
|
||||
| `state_machine.rs` | homecore | `set` first/warm/no-op, `get` hit/miss, `all_snapshot`, `all_by_domain_light_20_of_100`, `broadcast_fan_out` | HOMECORE state-machine hot paths | no recorded baseline |
|
||||
|
||||
**Honest gap — `benchmark_baseline.json` is not a criterion baseline.** The repo-root
|
||||
`benchmark_baseline.json` (369.9 KB) contains **1,566 live-capture samples** from a
|
||||
2-node session (fields: `tick`, `n_nodes`, `variance`, `motion`, `presence`,
|
||||
`confidence`, `est_persons`, `n_persons_rendered`, `kp_spread`, `rssi`) plus a summary
|
||||
block — it records **field-trial telemetry (L5)**, not micro-benchmark latencies.
|
||||
No file in the repo references it (`grep -rn benchmark_baseline` → 0 hits outside the
|
||||
file itself); its producer must be identified and committed (§5.3). Summary values
|
||||
(all from the file's `summary` object):
|
||||
|
||||
| Metric | Baseline value |
|
||||
|---|---:|
|
||||
| `total_frames` | 1,566 |
|
||||
| `presence_ratio` | 0.9336 (1,462/1,566 frames presence-true) |
|
||||
| `confidence_mean` | 0.6433 |
|
||||
| `variance_mean` / `variance_std` | 109.36 / 154.13 |
|
||||
| `kp_spread_mean` / `kp_spread_std` | 86.73 / 4.52 |
|
||||
| `person_count_changes` | 10 |
|
||||
|
||||
Criterion latencies that *have* been recorded live in ADR documents instead
|
||||
(ADR-147-benchmark-proof.md, ADR-149 §4.3, CHANGELOG Performance) — §5 below defines
|
||||
how to consolidate them into a real machine-readable criterion baseline.
|
||||
|
||||
### 1.4 L3 — Dataset-level accuracy evaluation
|
||||
|
||||
- **Datasets (ADR-015):** primary **MM-Fi** (40 subjects × 27 actions × ~320K frames,
|
||||
1TX×3RX, 114 subcarriers @100 Hz, 17-keypoint COCO + DensePose UV, CC BY-NC 4.0);
|
||||
secondary **Wi-Pose** (12 volunteers × 12 actions × 166,600 packets, 3×3, 30
|
||||
subcarriers). 114→56 subcarrier interpolation via `subcarrier.rs`; validation split =
|
||||
subjects 33–40 held out (ADR-015 Phase 1).
|
||||
- **Acceptance tiers:** `wifi-densepose-train/src/ruview_metrics.rs` —
|
||||
PCK@0.2 / OKS / MOTA / vitals rolled into `RuViewTier`
|
||||
(Fail/Bronze/Silver/Gold) (ADR-145 §1.1).
|
||||
- **Ablation harness (ADR-145):** 6-variant matrix (`csi_only`, `cir_only`,
|
||||
`csi_plus_cir`, `plus_doppler`, `plus_bfld`, `plus_uwb`-skipped), each variant
|
||||
producing acceptance tier + `SpecMetrics` (presence ≥0.90, localization ≤0.50 m,
|
||||
activity ≥0.70, FP ≤0.05, FN ≤0.10), `LatencyProfile` (p95 ≤100 ms), and
|
||||
`PrivacyLeakage` (MIA `leakage_score` ≤0.05), SHA-256-pinned per variant under
|
||||
`PROOF_SEED=42` (ADR-145 §2.2–2.6). Built at commit `0f336b7d3` (ADR-145
|
||||
implementation status); CLI auto-mode wiring is pending.
|
||||
- **Cross-environment:** ADR-027 MERIDIAN `CrossDomainEvaluator`
|
||||
(`wifi-densepose-train/src/eval.rs`) — `domain_gap_ratio`, extended by ADR-145
|
||||
`cross_room_degradation()` with a 17-joint PCK-delta heatmap.
|
||||
|
||||
### 1.5 L4 — Hardware-in-loop
|
||||
|
||||
- Real CSI nodes: ESP32-S3 on **COM9**, ESP32-C6 + MR60BHA2 on **COM12** (`CLAUDE.md`
|
||||
hardware table). ADR-018 binary frame protocol over UDP:5005 (ADR-028 §3.2/§3.4).
|
||||
- ADR-145 Tier-4 test (gated, `#[cfg(feature = "hardware-test")]`): replay a live 30 s
|
||||
COM9 capture through `csi_only` and `csi_plus_cir`; assert no presence regression and
|
||||
p95 < 100 ms.
|
||||
- A/B board protocol precedent (`CHANGELOG.md` #987): fixed vs unmodified control board
|
||||
against Apple-Watch ground truth (control pegged 40–49 BPM; fixed 88–91 vs 87 GT) —
|
||||
this fixed-board/control-board + external ground-truth pattern is the required design
|
||||
for all hardware vital-sign claims.
|
||||
- Witness bundle pins firmware: per-file SHA-256 of all sources + release binaries
|
||||
(`generate-witness-bundle.sh` step 5).
|
||||
|
||||
### 1.6 L5 — Field trials
|
||||
|
||||
Live multi-node sessions captured as JSONL/JSON with summary statistics —
|
||||
`benchmark_baseline.json` (§1.3) is the existing exemplar. ADR-149 §6 adds the seeded
|
||||
`evals/` episode harness (Stage 1 kinematic full-matrix, Stage 2 Gazebo/PX4 SITL on the
|
||||
3 median seeds) for the swarm domain.
|
||||
|
||||
---
|
||||
|
||||
## 2. Beyond-SOTA Acceptance Criteria per Capability Axis
|
||||
|
||||
A claim is "beyond SOTA" only with: a named external baseline, an exact metric and
|
||||
protocol match, the dataset/split named, the threshold pre-registered, and the
|
||||
statistical procedure of §3 followed. Current axes with measured status:
|
||||
|
||||
| Axis | Metric (exact) | Dataset / protocol | SOTA baseline | Beyond-SOTA threshold | Measured status (cited) |
|
||||
|---|---|---|---|---|---|
|
||||
| In-domain pose accuracy | torso-PCK@20: `‖pred−gt‖ ≤ 0.2·‖R-shoulder−L-hip‖` | MM-Fi `random_split` (ratio 0.8, seed 0) | MultiFormer **72.25%** (Table VII); CSI2Pose 68.41% | > 72.25% with 95% CI lower bound above it | Flagship **83.59%**; micro (75,237 params) **74.30%** (`docs/benchmarks/wifi-pose-efficiency-frontier.md`) |
|
||||
| Edge efficiency frontier | torso-PCK@20 at deployed precision + params + batch-1 latency | same | MultiFormer 72.25% at full size | Pareto-dominance: smaller **and** above 72.25% at the deployed precision | int8 73.5 KB **74.70%**; int4-QAT 36.7 KB **74.46%**; shipped int4 verified **74.08%**, 0.135 ms 1-thread x86 (same file) |
|
||||
| Cross-subject generalization | torso-PCK@20, official MM-Fi cross-subject split (256,608 train / 64,152 test) | leakage-free split | own zero-shot baseline 63.99% | ADR-150 §4 gate: **+≥6 pts cross-subject without losing >2 pts random-split** | Best zero-shot **64.92%** (mixup+TTA+3-seed); gate judged unreachable without new capture (ADR-150 §3.2) |
|
||||
| Few-shot calibration (deployment) | PCK@20 after K labeled in-room samples; adapter size | MM-Fi cross-subject & cross-environment splits | zero-shot (64% / 10.6%) | SOTA-level (≳72%) from ≤200 samples with ≤~11 KB per-room adapter | cross-subject ~**72%** @100–200 samples (3 seeds); cross-env **10.6→73.1%** @200, 60.1% @5 (ADR-150 §3.5–3.6) |
|
||||
| Swarm SAR localization | CEP50/CEP95 (m), GDOP-stratified | seeded episode distribution (ADR-149 §6), not single geometry | Wi2SAR **5 m** (arxiv 2604.09115, paper-to-paper) | CEP50 < 5 m, IQM over ≥10 seeds, 95% CI excluding 5 m | 1.732 m single synthetic geometry — graded **Low–Medium**, not yet claimable (ADR-149 §7) |
|
||||
| Swarm coverage | coverage-rate@240 s; time-to-95% | episode rollouts | Wi2SAR 160k m²/13.5 min | rollout (not analytic) mean+CI beating baseline | 223 s is an analytic estimate — graded **Low** (ADR-149 §7) |
|
||||
| Control-loop latency | criterion wall-clock | local hardware, named | 10 ms / 100 Hz budget | all stages ≪ budget | 3.3 µs MARL / 43 µs RRT-APF / 54 ns fusion / 248 µs PPO (ADR-149 §4.3) |
|
||||
| World-model trajectory | MDE (m) at 5-frame horizon | RuView CSI-derived occupancy | pre-fine-tune random-weight baseline 9.49 m MDE | **≤1.0 m (2.0 vox)** at 5-frame horizon (ADR-147 §5 target, cited in benchmark-proof §4) | 9.49 m / FDE 16.23 m random weights; 208.45 ms median latency on real CSI (ADR-147-benchmark-proof §4, §7) |
|
||||
| Privacy leakage | MIA `leakage_score = 2·(AUC−0.5)` | fixed replay, fixed-seed shadow classifier | chance (0) | ≤ **0.05** (attacker AUC ≤ 0.525) | gate defined, harness built (ADR-145 §2.3) |
|
||||
| Vitals (hardware) | BPM error vs wearable ground truth | live A/B board protocol | control board behavior | within physiological agreement of ground truth, stable spread | 88–91 BPM vs 87 GT, spread 59→0 (CHANGELOG #987) |
|
||||
|
||||
### Claim-language discipline (from ADR-149 §7 grading)
|
||||
|
||||
| Evidence | Permitted language |
|
||||
|---|---|
|
||||
| Single run / single geometry / analytic estimate | "directional", never "beats SOTA" |
|
||||
| Seeded multi-run with CIs vs paper baseline | "exceeds the published X result paper-to-paper" |
|
||||
| Same metric, same split, same protocol, CI excludes baseline | "beyond SOTA on <dataset>/<split>" |
|
||||
| No public leaderboard exists (swarm CSI-SAR) | never claim "leaderboard standing" (ADR-149 §3) |
|
||||
|
||||
---
|
||||
|
||||
## 3. Statistical Procedure for Honest Claims
|
||||
|
||||
Adopted from ADR-149 §5 (Agarwal 2021 / Gorsane 2022 standard) and the practices
|
||||
already used in ADR-150/efficiency-frontier measurements:
|
||||
|
||||
1. **Seeds.** ≥10 independent seeds for RL/episodic claims (ADR-149 §5); ≥3 seeds
|
||||
minimum for supervised dataset evals (ADR-150 §3.5 used 3 seeds; report all).
|
||||
Training seeds, eval seeds, and split files are versioned and committed.
|
||||
2. **Aggregate.** IQM (not mean/median) for episodic metrics + performance profiles;
|
||||
for dataset accuracy report mean across seeds with each seed's value listed.
|
||||
3. **Confidence intervals.** 95% stratified bootstrap, 1,000 resamples (ADR-149 §5;
|
||||
reference impl: `rliable`).
|
||||
4. **Paired comparisons.** When comparing model A vs B (e.g. `csi_plus_cir` vs
|
||||
`csi_only`, or ours vs a reproduced baseline), evaluate both on the **identical
|
||||
frozen test frames** and use a paired bootstrap over per-sample correctness
|
||||
(PCK hit/miss is per-joint binary — pair at the joint-sample level). For
|
||||
paper-to-paper comparisons where the baseline cannot be re-run, state so
|
||||
explicitly ("paper-to-paper", ADR-149 §2) and require the CI lower bound to clear
|
||||
the published point value.
|
||||
5. **Pre-registration.** The threshold lives in an ADR **before** the run
|
||||
(precedent: ADR-150 §4 gate written before §3.2 measurements; the measurements
|
||||
honestly reported the gate as not met).
|
||||
6. **Negative results are recorded.** ADR-150 §1/§3.2 keeps DANN-failed,
|
||||
capacity-hurts, and KD-didn't-help results in the record — required practice.
|
||||
7. **Eval episodes (swarm):** 50 fixed, versioned episodes per policy
|
||||
(10 victim layouts × 5 CSI-noise levels), ≥3 baselines (random walk,
|
||||
boustrophedon+triangulation, IPPO) (ADR-149 §5).
|
||||
8. **GDOP stratification** for any localization claim, so geometry artifacts cannot
|
||||
produce the headline (ADR-149 §6.3).
|
||||
|
||||
---
|
||||
|
||||
## 4. Regression-Gate Design (CI Enforcement)
|
||||
|
||||
### 4.1 Three gate classes, three tolerances
|
||||
|
||||
| Gate class | Source of truth | Tolerance | On breach |
|
||||
|---|---|---|---|
|
||||
| Determinism hashes | `expected_features.sha256`, `expected_cir_features.sha256`, `expected_calibration_features.sha256`, future `expected_ablation_<slug>.sha256` | **exact (0%)** | exit 1 = FAIL; exit 2 = SKIP only for placeholder hashes (proof.rs `0/1/2` convention, ADR-145 §2.4) |
|
||||
| Accuracy / quality metrics | per-variant canonical bytes, quantized 1e-3 (ADR-145 §2.6) | exact after quantization | FAIL CI; tier change requires ADR amendment |
|
||||
| Latency / throughput | criterion estimates JSON | **% tolerance per scale** (below) | FAIL on regression beyond tolerance; trend everything |
|
||||
|
||||
### 4.2 Criterion baseline file (replaces the current gap)
|
||||
|
||||
Today criterion numbers live in prose (ADR-147-benchmark-proof, ADR-149 §4.3,
|
||||
CHANGELOG). Formalize:
|
||||
|
||||
1. `cargo bench --workspace -- --save-baseline main` on a **named, fixed runner**
|
||||
(ADR-147 used RTX 5080 / specific host; record host + toolchain in the file).
|
||||
2. Export `target/criterion/*/estimates.json` point estimates into a committed
|
||||
`v2/benchmarks/criterion-baseline.json`: `{bench_id, crate, p50_ns, host, commit}`.
|
||||
3. CI compares new runs against it with scale-aware tolerance — wall-clock noise is
|
||||
proportionally larger at small magnitudes:
|
||||
|
||||
| Magnitude | Tolerance | Rationale |
|
||||
|---|---|---|
|
||||
| < 1 µs (e.g. fusion 54 ns, privacy decide <50 ns target) | ±25% | timer/jitter dominated |
|
||||
| 1 µs – 1 ms (MARL 3.3 µs, RRT-APF 43 µs, PPO 248 µs) | ±15% | criterion CI typically <5%, leave CI-runner headroom |
|
||||
| > 1 ms (engine cycle vs 50 ms budget, OccWorld ~209 ms) | ±10% **and** absolute budget (50 ms / 500 ms ADR-147 §6) | budgets are the contract |
|
||||
|
||||
4. Hard in-source acceptance thresholds remain authoritative regardless of baseline:
|
||||
sketch ≥8× (`sketch_bench.rs`), prefilter ≥4× (`aether_prefilter_bench.rs`),
|
||||
nvsim ≥1 kHz (`pipeline_throughput.rs`), MQTT header targets, ADR-145 p95 ≤100 ms.
|
||||
5. Latency stays **out of determinism hashes** (ADR-145 §2.6) but **in** the trended
|
||||
`summary.json`, so sub-threshold drift is visible (ADR-145 §3.2 mitigation).
|
||||
|
||||
### 4.3 Live-capture baseline gate (`benchmark_baseline.json`)
|
||||
|
||||
Adopt the file as the L5 regression anchor with documented provenance, then gate a
|
||||
re-capture of the same scenario (same 2-node placement, same room class) against the
|
||||
summary block:
|
||||
|
||||
| Field | Baseline | Suggested gate |
|
||||
|---|---:|---|
|
||||
| `presence_ratio` | 0.9336 | ≥ 0.90 for an occupied-room session |
|
||||
| `confidence_mean` | 0.6433 | within ±0.10 |
|
||||
| `kp_spread_std` | 4.52 | ≤ 2× baseline (skeleton stability) |
|
||||
| `person_count_changes` | 10 / 1,566 frames | ≤ 2× baseline (count flapping — see CHANGELOG #803/#894 clamp bugs this metric would have caught) |
|
||||
|
||||
Field-trial gates are **soft** (warn + require human sign-off), never auto-merge
|
||||
blockers — environments differ; the gate exists to force an explanation.
|
||||
|
||||
### 4.4 Wiring
|
||||
|
||||
Pre-merge (`CLAUDE.md` checklist): L0 + L1. Nightly: L2 criterion + ADR-145 Tier-3
|
||||
ablation matrix (minutes-scale, ADR-145 §3.2). Release: full witness bundle +
|
||||
`VERIFY.sh` + L4 on real COM-port hardware (`CLAUDE.md` firmware rule 6/7).
|
||||
|
||||
---
|
||||
|
||||
## 5. Reproducibility & External-Witness Requirements
|
||||
|
||||
Anyone outside the project must be able to re-run every claimed result:
|
||||
|
||||
1. **One command per layer.** `cargo test --workspace --no-default-features`;
|
||||
`python archive/v1/data/proof/verify.py`; `bash scripts/generate-witness-bundle.sh`
|
||||
then `bash VERIFY.sh` inside the bundle; per ADR-150 §4 every accuracy result needs
|
||||
"one-command reproduction" (efficiency frontier publishes its exact command:
|
||||
`python aether-arena/staging/train_efficiency_pareto.py npy/X.npy npy/Y.npy npy/split_random.npy`).
|
||||
2. **Pinned numerical environment.** The Python proof requires single-threaded BLAS
|
||||
(`OMP_NUM_THREADS=1`, `OPENBLAS_NUM_THREADS=1`, `MKL_NUM_THREADS=1`,
|
||||
`VECLIB_MAXIMUM_THREADS=1`, `NUMEXPR_NUM_THREADS=1`) and 6-decimal quantization
|
||||
(`HASH_QUANTIZATION_DECIMALS=6`) — the #560 fix in `CHANGELOG.md`; Rust proof
|
||||
runners use coarse u16 quantization at 1e-3 in natural order
|
||||
(`calibration_proof_runner.rs` pattern, ADR-145 §2.6) for libm portability.
|
||||
3. **Seeds are constants, committed:** `PROOF_SEED=42`, `MODEL_SEED=0`
|
||||
(`proof.rs`, ADR-015 Phase 5); dataset splits committed as `.npy`
|
||||
(`split_random.npy`); swarm configs as versioned YAML with all seeds (ADR-149 §5).
|
||||
4. **Artifacts carry hashes.** Published model artifacts include SHA-256 (HuggingFace
|
||||
`pose_micro_int4.npz`, sha256 `c03eeb…` — efficiency-frontier doc); witness bundle
|
||||
has a `MANIFEST.sha256` over every file; provenance fields
|
||||
(`replay_sha256`, `model_sha256`, `calibration_version`, `privacy_mode`) are bound
|
||||
into ablation proof hashes (ADR-145 §2.7) so a metric cannot be quoted without its
|
||||
exact model + calibration + privacy decision.
|
||||
5. **Hardware claims name the hardware.** ADR-147 records RTX 5080 / CUDA 12.8 /
|
||||
PyTorch 2.10.0; nvsim states the Cortex-A53 scaling caveat in the bench header;
|
||||
efficiency-frontier flags ARM validation as pending. Copy this discipline.
|
||||
6. **Witness rows.** Every new proof gains rows in `docs/WITNESS-LOG-028.md`
|
||||
(ADR-145 §5.3 adds W-39…W-41) and the bundle's `source-hashes.txt`.
|
||||
7. **Secret hygiene in evidence.** Bundle logs pass through
|
||||
`scripts/redact-secrets.py` (ADR-110 wave-5 incident note in
|
||||
`generate-witness-bundle.sh` step 4) — external evidence must never embed `.env`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Known Measurement Pitfalls (WiFi-sensing specific)
|
||||
|
||||
| # | Pitfall | Repo evidence | Mitigation in this methodology |
|
||||
|---|---|---|---|
|
||||
| 1 | **Subject leakage / split optimism.** In-domain `random_split` has temporal/subject-adjacency effects; the same model family scores 83.6% random-split but ~11.6% torso-PCK on the leakage-free cross-subject split | efficiency-frontier "Controlled claim" footnote; ADR-150 §1, §3.2 | Always report the split name; publish random-split and cross-subject numbers side by side; cross-subject claims only on the official split |
|
||||
| 2 | **Per-environment overfitting.** Zero-shot cross-environment collapses to 10.6%; subject-scaling saturates ~63.7% past 16–20 subjects because the residual is room/device shift | ADR-150 §3.3, §3.6 | Cross-room degradation + 17-joint heatmap in every ablation (ADR-145 §2.5); claim deployment accuracy only with the calibration protocol stated (K samples, adapter size) |
|
||||
| 3 | **Mock-mode contamination.** Mock firmware missed a real Kconfig threshold bug; the nn crate ships a `mock_inference` criterion group that must never be quoted as pipeline performance | `CLAUDE.md` firmware rule 7; `inference_bench.rs` `bench_mock_inference` | L4 mandatory before firmware release ("Always test with real WiFi CSI, not mock mode"); label mock benches in reports; ADR-147 §7 re-ran the benchmark on real CSI explicitly "no mocks" |
|
||||
| 4 | **Single-run point estimates.** 1.732 m localization from one synthetic geometry; 223 s coverage from an analytic formula | ADR-149 §1, §7 | §3 seed/CI protocol; evidence-grade table before publication |
|
||||
| 5 | **Random-weight / untrained baselines read as results.** OccWorld MDE 9.49 m is a pre-fine-tuning random-weight reading | ADR-147-benchmark-proof §4 | Label baseline-vs-target explicitly; never aggregate untrained-model numbers into capability claims |
|
||||
| 6 | **Latency conflated with quality.** Criterion µs numbers prove no compute bottleneck, nothing about accuracy | ADR-149 §2, §4.3 | L2 is gate-only; quality claims live in L3+ |
|
||||
| 7 | **Floating-point nondeterminism breaking proofs.** SciPy FFT SIMD reordering + multithreaded BLAS produced different hashes across CI microarchitectures | CHANGELOG #560; `calibration_proof_runner.rs` lines 1–13 (cited in ADR-145 §2.3) | Quantize before hashing; pin thread env vars; exclude wall-clock from hashes |
|
||||
| 8 | **Hash churn without procedure.** Three distinct historical values of the proof hash exist (`8c0680d7…` ADR-028, `667eb054…` CHANGELOG #560, `f8e76f21…` current file) | cited files | Every regeneration via `--generate-hash` + re-verify + CHANGELOG entry + witness bundle refresh |
|
||||
| 9 | **Aggregation bugs masking accuracy.** Person count clamped to 1 by EMA mapping; eigenvalue path leaking counts up to 10; both invisible to unit tests for months | CHANGELOG #803, #894 | L5 summary gates on `person_count_changes`/count distributions; convergence tests replaying the live loop |
|
||||
| 10 | **Stale verification claims.** `VERIFY.sh` prints hardcoded "(8/8)" over 10 actual checks; `CLAUDE.md` says "7/7" | `generate-witness-bundle.sh` line 293; `CLAUDE.md` | Compute the verdict count; audit doc claims against scripts each release |
|
||||
| 11 | **Licensing limits on the eval set.** MM-Fi is CC BY-NC — weights trained solely on it cannot back commercial claims | ADR-015 Consequences | Track dataset license alongside every published number |
|
||||
|
||||
---
|
||||
|
||||
## 7. Gap List (what must be built to fully execute this methodology)
|
||||
|
||||
| Gap | Owner layer | Source |
|
||||
|---|---|---|
|
||||
| Machine-readable criterion baseline (`v2/benchmarks/criterion-baseline.json`) + CI comparison job | L2 | §4.2 (numbers currently only in ADR prose) |
|
||||
| Provenance + producer script for `benchmark_baseline.json`; soft-gate job | L5 | §1.3, §4.3 (zero code references today) |
|
||||
| `ruview-cli --ablation mode=auto` wiring + `expected_ablation_<slug>.sha256` (currently placeholders → exit 2) | L3 | ADR-145 implementation status |
|
||||
| Seeded swarm `evals/` harness + `evals/RESULTS.md` internal leaderboard | L3/L5 | ADR-149 §6, §8 open issues |
|
||||
| Fix `VERIFY.sh` hardcoded verdict count; reconcile `CLAUDE.md` "7/7" | L1 | §1.2 |
|
||||
| Curated paired room-A/room-B labeled replay set (frozen, SHA-pinned, never trained on) | L3 | ADR-145 §3.2 |
|
||||
| ARM/edge on-device latency validation for the int4 model (x86-only today) | L4 | efficiency-frontier doc ("Pi fleet pending") |
|
||||
| Bench validation of the antenna-placement matrix on real hardware | L4 | PRODUCTION-ROADMAP.md Tier 2.3 |
|
||||
|
||||
---
|
||||
|
||||
## Update — falsifiable occupancy benchmark implemented
|
||||
|
||||
`wifi-densepose-train::occupancy_bench` (added this branch) makes the
|
||||
presence/person-count claim **falsifiable in code**, directly enforcing the L3
|
||||
discipline above. It grades predictions vs ground truth and gates a SOTA claim
|
||||
behind a single `claim_allowed` invariant that requires **all** of:
|
||||
|
||||
1. `DataProvenance::Measured` — synthetic/mock data is scorable for regression
|
||||
but **never claimable** (anti-mock-contamination; the CLAUDE.md Kconfig-bug
|
||||
lesson made structural).
|
||||
2. A leak-free `EvalSplit` — `validate()` refuses any split where a subject *or*
|
||||
environment id appears in both train and test (subject leakage / per-env
|
||||
overfitting).
|
||||
3. `n_test ≥ min_test_samples` (small-N guard).
|
||||
4. Presence F1 whose **bootstrap-CI lower bound** (deterministic splitmix64,
|
||||
seeded) clears the threshold — not the point estimate.
|
||||
5. Count MAE within threshold.
|
||||
|
||||
The claim string is unreadable except through the gate (returns `NO_CLAIM`
|
||||
otherwise) — same discipline as the `ruview-gamma` acceptance gate. 10 tests
|
||||
cover each refusal path. What remains is *data*, not *method*: feed it a frozen,
|
||||
SHA-pinned, subject/environment-disjoint **measured** replay set (the curated
|
||||
room-A/room-B item above) and the "beyond SOTA" claim becomes a passing or
|
||||
failing test, not a slogan.
|
||||
|
||||
---
|
||||
|
||||
*All values cited from: `benchmark_baseline.json`, `v2/crates/*/benches/*.rs` (15
|
||||
files), `docs/adr/ADR-147-benchmark-proof.md`,
|
||||
`docs/adr/ADR-149-swarm-benchmarking-evaluation-methodology.md`,
|
||||
`docs/adr/ADR-145-ablation-eval-harness-privacy-leakage.md`,
|
||||
`docs/adr/ADR-028-esp32-capability-audit.md`,
|
||||
`docs/adr/ADR-015-public-dataset-training-strategy.md`,
|
||||
`docs/adr/ADR-150-rf-foundation-encoder.md`,
|
||||
`docs/benchmarks/wifi-pose-efficiency-frontier.md`,
|
||||
`scripts/generate-witness-bundle.sh`, `archive/v1/data/proof/verify.py`,
|
||||
`archive/v1/data/proof/expected_features.sha256`, `CHANGELOG.md`, `CLAUDE.md`,
|
||||
`docs/research/sota-2026-05-22/PRODUCTION-ROADMAP.md`.*
|
||||
@@ -0,0 +1,252 @@
|
||||
# RuView Beyond-SOTA — 04: Performance Review & Optimization Roadmap
|
||||
|
||||
**Scope:** the streaming sensing pipeline (CSI ingest → multistatic fusion → CIR gate →
|
||||
pose publish) in `v2/`, hot-path crates `wifi-densepose-signal` (ruvsense),
|
||||
`wifi-densepose-engine`, `wifi-densepose-ruvector`, plus build-profile and edge-target
|
||||
(Pi 5-class, WASM) considerations.
|
||||
|
||||
**Hard constraint (non-negotiable):** the witness chain (ADR-028, ADR-136 §2.5 replay
|
||||
contract, ADR-137 §2.7 BLAKE3 witness in
|
||||
`v2/crates/wifi-densepose-engine/src/lib.rs:437-448`) requires **bit-exact deterministic
|
||||
float output**. Every recommendation below is tagged with its determinism risk. Anything
|
||||
that reorders float additions, enables FMA contraction, fast-math, or parallel reduction
|
||||
**changes the witness hash** and requires a coordinated proof-hash regeneration
|
||||
(`verify.py --generate-hash`) plus witness-bundle re-issue.
|
||||
|
||||
---
|
||||
|
||||
## 1. What we actually have measured (and what we don't)
|
||||
|
||||
`/home/user/RuView/benchmark_baseline.json` is a **signal-quality soak baseline**, not a
|
||||
latency benchmark: 1,566 samples (ticks 51131–52395) of
|
||||
`variance / motion / presence / confidence / est_persons / kp_spread / rssi`, with a
|
||||
summary block (`confidence_mean: 0.643`, `presence_ratio: 0.934`,
|
||||
`kp_spread_mean: 86.7`, `person_count_changes: 10`). **It contains zero timing data.**
|
||||
It is the accuracy guardrail for any optimization (post-change soak must reproduce these
|
||||
distributions), not a latency baseline.
|
||||
|
||||
Latency benchmarks exist but no committed results were found in the repo:
|
||||
|
||||
| Bench | File | What it measures |
|
||||
|---|---|---|
|
||||
| `process_cycle_4nodes_56sc` | `v2/crates/wifi-densepose-engine/benches/engine_cycle.rs:34-48` | One full engine cycle, 4 nodes × 56 subcarriers, vs. the documented 50 ms budget (`engine_cycle.rs:3-6`) |
|
||||
| `cir_bench` | `v2/crates/wifi-densepose-signal/benches/cir_bench.rs` | `CirEstimator::estimate()` per tier (HT20/HT40/HE20/HE40) + 12-link amortization |
|
||||
| `sketch_bench` | `v2/crates/wifi-densepose-ruvector/benches/sketch_bench.rs:86-175` | Hamming sketch vs. float L2/cosine compare; top-K over 1,024-sketch bank |
|
||||
| `signal_bench`, `calibration_bench`, `aether_prefilter_bench` | `v2/crates/wifi-densepose-signal/benches/` | Signal-path and ADR-135 calibration throughput |
|
||||
|
||||
**Action zero of the roadmap is to run these on a Pi 5 and commit the criterion
|
||||
baselines.** All impact classes below are derived from operation counts read out of the
|
||||
code (cited), not invented measurements.
|
||||
|
||||
---
|
||||
|
||||
## 2. Latency budget model — streaming pipeline
|
||||
|
||||
Two clock domains exist and must not be conflated:
|
||||
|
||||
- **TDMA sensing cycle: 20 Hz / 50 ms** — the architecture's own budget
|
||||
(`v2/crates/wifi-densepose-signal/src/ruvsense/mod.rs:5`, `RuvSenseConfig::target_hz =
|
||||
20.0` at `mod.rs:258`, and the bench doc `engine_cycle.rs:3`).
|
||||
- **CSI ingest: 100 Hz per node** — raw frames arrive ~5× faster than the fused output
|
||||
rate; per-frame ingest work (parse, normalize, calibrate, window) must therefore fit a
|
||||
**10 ms** per-frame envelope while the fused path fits **< 50 ms end-to-end**.
|
||||
|
||||
Proposed per-stage budget for the 50 ms end-to-end target (4 nodes, HT20 / 56
|
||||
subcarriers — the configuration the engine bench encodes):
|
||||
|
||||
| # | Stage | Code | Budget | Risk (from code reading) |
|
||||
|---|---|---|---|---|
|
||||
| 1 | Ingest + hardware normalize (per 100 Hz frame) | `hardware_norm`, `multiband.rs` | 2 ms | Low — vector ops on 56 floats |
|
||||
| 2 | Calibration apply (ADR-135) | `ruvsense/calibration.rs` | 2 ms | Low — Welford lookups |
|
||||
| 3 | Phase alignment | `phase_align.rs:117-152` | 1 ms | Low — ≤ 20 iterations over ≤ 17 static subcarriers (`config.max_iterations: 20`, `phase_align.rs:57`); allocation churn only (§3) |
|
||||
| 4 | Multistatic fusion (attention + softmax) | `multistatic.rs:512-598` | 2 ms | Low — O(nodes × 56); but does duplicate work in `fuse_scored` (§3, F2) |
|
||||
| 5 | **CIR gate (ISTA L1)** | `multistatic.rs:440-475` → `cir.rs:601-654` | 15 ms | **HIGH** — dominant cost, scales badly with PHY tier (below) |
|
||||
| 6 | Coherence score + gate decision | `coherence.rs`, `coherence_gate.rs` | 2 ms | Low — z-scores over 56 subcarriers |
|
||||
| 7 | Tomography (ADR-030 tier 2, when enabled) | `tomography.rs:236-323` | 8 ms | **Medium** — per-iteration allocation + loose step size (§3, F8/F9) |
|
||||
| 8 | Pose tracker (17-kp Kalman + re-ID) | `pose_tracker.rs` | 8 ms | Medium — sketch prefilter (ADR-084) already mitigates the re-ID scan |
|
||||
| 9 | Engine: quality score, privacy gate, WorldGraph node, BLAKE3 witness | `engine/src/lib.rs:304-368` | 5 ms | Low per cycle, but **unbounded memory growth** (§4) |
|
||||
| 10 | Publish (WS/serde) | sensing-server | 5 ms | Low |
|
||||
| | **Total** | | **50 ms** | |
|
||||
|
||||
### Why stage 5 is the at-risk stage — operation counts from the code
|
||||
|
||||
`ista_solve` (`cir.rs:601-654`) runs **two dense complex mat-vecs per iteration**
|
||||
(`matvec_phi` at `cir.rs:717-726`, `matvec_phi_h` at `cir.rs:730-745`), each O(K·G)
|
||||
complex MACs (≈ 8 FLOPs each), up to `max_iters: 100` (`cir.rs:176`). Per
|
||||
`CirConfig` (`cir.rs:164-233`):
|
||||
|
||||
| Tier | K (active) | G (taps) | FLOPs/iter (2·K·G·8) | FLOPs @100 iters |
|
||||
|---|---|---|---|---|
|
||||
| HT20 | 52 | 156 | ≈ 0.13 M | ≈ 13 M |
|
||||
| HT40 | 114 | 342 | ≈ 0.62 M | ≈ 62 M |
|
||||
| HE20 | 242 | 726 | ≈ 2.8 M | ≈ 0.28 G |
|
||||
| HE40 | 484 | 1,452 | ≈ 11.2 M | ≈ 1.1 G |
|
||||
|
||||
HT20 fits the 15 ms budget comfortably on a Pi 5; **HE40 at worst-case iteration count
|
||||
is ~1.1 GFLOP of scalar, cache-unfriendly work per estimate and will not fit any 50 ms
|
||||
budget without structural change** (F4 below). Today the gate runs once per cycle on the
|
||||
first link only (`multistatic.rs:452-463`), which contains the damage; the 12-link
|
||||
amortization pattern in `cir_bench.rs` shows the intended scale-up, which multiplies
|
||||
this cost ×12.
|
||||
|
||||
---
|
||||
|
||||
## 3. Findings table — optimization opportunities
|
||||
|
||||
Impact: relative cycle-time/memory effect at the 4-node HT20 operating point unless
|
||||
noted. Determinism: **EXACT** = bit-identical output guaranteed; **TIE** = only
|
||||
tie-breaking/ordering may differ; **CHANGES-FLOATS** = output bits change, witness/proof
|
||||
hash must be regenerated.
|
||||
|
||||
| ID | Finding (file:line) | Impact | Effort | Determinism |
|
||||
|---|---|---|---|---|
|
||||
| F1 | `FusedSensingFrame` deep-copies every input frame each cycle: `node_frames: node_frames.to_vec()` (`multistatic.rs:282`) — clones all per-node amplitude+phase vectors per 50 ms cycle even when downstream geometry consumers don't need them | Med | Low (Arc/Cow or borrow) | EXACT |
|
||||
| F2 | `fuse_scored` re-derives the per-node amplitude views and recomputes `node_attention_weights` after `fuse` already computed them inside `attention_weighted_fusion` (`multistatic.rs:311-321` duplicating `multistatic.rs:520`) — full cosine-sim + softmax done twice per cycle | Low-Med | Low (return weights from `fuse`) | EXACT (same math, computed once) |
|
||||
| F3 | CIR gate rebuilds a heap `CsiFrame` per cycle: `build_csi_frame_from_channel` allocates an `Array2<Complex64>` and converts amplitude/phase via `from_polar` per subcarrier (`multistatic.rs:488-506`, called from `multistatic.rs:462`), then `extract_csi_vector` converts back to `Complex32` (`cir.rs:505-530`) — f32→f64→f32 round-trip plus two allocations purely as glue | Med | Med (give `CirEstimator` a slice-based entry point) | EXACT if conversions reproduce exactly (f32→f64 is lossless; `from_polar` in f64 then truncate ≠ f32 polar — keep the f64 intermediate to stay exact, or accept CHANGES-FLOATS and regenerate hashes) |
|
||||
| F4 | ISTA inner loop uses dense O(K·G) mat-vecs (`cir.rs:717-745`) although Φ is a sub-sampled DFT (`cir.rs:539-558`) — the products Φx and Φᴴr are computable via an FFT of length G in O(G log G), an ~8–40× FLOP cut at HE20/HE40 (table §2) | **High** (the only path to HE40 real-time) | High | **CHANGES-FLOATS** (different summation order than the sequential dot product) — must ship behind a feature flag, A/B against `cir_proof_runner`, regenerate `expected_features.sha256` + witness bundle |
|
||||
| F5 | `neumann_warm_start` recomputes the diagonal of ΦᴴΦ with a full K×G pass **per frame** (`cir.rs:676-681`), rebuilds the COO→CSR diagonal matrix per frame (`cir.rs:683-685`), and collects `rhs_re`/`rhs_im` Vecs per frame (`cir.rs:689-690`) — yet `diag` depends only on Φ, which is fixed at `CirEstimator::new` | Med | Low (precompute diag+CSR in `new()`) | EXACT (same values, computed once) |
|
||||
| F6 | `phase_variance` collects a `Vec<f32>` of phases per call (`cir.rs:792`) — replaceable by a two-pass loop with zero allocation | Low | Low | EXACT |
|
||||
| F7 | Φ and Φᴴ are both stored densely (`cir.rs:546-547`): 2·K·G·8 bytes — Φᴴ entries are just conjugates of Φ (`cir.rs:555`), so a transposed-iteration kernel over Φ alone halves the footprint (HE40: 11.2 MB → 5.6 MB) | Low (latency) / Med (memory §4) | Med | EXACT (conjugation is exact; keep identical accumulation order in the transposed kernel) |
|
||||
| F8 | Tomography allocates the gradient vector **inside** the solver iteration loop: `let mut gradient = vec![0.0_f64; self.n_voxels]` (`tomography.rs:266`) — one heap alloc + zeroing per iteration, up to `max_iterations: 100` (`tomography.rs:75`); hoist and `fill(0.0)` | Med (for tier-2 deployments) | Low | EXACT |
|
||||
| F9 | Tomography step size uses the Frobenius-norm upper bound for the Lipschitz constant (`tomography.rs:253-259`, comment admits `‖WᵀW‖ ≤ ‖W‖_F²`) — a bound loose by up to the matrix rank, forcing proportionally more ISTA iterations than the power-method estimate used in `cir.rs:566-590` | Med | Low (reuse the cir.rs power-method pattern) | **CHANGES-FLOATS** (different step ⇒ different iterate path) |
|
||||
| F10 | `apply_phase_correction` clones the amplitude vector and allocates a fresh corrected-phase Vec per channel per cycle (`phase_align.rs:258-268`, `frame.amplitude.clone()` at `phase_align.rs:264`); `align` additionally `frames.to_vec()`s on the single-channel path (`phase_align.rs:128`) — an in-place `align_mut` avoids all of it | Low-Med | Low | EXACT |
|
||||
| F11 | Static-subcarrier selection fully sorts all subcarriers by variance (`phase_align.rs:180`) where `select_nth_unstable_by` suffices — trivial at 56 subcarriers, relevant at HE tiers (242–484) | Low | Low | **TIE** (equal-variance ties may select a different subcarrier set; pin a stable tie-break on index to stay EXACT) |
|
||||
| F12 | Engine clones each node's amplitude vector for the array coordinator every cycle: `cf.amplitude.clone()` (`engine/src/lib.rs:385`); also allocates a `Vec<Option<CalibrationId>>` per cycle (`lib.rs:293`) and `format!("{e:?}")` strings for every evidence ref (`lib.rs:337`) | Low | Low | EXACT |
|
||||
| F13 | `fuse_scored_calibrated` computes the modal calibration id in O(n²) (`multistatic.rs:404-410`) — harmless at n ≤ 15 nodes, noted for swarm-scale reuse (ADR-148) | Low | Low | EXACT |
|
||||
| F14 | **No `rayon` and no SIMD feature exists anywhere in the hot crates** (grep over `crates/*/Cargo.toml`: zero hits for rayon/simd/target-feature outside wasm-opt flags). The 12-link CIR pattern (`cir_bench.rs:4-5`) and the per-node ingest path are embarrassingly parallel **across independent links/nodes** | High (multi-link tiers) | Med | **EXACT if and only if** parallelism stays at link/node granularity with results collected in deterministic (index) order and no shared float accumulator; intra-link parallel reductions are CHANGES-FLOATS and are banned |
|
||||
| F15 | `Cir::top_k_taps` clones and fully sorts all G taps (`cir.rs:322-332`) — O(G log G) with a G-sized clone; a k-heap (the exact pattern already written in `sketch.rs:546-563`) is O(G log k) | Low | Low | TIE (equal-magnitude ordering; pin index tie-break) |
|
||||
| F16 | Core `CsiFrame` carries `Complex64` while the entire ruvsense DSP path computes in f32 (conversion at `cir.rs:525`) — 2× memory and bandwidth on every ingest for precision the pipeline immediately discards | Med (memory/bandwidth) | High (core type change ripples everywhere) | **CHANGES-FLOATS** at the boundary; defer until a major version |
|
||||
| F17 | Sketch path is already well-optimized: heap-based top-K with n ≤ k fast path (`sketch.rs:536-569`), 28-byte wire format (`sketch.rs:303`). Remaining win is build-level: `count_ones()` only lowers to POPCNT/NEON-vcnt when the target CPU enables it (see §5) | Low | Low | EXACT (integer ops) |
|
||||
|
||||
---
|
||||
|
||||
## 4. Memory-footprint analysis (Pi 5-class and WASM; ESP32 aggregation out of scope)
|
||||
|
||||
**Static, per-process (from struct definitions):**
|
||||
|
||||
| Component | Sizing source | Footprint |
|
||||
|---|---|---|
|
||||
| `CirEstimator` HT20 (Φ + Φᴴ, `Complex32`) | `cir.rs:546-547`, K=52 G=156 | 2 · 52 · 156 · 8 B ≈ **130 KB** |
|
||||
| `CirEstimator` HE20 | K=242 G=726 | ≈ **2.8 MB** |
|
||||
| `CirEstimator` HE40 | K=484 G=1452 | ≈ **11.2 MB** (halvable via F7) |
|
||||
| Tomography weight matrix | `tomography.rs:214-217`, sparse per-link (voxel,weight) pairs; default grid 8×8×4 = 256 voxels (`tomography.rs:70-73`) | tens of KB at default grid |
|
||||
| Sketch bank, 1,024 × 128-d | `sketch.rs` 1 bit/dim | 1,024 · 16 B ≈ **16 KB** (vs 512 KB float) |
|
||||
|
||||
A Pi 5 (4–8 GB) absorbs all of this trivially. The real memory risks are dynamic:
|
||||
|
||||
1. **Unbounded WorldGraph growth (the one genuine leak-class issue).** Every
|
||||
`process_cycle` appends a `SemanticState` node plus a `DerivedFrom` edge
|
||||
(`engine/src/lib.rs:346-352`), and change-points append `Event` nodes
|
||||
(`lib.rs:422-428`). At 20 Hz that is **1.73 M nodes/day** with no eviction anywhere
|
||||
in the engine. `snapshot_json` (`lib.rs:191-193`) then serializes the whole graph.
|
||||
**Required:** a retention/compaction policy (ring buffer or time-windowed rollup of
|
||||
SemanticStates). Determinism caveat: eviction changes snapshot *contents* (a product
|
||||
decision), not float math — the per-cycle witness (`lib.rs:437-448`) is unaffected.
|
||||
2. **Per-cycle allocation churn** (F1, F3, F5, F8, F10, F12): at 20 Hz this is dozens of
|
||||
short-lived heap allocations per cycle. On a Pi 5 this is allocator pressure and
|
||||
cache pollution rather than RSS growth; on WASM (bump-ish dlmalloc, no MADV_FREE) it
|
||||
inflates the linear memory high-water mark, which is never returned to the host.
|
||||
3. **WASM targets.** `wifi-densepose-wasm` is a browser binding crate (JS interop,
|
||||
serde, chrono — `crates/wifi-densepose-wasm/Cargo.toml`) and pulls `wifi-densepose-mat`
|
||||
optionally; it relies on `wasm-opt -O4` (`Cargo.toml` `[package.metadata.wasm-pack]`).
|
||||
`wifi-densepose-wasm-edge` is the disciplined one: `no_std` + `libm`, its own profile
|
||||
`opt-level = "s"`, lto, cgu=1 (`crates/wifi-densepose-wasm-edge/Cargo.toml`). Neither
|
||||
enables `+simd128` (§5). If the CIR estimator is ever compiled to wasm-edge, HE40's
|
||||
11.2 MB of sensing matrix alone is ~700 pages of linear memory — restrict edge WASM
|
||||
to HT20 (130 KB) or ship F4/F7 first.
|
||||
|
||||
---
|
||||
|
||||
## 5. Build-profile review & recommendations
|
||||
|
||||
Current release profile (`v2/Cargo.toml:213-218`) is already aggressive and correct:
|
||||
`opt-level = 3`, `lto = true` (fat), `codegen-units = 1`, `panic = "abort"`,
|
||||
`strip = true`; `bench` inherits release with debug symbols (`v2/Cargo.toml:225-227`).
|
||||
There is nothing wrong to fix here — the gains left are target- and feedback-driven:
|
||||
|
||||
1. **Per-target CPU tuning (EXACT, do first).** No `target-cpu` is set anywhere. For
|
||||
Pi 5 fleet builds: `RUSTFLAGS="-C target-cpu=cortex-a76"` — enables NEON scheduling
|
||||
and `vcnt` for the sketch path (F17) without changing IEEE semantics. LLVM does not
|
||||
reassociate float reductions or contract to FMA without explicit fast-math/contract
|
||||
flags, so scalar float results stay bit-exact. **Verify with the existing proof
|
||||
runners** (`cir_proof_runner`, `calibration_proof_runner`,
|
||||
`signal/Cargo.toml`) as the acceptance gate — that is exactly what they exist for.
|
||||
2. **WASM SIMD.** Add `-C target-feature=+simd128` for `wifi-densepose-wasm` builds and
|
||||
keep a non-SIMD artifact for older runtimes. Same determinism note as above; gate
|
||||
with the proof runners compiled to wasm where feasible.
|
||||
3. **PGO: feasible and determinism-safe.** PGO changes inlining/layout, never FP
|
||||
semantics. The repo already has ideal deterministic training workloads: the proof
|
||||
runner binaries plus `engine_cycle` / `cir_bench`. Pipeline: `cargo pgo build` →
|
||||
run proof runners + benches → `cargo pgo optimize`. Expect mid-single-digit to ~15%
|
||||
on branchy paths (gate decisions, tracker lifecycle); the dense ISTA loop will see
|
||||
little. Cost: CI complexity. Verdict: do it after F1–F12, not before.
|
||||
4. **Do not** enable `-ffast-math`-equivalents (`fadd_fast`, `core::intrinsics`,
|
||||
`-C llvm-args=-fp-contract=fast`) anywhere in the witness path. This must be a
|
||||
stated rule in CONTRIBUTING/ADR, not tribal knowledge.
|
||||
5. **BOLT / `opt-level` experiments are not worth it** ahead of F4; the pipeline is
|
||||
FLOP-bound in one loop, not front-end bound.
|
||||
|
||||
---
|
||||
|
||||
## 6. Prioritized 90-day plan
|
||||
|
||||
### Phase 0 — Measure (days 1–10)
|
||||
- Run and commit criterion baselines on a Pi 5 and an x86 dev box:
|
||||
`engine_cycle`, `cir_bench` (all four tiers), `sketch_bench`, `signal_bench`,
|
||||
`calibration_bench`. The 50 ms claim in `engine_cycle.rs:3` becomes a measured number.
|
||||
- Add a lightweight per-stage timing histogram (feature-gated, off in witness builds) at
|
||||
the §2 stage boundaries; wire a CI perf-regression gate (±10%) on the committed
|
||||
baselines.
|
||||
- Re-run the soak that produced `benchmark_baseline.json` and pin it as the accuracy
|
||||
guardrail for everything below.
|
||||
|
||||
### Phase 1 — Exact, zero-risk wins (days 10–35)
|
||||
All EXACT findings; no witness impact; each lands with proof-runner verification:
|
||||
- F5 (precompute warm-start diag/CSR in `CirEstimator::new`) — biggest exact CIR win.
|
||||
- F8 (hoist tomography gradient buffer), F6, F10, F12, F1, F2 (allocation/duplication
|
||||
removal), F15 + F11 with pinned index tie-breaks.
|
||||
- WorldGraph retention policy (the §4.1 unbounded-growth fix) — design ADR + ring-buffer
|
||||
implementation.
|
||||
- Expected outcome: measurable cycle-time reduction and flat memory under 24 h soak;
|
||||
**identical witness hashes**.
|
||||
|
||||
### Phase 2 — Determinism-managed structural wins (days 35–70)
|
||||
Each behind a feature flag, A/B'd against the legacy path (the `use_cir_gate` A/B switch
|
||||
at `multistatic.rs:103` is the template), with proof-hash regeneration as an explicit,
|
||||
witnessed release event:
|
||||
- **F4: FFT-based Φ/Φᴴ application in ISTA** — the headline item; the only route to
|
||||
HE20/HE40 real-time and the 12-link pattern. Acceptance: cir_bench speedup ≥ 5× at
|
||||
HE20, soak metrics within guardrail, new `expected_features.sha256` published in a
|
||||
fresh witness bundle.
|
||||
- F9 (power-method Lipschitz in tomography) riding the same hash-regen train.
|
||||
- F3 (slice-based CIR entry point), choosing the exact-f64-intermediate variant if the
|
||||
hash train slips.
|
||||
- F14: feature-gated `rayon` across **links/nodes only**, deterministic index-ordered
|
||||
collection; CI must run the determinism test (`engine/src/lib.rs:535-548`
|
||||
`cycle_is_deterministic`) with the feature on.
|
||||
|
||||
### Phase 3 — Platform & toolchain (days 70–90)
|
||||
- Pi 5 `target-cpu=cortex-a76` fleet builds + proof-runner verification (§5.1).
|
||||
- `+simd128` WASM artifact + size budget check for wasm-edge (§5.2, §4.3).
|
||||
- PGO pilot in CI using proof runners as the training corpus (§5.3).
|
||||
- Re-baseline: new criterion numbers, refreshed witness bundle, updated this document's
|
||||
§1 with real measured latencies.
|
||||
|
||||
**Out of 90-day scope, flagged for the architecture backlog:** F16 (Complex64→Complex32
|
||||
in core), F7 (single-matrix Φ kernel — bundle with F4), and HE40-on-edge (blocked on
|
||||
F4+F7).
|
||||
|
||||
---
|
||||
|
||||
## 7. Summary
|
||||
|
||||
The pipeline's only structural latency hazard is the dense ISTA CIR solver
|
||||
(`cir.rs:601-654` + `cir.rs:717-745`): fine at HT20, ~1.1 GFLOP worst-case per estimate
|
||||
at HE40, and slated to run per-link (×12). Everything else is allocation churn and
|
||||
duplicated work that can be removed with **bit-exact** refactors (F1–F12), plus one
|
||||
genuine memory bug-class issue: unbounded WorldGraph growth at 20 Hz
|
||||
(`engine/src/lib.rs:346-352`). The build profile is already optimal; remaining toolchain
|
||||
gains (target-cpu, wasm simd128, PGO) are determinism-safe and cheap. The determinism
|
||||
constraint is workable because the repo already owns the right tools — deterministic
|
||||
proof runners, an A/B gate pattern, and a per-cycle witness — so float-changing
|
||||
optimizations become scheduled, witnessed hash-regeneration events rather than risks.
|
||||
@@ -0,0 +1,96 @@
|
||||
# RuView Beyond-SOTA Research Series
|
||||
|
||||
Research swarm output (2026-06-09) defining what a beyond-state-of-the-art
|
||||
RuView implementation is, what the current system actually delivers, and the
|
||||
validation/benchmark/optimization evidence gathered in the same session.
|
||||
|
||||
Produced by a 5-agent hierarchical research swarm (system reviewer, SOTA
|
||||
surveyor, architect, benchmark methodologist, performance analyst) plus a
|
||||
validation pass run against the working tree.
|
||||
|
||||
## Documents
|
||||
|
||||
| Doc | Scope | One-line takeaway |
|
||||
|-----|-------|-------------------|
|
||||
| [00-system-review.md](00-system-review.md) | Capability audit of the current engine | Signal layer is the deepest asset (`ruvsense/` ≈14.4k lines, 310 in-module tests); the model tier is the emptiest (no trained checkpoint in-tree); the live 20 Hz path is the main integration gap |
|
||||
| [01-sota-landscape-2026.md](01-sota-landscape-2026.md) | Published SOTA per capability axis (web-verified) | Defines the beyond-SOTA bar: 12-row capability → published SOTA → RuView-today → target table; IEEE 802.11bf-2025 is ratified and moves the moat up-stack |
|
||||
| [02-beyond-sota-architecture.md](02-beyond-sota-architecture.md) | Target architecture | 8 pillars (RF foundation encoder + UQ heads, differentiable RF forward model, RF-SLAM×WorldGraph loop, camera→RF distillation, swarm apertures, continual adaptation, deterministic WASM edge, NV fusion) — all landing inside existing crates, no rewrite (per ADR-136 §2.1) |
|
||||
| [03-benchmark-validation-methodology.md](03-benchmark-validation-methodology.md) | Test/validation/benchmark methodology | 6-layer validation pyramid; 15 criterion bench targets inventoried; `benchmark_baseline.json` is a live-capture anchor, not a criterion baseline; statistical protocol from ADR-149 (≥10 seeds, IQM, bootstrap CIs) |
|
||||
| [04-optimization-roadmap.md](04-optimization-roadmap.md) | Performance review + 90-day plan | ISTA CIR solver is the dominant latency hazard (~1.1 GFLOP/frame at HE40); exact zero-risk wins identified; WorldGraph grows unboundedly (no eviction) — a real bug-class |
|
||||
|
||||
## Validation results (this session, 2026-06-09)
|
||||
|
||||
All measured on this branch (`claude/ruview-beyond-sota-xgv8aq`), Linux
|
||||
container, `cargo test --workspace --exclude wifi-densepose-desktop
|
||||
--no-default-features` (the desktop crate needs GTK system libraries absent in
|
||||
the container; this is an environment limitation, not a code failure).
|
||||
|
||||
| Layer | Command | Result |
|
||||
|-------|---------|--------|
|
||||
| L0 unit/integration | `cargo test --workspace --exclude wifi-densepose-desktop --no-default-features` | **154 suites, 2,797 passed, 0 failed** (pre-optimization baseline; re-run post-optimization also green) |
|
||||
| L1 deterministic proof | `python archive/v1/data/proof/verify.py` | **VERDICT: PASS** — hash `f8e76f21a0f9852b70b6d9dd5318239f6b20cbcb4cdd995863263cecdc446f7a` (bit-exact) |
|
||||
| L2 criterion (CIR) | `cargo bench -p wifi-densepose-signal --bench cir_bench --no-default-features --features cir` | Baselines captured pre/post optimization (below) |
|
||||
|
||||
~~Known pre-existing issue (not introduced here): `cargo check -p
|
||||
wifi-densepose-mat --no-default-features` fails standalone with 101 serde
|
||||
feature-unification errors; it builds and passes inside `--workspace` runs.~~
|
||||
**Fixed on this branch:** `pub mod api` (the only serde user) is now gated
|
||||
behind the `api` feature that owns the optional serde dependency; all feature
|
||||
combos compile.
|
||||
|
||||
## Optimizations applied (this session)
|
||||
|
||||
Two **exact** (bit-identical float results — summation order unchanged,
|
||||
witness chain unaffected) optimizations from the 04 roadmap's "zero-risk"
|
||||
tier were implemented and verified:
|
||||
|
||||
1. **`cir.rs` warm-start precompute** — the diagonal Tikhonov preconditioner
|
||||
`diag(Φ^H Φ) + λI` and its CSR matrix depend only on Φ and λ (fixed at
|
||||
`CirEstimator::new`) but were rebuilt on every frame (O(K·G) pass + CSR
|
||||
allocation). Moved to construction
|
||||
(`crates/wifi-densepose-signal/src/ruvsense/cir.rs`,
|
||||
`build_warm_start_system`).
|
||||
2. **`tomography.rs` solver hoisting** — the ISTA gradient `Vec` was
|
||||
allocated inside the 100-iteration loop and the Frobenius Lipschitz bound
|
||||
recomputed per `reconstruct` call; both hoisted
|
||||
(`crates/wifi-densepose-signal/src/ruvsense/tomography.rs`).
|
||||
|
||||
### Measured impact (criterion, paired pre/post baselines, same container)
|
||||
|
||||
| Bench | Pre-opt | Post-opt | Change | Significant? |
|
||||
|-------|---------|----------|--------|--------------|
|
||||
| `cir_estimate/he40` | 12.34 ms | 11.86 ms | **−3.9 %** | yes (p < 0.01) |
|
||||
| `cir_multiband_3band` (30 ms group) | 30.16 ms | 29.72 ms | −1.4 % | yes (p < 0.01) |
|
||||
| `cir_multiband` (142 ms group) | 141.9 ms | 140.1 ms | −1.2 % | yes (p < 0.01) |
|
||||
| `cir_estimate/ht40` | 11.73 ms | 11.78 ms | +0.4 % | no (p = 0.28) |
|
||||
| `cir_estimate/he20` | 2.49 ms | 2.49 ms | −0.1 % | no (p = 0.85) |
|
||||
| `cir_estimate/ht20` | 2.48 ms | 2.58 ms | +3.8 % | noise — see note |
|
||||
|
||||
Note on ht20: `cir_estimator_new/ht20` (construction, which now does strictly
|
||||
*more* work) also shows "+3 %", establishing a ≈3–4 % container noise floor;
|
||||
the ht20 estimate delta is within it. The honest summary: the warm-start
|
||||
precompute removes 1 of ~101 O(K·G) passes per frame, so the expected gain is
|
||||
≈1–4 % — consistent with what was measured. The dominant per-frame cost is
|
||||
the 100-iteration ISTA loop itself, which is exactly what the roadmap's
|
||||
flag-gated FFT-operator proposal (8–40× on the mat-vecs, requires witnessed
|
||||
hash regeneration) targets next.
|
||||
|
||||
Correctness post-optimization: `wifi-densepose-signal` 456 tests green;
|
||||
`wifi-densepose-engine` 11/11 green including `cycle_is_deterministic` and
|
||||
`calibration_mismatch_demotes_and_witness_stable` (witness-chain stability).
|
||||
|
||||
## Headline conclusions
|
||||
|
||||
1. **"Beyond SOTA" is currently unfalsifiable** without a real-CSI
|
||||
ground-truth benchmark — standing one up (per doc 03's acceptance table
|
||||
and ADR-149's statistical protocol) is the highest-leverage next step.
|
||||
2. **The path is evolution, not rewrite**: all eight architecture pillars in
|
||||
doc 02 land inside existing crates on the ADR-136 `Stage<I,O>`/`FrameMeta`
|
||||
contract spine.
|
||||
3. **The biggest engineering gaps** are the live 20 Hz ingest path, a trained
|
||||
RF encoder checkpoint, and WorldGraph retention/eviction — ahead of any
|
||||
frontier capability work.
|
||||
4. **Determinism is the differentiator**: every optimization and new pillar
|
||||
must preserve the witness chain; the advisory-vs-witnessed split (doc 02
|
||||
§determinism) is the mechanism that lets frontier components in without
|
||||
breaking it.
|
||||
@@ -0,0 +1,99 @@
|
||||
# We audited a state-of-the-art WiFi pose model. Here's what broke, what reproduced, and the 30× smaller model that nearly matches it.
|
||||
|
||||
*RuView team, June 2026. All numbers measured; full scripts and forensics in the
|
||||
[RuView repo](https://github.com/ruvnet/RuView/tree/main/benchmarks/wiflow-std).*
|
||||
|
||||
## The setup
|
||||
|
||||
WiFi sensing is having a moment: a 2026 preprint ("WiFlow", arXiv 2602.08661)
|
||||
claims **97.25% pose-estimation accuracy (PCK@20) from WiFi signals alone**,
|
||||
with a tiny 2.23M-parameter model — and unlike most papers, it ships
|
||||
everything: code, trained weights, and a 360,000-sample dataset.
|
||||
|
||||
We build WiFi sensing systems, so before adopting any external number we run
|
||||
it through a simple rule: **a claim is "CLAIMED" until we reproduce it, then
|
||||
it's "MEASURED."** Here's what happened when we tried.
|
||||
|
||||
## Day 1: nothing works
|
||||
|
||||
- **The code doesn't run.** The package imports a class that doesn't exist.
|
||||
(One-line fix.)
|
||||
- **The released model scores 0.08%, not 97.25%.** The shipped checkpoint was
|
||||
trained under a different data normalization than the shipped dataset —
|
||||
it's a real trained model, just not *this* pipeline's model. Even letting it
|
||||
cheat with a fitted per-keypoint correction only reaches 72%.
|
||||
- **The dataset is corrupted.** Its last 13 files contain garbage values up to
|
||||
3.4×10³⁸ (float32's maximum). Subtle consequence: the training loop uses
|
||||
fp16 mixed precision with no guards, so the first corrupted batch overflows
|
||||
and **permanently poisons the model's BatchNorm statistics**. Training from
|
||||
the public download produces NaN from epoch 1, every time.
|
||||
- The training script also crashes before its own test phase ever runs
|
||||
(calls an undefined function), and ignores its `--data_dir` flag.
|
||||
|
||||
At this point a less patient reader concludes "fraud." That would be wrong.
|
||||
|
||||
## Day 1, later: actually, the science is real
|
||||
|
||||
We repaired the artifacts — fixed the import, zeroed the 9,072 corrupted
|
||||
windows, retrained from scratch with the authors' own code and
|
||||
hyperparameters on one GPU (~50 minutes):
|
||||
|
||||
| Metric | Published | Our retrain |
|
||||
|---|---|---|
|
||||
| PCK@20 | 97.25% | **96.1–96.6%** |
|
||||
| PCK@50 | 99.48% | 99.0–99.1% |
|
||||
| Params | 2.23M | 2,225,042 (exact) |
|
||||
|
||||
**The claims reproduce.** What didn't survive contact was the *packaging*:
|
||||
wrong checkpoint, corrupted upload, broken glue code. This distinction —
|
||||
**artifact rot vs. bad science** — is the single most useful thing a
|
||||
reproduction can establish, and you can't establish it without actually
|
||||
running the thing.
|
||||
|
||||
(We filed all six defects upstream with fixes:
|
||||
[issue #3](https://github.com/DY2434/WiFlow-WiFi-Pose-Estimation-with-Spatio-Temporal-Decoupling/issues/3).
|
||||
And to be clear: the authors released more than 90% of papers do. That's the
|
||||
only reason this audit was possible.)
|
||||
|
||||
## Day 2: the model is also 2.6× too big
|
||||
|
||||
Once we could train, we asked: does the architecture need 2.23M parameters?
|
||||
|
||||
| Variant | Params | Accuracy (PCK@20) | Size on disk |
|
||||
|---|---|---|---|
|
||||
| Original | 2,225,042 | 96.61% | 8.97 MB |
|
||||
| **Half** | **843,834** | **96.62%** ✨ | — |
|
||||
| Quarter | 338,600 | 96.05% | — |
|
||||
| **Tiny** | **56,290** | **94.11%** | **295 KB** |
|
||||
|
||||
The half-width model **matches the original exactly** (and converges faster).
|
||||
The tiny one — 1/39th the parameters — gives up 2.5 points and runs at
|
||||
**0.66 ms per inference on a laptop CPU** (~1,500 poses/second) as a 295 KB
|
||||
ONNX file. For edge devices, that's the interesting end of the curve.
|
||||
|
||||
Quantization footnote: the paper's "~2.2 MB int8" estimate is reachable
|
||||
(we measured 2.44–2.53 MB) but only via conv-capable toolchains — PyTorch's
|
||||
one-line dynamic quantization converts *literally nothing* on this model
|
||||
(it has no Linear layers), a trap worth knowing about.
|
||||
|
||||
## What we took away
|
||||
|
||||
1. **Run the artifact, not the README.** Every number in a paper is one
|
||||
`git clone` away from being either confirmed or understood. Both outcomes
|
||||
are valuable; only one is publishable by the original authors.
|
||||
2. **fp16 + unvalidated data = silent model death.** Mixed-precision training
|
||||
with no NaN/inf guards doesn't fail loudly — it corrupts BatchNorm buffers
|
||||
and ships a broken model with a green progress bar. Validate inputs, or
|
||||
train in fp32, or guard the autocast.
|
||||
3. **Evidence-grade your own claims too.** Mid-audit, the same forensics
|
||||
tooling caught one of *our own* published accuracy numbers resting on a
|
||||
degenerate evaluation (a constant-output model scored with a flawed
|
||||
metric). We retracted it the same day. The rule has to cut both ways or
|
||||
it's marketing, not measurement.
|
||||
4. **Over-parameterization hides in SOTA tables.** Nobody publishes the
|
||||
half-size ablation that matches their headline model. Run it yourself;
|
||||
it's an hour of GPU time and sometimes it *is* the result.
|
||||
|
||||
*Reproduction scripts, corruption masks, the efficiency-sweep configs, and a
|
||||
numerically parity-proven Rust port (max divergence 1.2e-7) are all in
|
||||
[`benchmarks/wiflow-std/`](https://github.com/ruvnet/RuView/tree/main/benchmarks/wiflow-std).*
|
||||
+76
-16
@@ -1747,7 +1747,14 @@ See [ADR-071](adr/ADR-071-ruvllm-training-pipeline.md) and the [pretraining tuto
|
||||
|
||||
For significantly higher accuracy, use a webcam as a **temporary teacher** during training. The camera captures real 17-keypoint poses via MediaPipe, paired with simultaneous ESP32 CSI data. After training, the camera is no longer needed — the model runs on CSI only.
|
||||
|
||||
**Result: 92.9% PCK@20** from a 5-minute collection session.
|
||||
> **Accuracy note (2026-06-10):** the previously cited "92.9% PCK@20" figure is
|
||||
> retracted — a forensic recheck of the surviving eval holdout showed it came
|
||||
> from a constant-output model scored with an absolute (non-torso-normalized)
|
||||
> threshold on 69 nearly-static frames, a protocol under which a trivial
|
||||
> mean-pose predictor scores 100%. No measured camera-supervised PCK@20 is
|
||||
> currently published (see CHANGELOG, PR #535). Treat this workflow as a data
|
||||
> collection mechanism; accuracy claims will follow a ≥35-minute multi-pose
|
||||
> collection session evaluated with torso-normalized PCK.
|
||||
|
||||
### Requirements
|
||||
|
||||
@@ -1755,50 +1762,103 @@ For significantly higher accuracy, use a webcam as a **temporary teacher** durin
|
||||
- ESP32-S3 node streaming CSI over UDP (port 5005)
|
||||
- A webcam (laptop, USB, or Mac camera via Tailscale)
|
||||
|
||||
### Step 1: Capture Camera + CSI Simultaneously
|
||||
### Step 0: Check your CSI rate and plan the session length
|
||||
|
||||
Window yield is `csi_frames / 20` — **your CSI packet rate sets how long you
|
||||
must record.** Check it first (10-second probe):
|
||||
|
||||
```bash
|
||||
python - <<'EOF'
|
||||
import socket, time
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM); s.bind(('0.0.0.0', 5005)); s.settimeout(2)
|
||||
n, t0 = 0, time.time()
|
||||
while time.time() - t0 < 10:
|
||||
try: s.recvfrom(4096); n += 1
|
||||
except socket.timeout: pass
|
||||
print(f"{n/10:.1f} Hz -> {n/10*60/20:.0f} windows/min")
|
||||
EOF
|
||||
```
|
||||
|
||||
| CSI rate | Windows/min | Minutes for 2,000 windows (minimum trainable) |
|
||||
|---|---|---|
|
||||
| ~13 Hz (idle network) | ~39 | ~52 min |
|
||||
| ~53 Hz (active self-ping, #985 firmware) | ~160 | ~13 min — record 35–40 min anyway for pose variety |
|
||||
|
||||
A 5-minute session is **not enough to train on** — it produces a few hundred
|
||||
windows of one pose context, and models trained on it memorize rather than
|
||||
generalize (this is what invalidated the earlier accuracy figure).
|
||||
|
||||
### Step 1: (Recommended) calibrate camera ↔ room
|
||||
|
||||
The two-checkerboard calibration (ADR-152 §2.1.3) puts labels in a shared 3D
|
||||
room frame instead of raw camera coordinates, which is the published defense
|
||||
against layout-brittle "coordinate overfitting" (PerceptAlign, MobiCom'26):
|
||||
|
||||
```bash
|
||||
python scripts/calibrate-camera-room.py # < 5 min, two checkerboards + a few photos
|
||||
```
|
||||
|
||||
Without it, collection still works but labels are camera-frame only and the
|
||||
trained model will not survive camera/node relocation.
|
||||
|
||||
### Step 2: Capture Camera + CSI Simultaneously
|
||||
|
||||
Run both scripts at the same time (in separate terminals):
|
||||
|
||||
```bash
|
||||
# Terminal 1: Record ESP32 CSI
|
||||
python scripts/record-csi-udp.py --duration 300
|
||||
# Terminal 1: Record ESP32 CSI (2400 s = 40 min)
|
||||
python scripts/record-csi-udp.py --duration 2400
|
||||
|
||||
# Terminal 2: Capture camera keypoints
|
||||
python scripts/collect-ground-truth.py --duration 300 --preview
|
||||
python scripts/collect-ground-truth.py --duration 2400 --preview \
|
||||
--calibration data/calibration/camera-room.json # omit if you skipped Step 1
|
||||
```
|
||||
|
||||
Move around naturally in front of the camera for 5 minutes. The `--preview` flag shows a live skeleton overlay.
|
||||
During capture: keep your **full body in frame** with good lighting (MediaPipe
|
||||
confidence must stay above 0.5 — low-confidence frames are dropped at
|
||||
alignment), and **change activity every 1–2 minutes**: walk, raise hands,
|
||||
squat, hands up, kick, wave, turn, jump, sit, stand still. Pose variety is
|
||||
what the model learns from; 40 minutes of sitting produces a constant-pose
|
||||
predictor.
|
||||
|
||||
### Step 2: Align and Train
|
||||
### Step 3: Align and Train
|
||||
|
||||
```bash
|
||||
# Align camera keypoints with CSI windows
|
||||
# Align camera keypoints with CSI windows (prints kept/dropped window counts —
|
||||
# expect roughly csi_frames/20 kept; investigate if far below)
|
||||
node scripts/align-ground-truth.js \
|
||||
--gt data/ground-truth/*.jsonl \
|
||||
--csi data/recordings/csi-*.csi.jsonl
|
||||
|
||||
# Train (start with lite, scale up as you collect more data)
|
||||
# Train (pick the preset matching your window count)
|
||||
node scripts/train-wiflow-supervised.js \
|
||||
--data data/paired/*.jsonl \
|
||||
--scale lite \
|
||||
--scale small \
|
||||
--epochs 50
|
||||
|
||||
# Evaluate
|
||||
# Evaluate — torso-normalized PCK on a TEMPORAL split
|
||||
node scripts/eval-wiflow.js \
|
||||
--model models/wiflow-supervised/wiflow-v1.json \
|
||||
--data data/paired/*.jsonl
|
||||
```
|
||||
|
||||
**Evaluation protocol matters.** Use `eval-wiflow.js` (torso-normalized
|
||||
PCK@20, the metric comparable to published WiFi-pose results) on a temporal
|
||||
hold-out, and sanity-check that predictions actually vary across frames
|
||||
(`pred std > 0`) — a constant-pose model can score deceptively well on
|
||||
near-static data under weaker protocols. See
|
||||
`benchmarks/wiflow-std/RESULTS.md` for the forensic case study.
|
||||
|
||||
### Scale Presets
|
||||
|
||||
| Preset | Params | Training Time | Best For |
|
||||
|--------|--------|---------------|----------|
|
||||
| `--scale lite` | 189K | ~19 min | < 1,000 samples (5 min capture) |
|
||||
| `--scale small` | 474K | ~1 hr | 1K-10K samples |
|
||||
| `--scale medium` | 800K | ~2 hrs | 10K-50K samples |
|
||||
| `--scale full` | 7.7M | ~8 hrs | 50K+ samples (GPU recommended) |
|
||||
| `--scale lite` | 189K | ~19 min | sanity runs only (< 2K windows trains poorly) |
|
||||
| `--scale small` | 474K | ~1 hr | 2K-10K windows (one 40-min session) |
|
||||
| `--scale medium` | 800K | ~2 hrs | 10K-50K windows (multiple sessions/rooms) |
|
||||
| `--scale full` | 7.7M | ~8 hrs | 50K+ windows (GPU recommended) |
|
||||
|
||||
See [ADR-079](adr/ADR-079-camera-ground-truth-training.md) for the full design and optimization details.
|
||||
See [ADR-079](adr/ADR-079-camera-ground-truth-training.md) for the full design and optimization details, and ADR-152 §2.2 for the external WiFlow-STD benchmark these numbers should be read against.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -151,9 +151,13 @@ static void on_recv(const uint8_t *src_mac, const uint8_t *data, int len)
|
||||
* void (*)(const esp_now_send_info_t *tx_info, esp_now_send_status_t status)
|
||||
* Both signatures ignore the address-side argument here — we only inspect
|
||||
* `status` to bump the TX-fail counter — so the body is identical; only the
|
||||
* function-pointer type differs. ESP_IDF_VERSION_MAJOR is the canonical guard.
|
||||
* function-pointer type differs.
|
||||
*
|
||||
* Issue #1005: Espressif backported the new signature to v5.5
|
||||
* (`esp_now_send_info_t` = typedef of `wifi_tx_info_t` there), so the guard
|
||||
* must be the full version triple, not ESP_IDF_VERSION_MAJOR.
|
||||
*/
|
||||
#if ESP_IDF_VERSION_MAJOR >= 6
|
||||
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 5, 0)
|
||||
static void on_send(const esp_now_send_info_t *tx_info, esp_now_send_status_t status)
|
||||
{
|
||||
(void)tx_info;
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
/* Host-fuzzing stub for esp_netif.h (ADR-061).
|
||||
*
|
||||
* csi_collector.c's #954 self-ping needs the STA netif handle + gateway IP.
|
||||
* In the fuzz environment there is no network stack: the handle lookup
|
||||
* returns NULL, so csi_start_self_ping() takes its no-gateway early-out and
|
||||
* the esp_ping path is never exercised (but must compile and link).
|
||||
*/
|
||||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
|
||||
#include "esp_err.h"
|
||||
|
||||
typedef struct esp_netif_obj esp_netif_t;
|
||||
|
||||
typedef struct {
|
||||
uint32_t addr;
|
||||
} esp_ip4_addr_t;
|
||||
|
||||
typedef struct {
|
||||
esp_ip4_addr_t ip;
|
||||
esp_ip4_addr_t netmask;
|
||||
esp_ip4_addr_t gw;
|
||||
} esp_netif_ip_info_t;
|
||||
|
||||
static inline esp_netif_t *esp_netif_get_handle_from_ifkey(const char *if_key)
|
||||
{
|
||||
(void)if_key;
|
||||
return NULL; /* no netif in fuzz env -> self-ping early-out */
|
||||
}
|
||||
|
||||
static inline esp_err_t esp_netif_get_ip_info(esp_netif_t *netif, esp_netif_ip_info_t *ip_info)
|
||||
{
|
||||
(void)netif;
|
||||
(void)ip_info;
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
static inline char *esp_ip4addr_ntoa(const esp_ip4_addr_t *addr, char *buf, int buflen)
|
||||
{
|
||||
if (buf != NULL && buflen > 0) {
|
||||
snprintf(buf, (size_t)buflen, "%u.%u.%u.%u",
|
||||
(unsigned)(addr->addr & 0xff), (unsigned)((addr->addr >> 8) & 0xff),
|
||||
(unsigned)((addr->addr >> 16) & 0xff), (unsigned)((addr->addr >> 24) & 0xff));
|
||||
}
|
||||
return buf;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/* Host-fuzzing stub for lwip/ip_addr.h (ADR-061). Minimal surface for the
|
||||
* #954 self-ping block; never functionally exercised in the fuzz env. */
|
||||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
typedef struct {
|
||||
uint32_t addr;
|
||||
uint8_t type;
|
||||
} ip_addr_t;
|
||||
|
||||
static inline int ipaddr_aton(const char *cp, ip_addr_t *addr)
|
||||
{
|
||||
(void)cp;
|
||||
if (addr != NULL) {
|
||||
addr->addr = 0;
|
||||
addr->type = 0;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/* Host-fuzzing stub for ping/ping_sock.h (ADR-061). The #954 self-ping is
|
||||
* unreachable in the fuzz env (esp_netif stub returns no gateway), but the
|
||||
* symbols must compile and link. */
|
||||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
#include "esp_err.h"
|
||||
#include "lwip/ip_addr.h"
|
||||
|
||||
typedef void *esp_ping_handle_t;
|
||||
|
||||
typedef void (*esp_ping_cb_t)(esp_ping_handle_t hdl, void *args);
|
||||
|
||||
typedef struct {
|
||||
uint32_t count;
|
||||
uint32_t interval_ms;
|
||||
uint32_t timeout_ms;
|
||||
uint32_t data_size;
|
||||
uint8_t tos;
|
||||
int ttl;
|
||||
ip_addr_t target_addr;
|
||||
uint32_t task_stack_size;
|
||||
uint32_t task_prio;
|
||||
uint32_t interface;
|
||||
} esp_ping_config_t;
|
||||
|
||||
#define ESP_PING_COUNT_INFINITE (0)
|
||||
|
||||
#define ESP_PING_DEFAULT_CONFIG() \
|
||||
{ \
|
||||
.count = 5, \
|
||||
.interval_ms = 1000, \
|
||||
.timeout_ms = 1000, \
|
||||
.data_size = 64, \
|
||||
.tos = 0, \
|
||||
.ttl = 64, \
|
||||
.target_addr = {0, 0}, \
|
||||
.task_stack_size = 2048, \
|
||||
.task_prio = 2, \
|
||||
.interface = 0, \
|
||||
}
|
||||
|
||||
typedef struct {
|
||||
void *cb_args;
|
||||
esp_ping_cb_t on_ping_success;
|
||||
esp_ping_cb_t on_ping_timeout;
|
||||
esp_ping_cb_t on_ping_end;
|
||||
} esp_ping_callbacks_t;
|
||||
|
||||
static inline esp_err_t esp_ping_new_session(const esp_ping_config_t *config,
|
||||
const esp_ping_callbacks_t *cbs,
|
||||
esp_ping_handle_t *hdl_out)
|
||||
{
|
||||
(void)config;
|
||||
(void)cbs;
|
||||
if (hdl_out != NULL) {
|
||||
*hdl_out = (void *)0;
|
||||
}
|
||||
return ESP_FAIL; /* never starts a ping task in the fuzz env */
|
||||
}
|
||||
|
||||
static inline esp_err_t esp_ping_start(esp_ping_handle_t hdl)
|
||||
{
|
||||
(void)hdl;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
static inline esp_err_t esp_ping_stop(esp_ping_handle_t hdl)
|
||||
{
|
||||
(void)hdl;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
static inline esp_err_t esp_ping_delete_session(esp_ping_handle_t hdl)
|
||||
{
|
||||
(void)hdl;
|
||||
return ESP_OK;
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Two-checkerboard camera-room calibration for WiFi pose training (ADR-152 S2.1.3).
|
||||
|
||||
Aligns the ADR-079 ground-truth camera and the ESP32 WiFi transceivers in
|
||||
one shared 3D room frame -- the PerceptAlign (arXiv 2601.12252) defense
|
||||
against "coordinate overfitting", where CSI-to-camera-coordinate regression
|
||||
memorizes the deployment layout and collapses cross-layout.
|
||||
|
||||
Procedure (<5 minutes):
|
||||
1. Print a checkerboard (default 9x6 inner corners, 25 mm squares).
|
||||
2. Tape one board flat on the ORIGIN WALL, tape-measure its top-left inner
|
||||
corner position in room coordinates (+x along wall, +y into room, +z up).
|
||||
3. Lay the second board flat on the FLOOR, measure its near-left inner corner.
|
||||
4. With the collection camera in its final position, photograph each board.
|
||||
5. Run this script; tape-measure each ESP32 node position when prompted
|
||||
(or pass --geometry nodes.json).
|
||||
|
||||
Output: a calibration bundle JSON consumed by
|
||||
scripts/collect-ground-truth.py --calibration <bundle.json>
|
||||
|
||||
Usage:
|
||||
python scripts/calibrate-camera-room.py \\
|
||||
--wall-image photos/wall.jpg --wall-origin 0.50,0.0,1.60 \\
|
||||
--floor-image photos/floor.jpg --floor-origin 1.00,1.00,0.0 \\
|
||||
--calib-images "photos/intrinsics/*.jpg" \\
|
||||
--geometry config/transceivers.json \\
|
||||
--output data/calibration/camera-room.json
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import glob
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import calibration_lib as cal # noqa: E402
|
||||
|
||||
INTRINSICS_CACHE = Path("data") / ".cache" / "camera_intrinsics.json"
|
||||
|
||||
|
||||
def parse_vec3(text: str) -> np.ndarray:
|
||||
parts = [float(p) for p in text.replace(",", " ").split()]
|
||||
if len(parts) != 3:
|
||||
raise argparse.ArgumentTypeError(f"Expected 3 comma-separated numbers, got {text!r}")
|
||||
return np.array(parts, dtype=np.float64)
|
||||
|
||||
|
||||
def detect_corners(image_path: Path, cols: int, rows: int) -> tuple[np.ndarray, tuple[int, int]]:
|
||||
image = cv2.imread(str(image_path))
|
||||
if image is None:
|
||||
print(f"ERROR: Cannot read image {image_path}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
corners = cal.find_board_corners(image, cols, rows)
|
||||
if corners is None:
|
||||
print(
|
||||
f"ERROR: No {cols}x{rows} checkerboard found in {image_path}. "
|
||||
"Check lighting, focus, and the --board-cols/--board-rows flags.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
h, w = image.shape[:2]
|
||||
return corners, (w, h)
|
||||
|
||||
|
||||
def resolve_intrinsics(args, repo_root: Path, board_args: tuple[int, int, float]) -> dict:
|
||||
"""Pre-computed file > cached > computed from --calib-images >
|
||||
last-resort 2-view estimate from the wall+floor photos themselves."""
|
||||
cols, rows, square_m = board_args
|
||||
|
||||
if args.intrinsics:
|
||||
print(f"Intrinsics: loading {args.intrinsics}")
|
||||
return cal.load_intrinsics(Path(args.intrinsics))
|
||||
|
||||
cache_path = repo_root / INTRINSICS_CACHE
|
||||
if cache_path.exists() and not args.recalibrate_intrinsics:
|
||||
print(f"Intrinsics: using cached {cache_path} (pass --recalibrate-intrinsics to redo)")
|
||||
intr = cal.load_intrinsics(cache_path)
|
||||
intr["source"] = "cached"
|
||||
return intr
|
||||
|
||||
if args.calib_images:
|
||||
paths = sorted(glob.glob(args.calib_images))
|
||||
if len(paths) < 3:
|
||||
print(
|
||||
f"ERROR: --calib-images matched only {len(paths)} file(s); "
|
||||
"need >= 3 checkerboard views for stable intrinsics.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
corner_sets, image_size = [], None
|
||||
for p in paths:
|
||||
corners, size = detect_corners(Path(p), cols, rows)
|
||||
if image_size is None:
|
||||
image_size = size
|
||||
elif size != image_size:
|
||||
print(f"ERROR: {p} has size {size}, expected {image_size}.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
corner_sets.append(corners)
|
||||
print(f" corners found: {p}")
|
||||
intr = cal.compute_intrinsics(corner_sets, image_size, cols, rows, square_m)
|
||||
print(f"Intrinsics: computed from {len(paths)} views, "
|
||||
f"reprojection RMS {intr['reprojection_error_px']:.3f} px")
|
||||
cal.save_bundle(intr, cache_path) # plain JSON write; reused on next run
|
||||
print(f" cached to {cache_path}")
|
||||
return intr
|
||||
|
||||
# Last resort: 2-view calibration from the extrinsic photos. Workable but
|
||||
# weak -- warn loudly and recommend a proper multi-view pass.
|
||||
print(
|
||||
"WARNING: no --intrinsics / cache / --calib-images; estimating intrinsics "
|
||||
"from the wall+floor photos alone (2 views, low quality). Prefer "
|
||||
"--calib-images with 5-10 varied board views.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
corner_sets, image_size = [], None
|
||||
for p in (args.wall_image, args.floor_image):
|
||||
corners, size = detect_corners(Path(p), cols, rows)
|
||||
image_size = image_size or size
|
||||
corner_sets.append(corners)
|
||||
intr = cal.compute_intrinsics(corner_sets, image_size, cols, rows, square_m)
|
||||
intr["source"] = "two-view-fallback"
|
||||
return intr
|
||||
|
||||
|
||||
def prompt_transceiver_geometry() -> dict:
|
||||
"""Tape-measure entry of ESP32 node positions in room coordinates."""
|
||||
print()
|
||||
print("Transceiver geometry -- enter one node per line:")
|
||||
print(" <node-id> <x> <y> <z> [yaw_deg] (meters, room frame; blank line to finish)")
|
||||
print(" example: esp32-s3-a 0.10 2.40 1.10 180")
|
||||
nodes = []
|
||||
while True:
|
||||
try:
|
||||
line = input("node> ").strip()
|
||||
except EOFError:
|
||||
break
|
||||
if not line:
|
||||
break
|
||||
parts = line.split()
|
||||
if len(parts) not in (4, 5):
|
||||
print(" expected: <node-id> <x> <y> <z> [yaw_deg]", file=sys.stderr)
|
||||
continue
|
||||
try:
|
||||
node = {"id": parts[0], "position_m": [float(parts[1]), float(parts[2]), float(parts[3])]}
|
||||
if len(parts) == 5:
|
||||
node["antenna_yaw_deg"] = float(parts[4])
|
||||
except ValueError:
|
||||
print(" positions must be numeric", file=sys.stderr)
|
||||
continue
|
||||
nodes.append(node)
|
||||
if not nodes:
|
||||
print("WARNING: no transceiver nodes entered; bundle will carry empty geometry.",
|
||||
file=sys.stderr)
|
||||
return {"nodes": nodes, "units": "meters", "source": "tape-measure-prompt"}
|
||||
|
||||
|
||||
def load_geometry_file(path: Path) -> dict:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
nodes = data.get("nodes", data if isinstance(data, list) else None)
|
||||
if nodes is None:
|
||||
raise ValueError(f"{path}: expected {{'nodes': [...]}} or a top-level list")
|
||||
for node in nodes:
|
||||
if "id" not in node or "position_m" not in node:
|
||||
raise ValueError(f"{path}: each node needs 'id' and 'position_m' [x,y,z]")
|
||||
return {"nodes": nodes, "units": "meters", "source": "file"}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Two-checkerboard camera-room calibration (ADR-152 S2.1.3 / ADR-079)."
|
||||
)
|
||||
parser.add_argument("--wall-image", required=True,
|
||||
help="Photo of the checkerboard on the origin wall")
|
||||
parser.add_argument("--floor-image", required=True,
|
||||
help="Photo of the checkerboard on the floor (camera NOT moved)")
|
||||
parser.add_argument("--wall-origin", type=parse_vec3, default="0.5,0.0,1.6",
|
||||
help="Room xyz (m) of the wall board's first inner corner "
|
||||
"(default: 0.5,0.0,1.6)")
|
||||
parser.add_argument("--floor-origin", type=parse_vec3, default="1.0,1.0,0.0",
|
||||
help="Room xyz (m) of the floor board's first inner corner "
|
||||
"(default: 1.0,1.0,0.0)")
|
||||
parser.add_argument("--wall-axes", default="+x,-z",
|
||||
help="Wall board column,row directions in room frame (default: +x,-z)")
|
||||
parser.add_argument("--floor-axes", default="+x,+y",
|
||||
help="Floor board column,row directions in room frame (default: +x,+y)")
|
||||
parser.add_argument("--board-cols", type=int, default=cal.DEFAULT_BOARD_COLS,
|
||||
help=f"Inner corners per row (default: {cal.DEFAULT_BOARD_COLS})")
|
||||
parser.add_argument("--board-rows", type=int, default=cal.DEFAULT_BOARD_ROWS,
|
||||
help=f"Inner corners per column (default: {cal.DEFAULT_BOARD_ROWS})")
|
||||
parser.add_argument("--square-size-mm", type=float, default=cal.DEFAULT_SQUARE_SIZE_MM,
|
||||
help=f"Checkerboard square size in mm (default: {cal.DEFAULT_SQUARE_SIZE_MM})")
|
||||
parser.add_argument("--intrinsics", help="Pre-computed intrinsics JSON (skips computation)")
|
||||
parser.add_argument("--calib-images",
|
||||
help="Glob of >=3 checkerboard photos for intrinsics computation")
|
||||
parser.add_argument("--recalibrate-intrinsics", action="store_true",
|
||||
help="Ignore the cached intrinsics and recompute")
|
||||
parser.add_argument("--geometry",
|
||||
help="Transceiver geometry JSON ({nodes:[{id,position_m,[antenna_yaw_deg]}]}); "
|
||||
"omit to be prompted for tape-measure entry")
|
||||
parser.add_argument("--output", default=None,
|
||||
help="Bundle output path (default: data/calibration/camera-room-<ts>.json)")
|
||||
args = parser.parse_args()
|
||||
|
||||
if isinstance(args.wall_origin, str):
|
||||
args.wall_origin = parse_vec3(args.wall_origin)
|
||||
if isinstance(args.floor_origin, str):
|
||||
args.floor_origin = parse_vec3(args.floor_origin)
|
||||
|
||||
repo_root = Path(__file__).resolve().parent.parent
|
||||
cols, rows = args.board_cols, args.board_rows
|
||||
square_m = args.square_size_mm / 1000.0
|
||||
|
||||
# --- Intrinsics ---
|
||||
intrinsics = resolve_intrinsics(args, repo_root, (cols, rows, square_m))
|
||||
camera_matrix = np.asarray(intrinsics["camera_matrix"], dtype=np.float64)
|
||||
dist_coeffs = np.asarray(intrinsics["dist_coeffs"], dtype=np.float64)
|
||||
|
||||
# --- Corner detection on the two placed boards ---
|
||||
wall_corners, wall_size = detect_corners(Path(args.wall_image), cols, rows)
|
||||
floor_corners, floor_size = detect_corners(Path(args.floor_image), cols, rows)
|
||||
if wall_size != floor_size:
|
||||
print(f"ERROR: wall image {wall_size} and floor image {floor_size} differ in size; "
|
||||
"both must come from the fixed collection camera.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
print(f"Corners detected: wall + floor boards ({cols}x{rows}, {args.square_size_mm} mm)")
|
||||
|
||||
# Re-scale intrinsics if they were computed at a different resolution
|
||||
# than the extrinsic photos (the bundle always stores K at wall_size).
|
||||
intr_size = tuple(intrinsics["image_size"])
|
||||
if intr_size != wall_size:
|
||||
sx, sy = wall_size[0] / intr_size[0], wall_size[1] / intr_size[1]
|
||||
camera_matrix[0, 0] *= sx
|
||||
camera_matrix[0, 2] *= sx
|
||||
camera_matrix[1, 1] *= sy
|
||||
camera_matrix[1, 2] *= sy
|
||||
print(f" intrinsics scaled {intr_size} -> {wall_size}")
|
||||
intrinsics = {**intrinsics, "camera_matrix": camera_matrix.tolist(),
|
||||
"image_size": list(wall_size)}
|
||||
|
||||
# --- Room-frame corner positions from the measured placements ---
|
||||
wall_u, wall_v = (cal.parse_axis(t) for t in args.wall_axes.split(","))
|
||||
floor_u, floor_v = (cal.parse_axis(t) for t in args.floor_axes.split(","))
|
||||
wall_room = cal.board_room_points(cols, rows, square_m, args.wall_origin, wall_u, wall_v)
|
||||
floor_room = cal.board_room_points(cols, rows, square_m, args.floor_origin, floor_u, floor_v)
|
||||
|
||||
# --- Extrinsics: joint two-board solve (resolves per-board corner-order
|
||||
# ambiguity -- a single planar board is centrosymmetric; the pair is not) ---
|
||||
extrinsics = cal.solve_two_board_extrinsics(
|
||||
wall_room, wall_corners, floor_room, floor_corners, camera_matrix, dist_coeffs
|
||||
)
|
||||
wall_rmse = extrinsics["per_board"]["wall"]["rmse_px"]
|
||||
floor_rmse = extrinsics["per_board"]["floor"]["rmse_px"]
|
||||
print(f" joint solve: RMSE {extrinsics['rmse_px']:.3f} px "
|
||||
f"(wall {wall_rmse:.3f} / floor {floor_rmse:.3f})")
|
||||
print(f" camera at room {np.round(extrinsics['translation_m'], 3).tolist()} m")
|
||||
if max(wall_rmse, floor_rmse) > 3.0:
|
||||
print(
|
||||
"WARNING: high per-board reprojection error -- re-check the measured "
|
||||
"board origins/axes and that the camera did not move between photos.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
# --- Transceiver geometry ---
|
||||
if args.geometry:
|
||||
geometry = load_geometry_file(Path(args.geometry))
|
||||
print(f"Transceiver geometry: {len(geometry['nodes'])} node(s) from {args.geometry}")
|
||||
else:
|
||||
geometry = prompt_transceiver_geometry()
|
||||
|
||||
# --- Bundle ---
|
||||
bundle = cal.make_bundle(
|
||||
camera_intrinsics=intrinsics,
|
||||
camera_to_room_extrinsics=extrinsics,
|
||||
checkerboard_spec={"cols": cols, "rows": rows, "square_size_mm": args.square_size_mm},
|
||||
transceiver_geometry=geometry,
|
||||
)
|
||||
if args.output:
|
||||
out_path = Path(args.output)
|
||||
else:
|
||||
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
out_path = repo_root / "data" / "calibration" / f"camera-room-{ts}.json"
|
||||
cal.save_bundle(bundle, out_path)
|
||||
|
||||
print()
|
||||
print("=== Calibration bundle written ===")
|
||||
print(f" path: {out_path}")
|
||||
print(f" calibration_id: {cal.calibration_id(bundle)}")
|
||||
print(f" next: python scripts/collect-ground-truth.py --calibration {out_path}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,416 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Camera-room calibration library for WiFi pose ground truth (ADR-152 S2.1.3).
|
||||
|
||||
Implements the PerceptAlign-style two-checkerboard alignment adopted in
|
||||
ADR-152 S2.1.3 to defend the ADR-079 camera-supervised pipeline against
|
||||
"coordinate overfitting" (arXiv 2601.12252, MobiCom'26): models regressing
|
||||
CSI to raw camera-frame coordinates memorize the deployment layout and
|
||||
collapse cross-layout. The fix is to express camera AND WiFi transceivers
|
||||
in one shared 3D room frame, and stamp every training label with the
|
||||
calibration + transceiver geometry that produced it.
|
||||
|
||||
Used by:
|
||||
scripts/calibrate-camera-room.py (produces the calibration bundle)
|
||||
scripts/collect-ground-truth.py (consumes it via --calibration)
|
||||
|
||||
Room frame convention (right-handed, meters):
|
||||
origin = a designated wall/floor corner of the room
|
||||
+x = along the origin wall
|
||||
+y = into the room (away from the origin wall)
|
||||
+z = up
|
||||
|
||||
No-depth limitation (IMPORTANT): a single 2D camera keypoint constrains
|
||||
only a *ray* in the room frame, not a 3D point. The transform helpers here
|
||||
therefore return unit bearing rays from the camera center -- a projective
|
||||
alignment. Consumers that need metric 3D points must supply a depth
|
||||
assumption downstream (floor-plane intersection, known subject height,
|
||||
multi-view triangulation, ...). Raw image coordinates are always preserved
|
||||
alongside the room-frame rays so training can choose either representation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
BUNDLE_SCHEMA_VERSION = 1
|
||||
BUNDLE_METHOD = "two-checkerboard"
|
||||
|
||||
# Default checkerboard: 9x6 inner corners, 25 mm squares (a common print).
|
||||
DEFAULT_BOARD_COLS = 9
|
||||
DEFAULT_BOARD_ROWS = 6
|
||||
DEFAULT_SQUARE_SIZE_MM = 25.0
|
||||
|
||||
_AXIS_TOKENS = {
|
||||
"+x": (1.0, 0.0, 0.0), "-x": (-1.0, 0.0, 0.0),
|
||||
"+y": (0.0, 1.0, 0.0), "-y": (0.0, -1.0, 0.0),
|
||||
"+z": (0.0, 0.0, 1.0), "-z": (0.0, 0.0, -1.0),
|
||||
}
|
||||
|
||||
|
||||
def parse_axis(token: str) -> np.ndarray:
|
||||
"""Parse an axis token like '+x' or '-z' into a room-frame unit vector."""
|
||||
key = token.strip().lower()
|
||||
if key in _AXIS_TOKENS:
|
||||
return np.array(_AXIS_TOKENS[key], dtype=np.float64)
|
||||
raise ValueError(f"Invalid axis token {token!r}; expected one of {sorted(_AXIS_TOKENS)}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Checkerboard geometry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def board_object_points(cols: int, rows: int, square_size_m: float) -> np.ndarray:
|
||||
"""Inner-corner positions in the board's own frame (z=0 plane), row-major.
|
||||
|
||||
Matches the corner ordering of cv2.findChessboardCorners for a
|
||||
(cols, rows) pattern: cols varies fastest.
|
||||
"""
|
||||
pts = np.zeros((rows * cols, 3), dtype=np.float64)
|
||||
grid = np.mgrid[0:cols, 0:rows].T.reshape(-1, 2) # (rows*cols, 2), cols fastest
|
||||
pts[:, :2] = grid * square_size_m
|
||||
return pts
|
||||
|
||||
|
||||
def board_room_points(
|
||||
cols: int,
|
||||
rows: int,
|
||||
square_size_m: float,
|
||||
origin: np.ndarray,
|
||||
u_axis: np.ndarray,
|
||||
v_axis: np.ndarray,
|
||||
) -> np.ndarray:
|
||||
"""Inner-corner positions in ROOM coordinates for a board placed at a
|
||||
known position: first corner at `origin`, columns stepping along
|
||||
`u_axis`, rows stepping along `v_axis` (both room-frame unit vectors).
|
||||
"""
|
||||
local = board_object_points(cols, rows, square_size_m)
|
||||
origin = np.asarray(origin, dtype=np.float64)
|
||||
u = np.asarray(u_axis, dtype=np.float64)
|
||||
v = np.asarray(v_axis, dtype=np.float64)
|
||||
return origin[None, :] + local[:, 0:1] * u[None, :] + local[:, 1:2] * v[None, :]
|
||||
|
||||
|
||||
def find_board_corners(image: np.ndarray, cols: int, rows: int) -> np.ndarray | None:
|
||||
"""Detect and sub-pixel-refine checkerboard inner corners.
|
||||
|
||||
Returns (cols*rows, 2) float64 pixel coordinates, or None if not found.
|
||||
"""
|
||||
gray = image if image.ndim == 2 else cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
|
||||
flags = cv2.CALIB_CB_ADAPTIVE_THRESH | cv2.CALIB_CB_NORMALIZE_IMAGE
|
||||
found, corners = cv2.findChessboardCorners(gray, (cols, rows), flags=flags)
|
||||
if not found:
|
||||
return None
|
||||
criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 30, 1e-3)
|
||||
corners = cv2.cornerSubPix(gray, corners, (11, 11), (-1, -1), criteria)
|
||||
return corners.reshape(-1, 2).astype(np.float64)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Intrinsics
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def compute_intrinsics(
|
||||
corner_sets: list[np.ndarray],
|
||||
image_size: tuple[int, int],
|
||||
cols: int,
|
||||
rows: int,
|
||||
square_size_m: float,
|
||||
) -> dict:
|
||||
"""Camera intrinsics from N checkerboard views via cv2.calibrateCamera.
|
||||
|
||||
corner_sets: list of (cols*rows, 2) pixel corner arrays.
|
||||
image_size: (width, height) of the calibration images.
|
||||
"""
|
||||
obj = board_object_points(cols, rows, square_size_m).astype(np.float32)
|
||||
obj_pts = [obj for _ in corner_sets]
|
||||
img_pts = [c.reshape(-1, 1, 2).astype(np.float32) for c in corner_sets]
|
||||
rms, camera_matrix, dist_coeffs, _, _ = cv2.calibrateCamera(
|
||||
obj_pts, img_pts, tuple(image_size), None, None
|
||||
)
|
||||
return {
|
||||
"image_size": [int(image_size[0]), int(image_size[1])],
|
||||
"camera_matrix": camera_matrix.tolist(),
|
||||
"dist_coeffs": dist_coeffs.ravel().tolist(),
|
||||
"reprojection_error_px": float(rms),
|
||||
"source": "computed",
|
||||
}
|
||||
|
||||
|
||||
def load_intrinsics(path: Path) -> dict:
|
||||
"""Load a pre-computed intrinsics JSON ({camera_matrix, dist_coeffs, image_size})."""
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
# Accept either a bare intrinsics dict or a full calibration bundle.
|
||||
intr = data.get("camera_intrinsics", data)
|
||||
for key in ("camera_matrix", "dist_coeffs", "image_size"):
|
||||
if key not in intr:
|
||||
raise ValueError(f"Intrinsics file {path} missing key {key!r}")
|
||||
intr = dict(intr)
|
||||
intr["source"] = "file"
|
||||
return intr
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Extrinsics (camera -> room rigid transform)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def reprojection_rmse(
|
||||
room_points: np.ndarray,
|
||||
image_points: np.ndarray,
|
||||
rvec: np.ndarray,
|
||||
tvec: np.ndarray,
|
||||
camera_matrix: np.ndarray,
|
||||
dist_coeffs: np.ndarray,
|
||||
) -> float:
|
||||
proj, _ = cv2.projectPoints(room_points, rvec, tvec, camera_matrix, dist_coeffs)
|
||||
err = proj.reshape(-1, 2) - image_points.reshape(-1, 2)
|
||||
return float(np.sqrt(np.mean(np.sum(err**2, axis=1))))
|
||||
|
||||
|
||||
def _solve_pnp(
|
||||
room_points: np.ndarray,
|
||||
image_points: np.ndarray,
|
||||
camera_matrix: np.ndarray,
|
||||
dist_coeffs: np.ndarray,
|
||||
) -> dict | None:
|
||||
"""One solvePnP run (room->camera), inverted to camera->room. Returns
|
||||
{rotation (3x3 camera->room), translation_m (camera center in room
|
||||
frame), rmse_px} or None on failure.
|
||||
"""
|
||||
ok, rvec, tvec = cv2.solvePnP(
|
||||
room_points.reshape(-1, 1, 3),
|
||||
image_points.reshape(-1, 1, 2),
|
||||
camera_matrix,
|
||||
dist_coeffs,
|
||||
flags=cv2.SOLVEPNP_ITERATIVE,
|
||||
)
|
||||
if not ok:
|
||||
return None
|
||||
rmse = reprojection_rmse(room_points, image_points, rvec, tvec, camera_matrix, dist_coeffs)
|
||||
r_room_to_cam, _ = cv2.Rodrigues(rvec)
|
||||
r_cam_to_room = r_room_to_cam.T
|
||||
camera_center_room = (-r_cam_to_room @ tvec).ravel()
|
||||
return {
|
||||
"rotation": r_cam_to_room.tolist(),
|
||||
"translation_m": camera_center_room.tolist(),
|
||||
"rmse_px": rmse,
|
||||
}
|
||||
|
||||
|
||||
def solve_extrinsics(
|
||||
room_points: np.ndarray,
|
||||
image_points: np.ndarray,
|
||||
camera_matrix: np.ndarray,
|
||||
dist_coeffs: np.ndarray,
|
||||
) -> dict:
|
||||
"""Solve the camera->room rigid transform from 3D room-frame points and
|
||||
their 2D pixel observations.
|
||||
|
||||
NOTE: the corner grid of a single planar checkerboard is centrosymmetric,
|
||||
so the corner ordering returned by findChessboardCorners (which may
|
||||
enumerate from either board end) cannot be disambiguated from one board
|
||||
alone -- the reversed ordering fits a ghost pose with identical
|
||||
reprojection error. Use solve_two_board_extrinsics for the full
|
||||
two-checkerboard procedure, where the joint point set breaks the symmetry.
|
||||
"""
|
||||
ext = _solve_pnp(room_points, image_points, camera_matrix, dist_coeffs)
|
||||
if ext is None:
|
||||
raise RuntimeError("solvePnP failed")
|
||||
return ext
|
||||
|
||||
|
||||
def solve_two_board_extrinsics(
|
||||
wall_room: np.ndarray,
|
||||
wall_image: np.ndarray,
|
||||
floor_room: np.ndarray,
|
||||
floor_image: np.ndarray,
|
||||
camera_matrix: np.ndarray,
|
||||
dist_coeffs: np.ndarray,
|
||||
) -> dict:
|
||||
"""Joint camera->room solve over both checkerboards (the ADR-152 S2.1.3
|
||||
two-checkerboard method).
|
||||
|
||||
Tries all 4 per-board corner-ordering combinations: each board's ordering
|
||||
is individually ambiguous (centrosymmetric grid), but the combined
|
||||
wall+floor point set is not, so exactly one combination reaches minimal
|
||||
reprojection error. Returns the solve_extrinsics dict plus
|
||||
{wall_flipped, floor_flipped, per_board: {wall|floor: {rmse_px}}}.
|
||||
"""
|
||||
best = None
|
||||
for wall_flipped in (False, True):
|
||||
for floor_flipped in (False, True):
|
||||
wi = wall_image[::-1].copy() if wall_flipped else wall_image
|
||||
fi = floor_image[::-1].copy() if floor_flipped else floor_image
|
||||
room = np.concatenate([wall_room, floor_room], axis=0)
|
||||
img = np.concatenate([wi, fi], axis=0)
|
||||
ext = _solve_pnp(room, img, camera_matrix, dist_coeffs)
|
||||
if ext is None:
|
||||
continue
|
||||
if best is None or ext["rmse_px"] < best[0]["rmse_px"]:
|
||||
ext["wall_flipped"] = wall_flipped
|
||||
ext["floor_flipped"] = floor_flipped
|
||||
rvec, _ = cv2.Rodrigues(np.asarray(ext["rotation"]).T)
|
||||
tvec = -np.asarray(ext["rotation"]).T @ np.asarray(ext["translation_m"])
|
||||
ext["per_board"] = {
|
||||
"wall": {"rmse_px": reprojection_rmse(
|
||||
wall_room, wi, rvec, tvec, camera_matrix, dist_coeffs)},
|
||||
"floor": {"rmse_px": reprojection_rmse(
|
||||
floor_room, fi, rvec, tvec, camera_matrix, dist_coeffs)},
|
||||
}
|
||||
best = (ext,)
|
||||
if best is None:
|
||||
raise RuntimeError("solvePnP failed for all corner-ordering combinations")
|
||||
return best[0]
|
||||
|
||||
|
||||
def extrinsics_consistency(ext_a: dict, ext_b: dict) -> dict:
|
||||
"""Angular + translational disagreement between two extrinsic solutions
|
||||
(the two single-board solves). Large values mean a mis-entered board
|
||||
placement or a bad corner detection.
|
||||
"""
|
||||
ra = np.asarray(ext_a["rotation"])
|
||||
rb = np.asarray(ext_b["rotation"])
|
||||
r_delta = ra.T @ rb
|
||||
angle = float(np.degrees(np.arccos(np.clip((np.trace(r_delta) - 1.0) / 2.0, -1.0, 1.0))))
|
||||
t_delta = float(
|
||||
np.linalg.norm(np.asarray(ext_a["translation_m"]) - np.asarray(ext_b["translation_m"]))
|
||||
)
|
||||
return {"rotation_deg": angle, "translation_m": t_delta}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Calibration bundle (the artifact written to disk)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def make_bundle(
|
||||
camera_intrinsics: dict,
|
||||
camera_to_room_extrinsics: dict,
|
||||
checkerboard_spec: dict,
|
||||
transceiver_geometry: dict,
|
||||
) -> dict:
|
||||
return {
|
||||
"schema_version": BUNDLE_SCHEMA_VERSION,
|
||||
"method": BUNDLE_METHOD,
|
||||
"calibrated_at": datetime.now(timezone.utc).isoformat(),
|
||||
"room_frame": {
|
||||
"description": "right-handed; origin at wall/floor corner; "
|
||||
"+x along origin wall, +y into room, +z up",
|
||||
"units": "meters",
|
||||
},
|
||||
"checkerboard_spec": checkerboard_spec,
|
||||
"camera_intrinsics": camera_intrinsics,
|
||||
"camera_to_room_extrinsics": camera_to_room_extrinsics,
|
||||
"transceiver_geometry": transceiver_geometry,
|
||||
}
|
||||
|
||||
|
||||
def calibration_id(bundle: dict) -> str:
|
||||
"""Stable content hash of a bundle -- stamped onto every emitted sample
|
||||
so a label can always be traced to the exact calibration that framed it.
|
||||
"""
|
||||
canonical = json.dumps(bundle, sort_keys=True, separators=(",", ":"))
|
||||
return "sha256:" + hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def save_bundle(bundle: dict, path: Path) -> None:
|
||||
path = Path(path)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(bundle, f, indent=2)
|
||||
f.write("\n")
|
||||
|
||||
|
||||
def load_bundle(path: Path) -> dict:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
bundle = json.load(f)
|
||||
for key in ("camera_intrinsics", "camera_to_room_extrinsics", "transceiver_geometry"):
|
||||
if key not in bundle:
|
||||
raise ValueError(f"Calibration bundle {path} missing key {key!r}")
|
||||
return bundle
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Keypoint transform (image -> room-frame bearing rays)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class CalibrationContext:
|
||||
"""Pre-computed transform state for a collection session.
|
||||
|
||||
Scales the bundle's intrinsics to the live capture resolution (MediaPipe
|
||||
keypoints are normalized [0,1], so we need the actual frame size to get
|
||||
back to pixels before undistorting).
|
||||
"""
|
||||
|
||||
def __init__(self, bundle: dict, frame_w: int, frame_h: int):
|
||||
self.bundle = bundle
|
||||
self.calibration_id = calibration_id(bundle)
|
||||
self.transceiver_geometry = bundle["transceiver_geometry"]
|
||||
self.frame_w = int(frame_w)
|
||||
self.frame_h = int(frame_h)
|
||||
|
||||
intr = bundle["camera_intrinsics"]
|
||||
k = np.asarray(intr["camera_matrix"], dtype=np.float64)
|
||||
cal_w, cal_h = intr["image_size"]
|
||||
sx = self.frame_w / float(cal_w)
|
||||
sy = self.frame_h / float(cal_h)
|
||||
k = k.copy()
|
||||
k[0, 0] *= sx
|
||||
k[0, 2] *= sx
|
||||
k[1, 1] *= sy
|
||||
k[1, 2] *= sy
|
||||
self.camera_matrix = k
|
||||
self.dist_coeffs = np.asarray(intr["dist_coeffs"], dtype=np.float64)
|
||||
|
||||
ext = bundle["camera_to_room_extrinsics"]
|
||||
self.r_cam_to_room = np.asarray(ext["rotation"], dtype=np.float64)
|
||||
self.origin_room = np.asarray(ext["translation_m"], dtype=np.float64)
|
||||
|
||||
def transform_keypoints(self, keypoints_norm: list[list[float]]) -> tuple[np.ndarray, np.ndarray]:
|
||||
"""Normalized [0,1] image keypoints -> unit bearing rays in the room
|
||||
frame, anchored at the camera center.
|
||||
|
||||
Projective alignment ONLY (no depth): each returned ray is the locus
|
||||
of room positions consistent with the 2D observation. Returns
|
||||
(camera_origin_room (3,), ray_dirs (N, 3) unit vectors).
|
||||
"""
|
||||
pts = np.asarray(keypoints_norm, dtype=np.float64)
|
||||
pts_px = pts * np.array([self.frame_w, self.frame_h], dtype=np.float64)
|
||||
undist = cv2.undistortPoints(
|
||||
pts_px.reshape(-1, 1, 2), self.camera_matrix, self.dist_coeffs
|
||||
).reshape(-1, 2)
|
||||
rays_cam = np.concatenate([undist, np.ones((len(undist), 1))], axis=1)
|
||||
rays_cam /= np.linalg.norm(rays_cam, axis=1, keepdims=True)
|
||||
rays_room = (self.r_cam_to_room @ rays_cam.T).T
|
||||
return self.origin_room, rays_room
|
||||
|
||||
|
||||
def load_calibration_context(path: Path, frame_w: int, frame_h: int) -> CalibrationContext:
|
||||
return CalibrationContext(load_bundle(path), frame_w, frame_h)
|
||||
|
||||
|
||||
def augment_record(record: dict, ctx: CalibrationContext | None) -> dict:
|
||||
"""Stamp a ground-truth record with room-frame rays + calibration metadata.
|
||||
|
||||
With ctx=None this is the identity -- the record (and hence the emitted
|
||||
JSONL line) is byte-identical to the pre-calibration ADR-079 format.
|
||||
Raw image-coordinate keypoints are kept untouched in both cases; the
|
||||
room-frame representation is ADDED, never substituted, so training can
|
||||
choose either (ADR-152 S2.1.3).
|
||||
"""
|
||||
if ctx is None:
|
||||
return record
|
||||
if record.get("keypoints"):
|
||||
_, rays = ctx.transform_keypoints(record["keypoints"])
|
||||
record["keypoints_room"] = [[round(float(v), 5) for v in ray] for ray in rays]
|
||||
else:
|
||||
record["keypoints_room"] = []
|
||||
record["camera_origin_room"] = [round(float(v), 5) for v in ctx.origin_room]
|
||||
record["calibration_id"] = ctx.calibration_id
|
||||
record["transceiver_geometry"] = ctx.transceiver_geometry
|
||||
return record
|
||||
@@ -6,9 +6,19 @@ synchronizes with ESP32 CSI recording from the sensing server.
|
||||
|
||||
Output: JSONL file in data/ground-truth/ with per-frame 17-keypoint COCO poses.
|
||||
|
||||
With --calibration <bundle.json> (produced by scripts/calibrate-camera-room.py,
|
||||
ADR-152 S2.1.3), every record is additionally stamped with room-frame bearing
|
||||
rays for each keypoint, the calibration_id, and the transceiver geometry --
|
||||
the PerceptAlign-style defense against coordinate overfitting. Raw image
|
||||
coordinates are always kept; without depth the room-frame representation is
|
||||
a projective alignment (rays, not 3D points) -- see scripts/calibration_lib.py.
|
||||
Without --calibration the output is byte-identical to the original ADR-079
|
||||
format.
|
||||
|
||||
Usage:
|
||||
python scripts/collect-ground-truth.py --preview --duration 60
|
||||
python scripts/collect-ground-truth.py --server http://192.168.1.10:3000
|
||||
python scripts/collect-ground-truth.py --calibration data/calibration/camera-room.json
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -168,8 +178,23 @@ def main():
|
||||
default="data/ground-truth",
|
||||
help="Output directory (default: data/ground-truth)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--calibration",
|
||||
default=None,
|
||||
help="Camera-room calibration bundle JSON from scripts/calibrate-camera-room.py "
|
||||
"(ADR-152 S2.1.3); adds room-frame keypoint rays + transceiver geometry "
|
||||
"to every record",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.calibration:
|
||||
print(
|
||||
"WARNING: no --calibration bundle; labels stay in raw camera coordinates "
|
||||
"and are layout-brittle (coordinate overfitting, ADR-152 S2.1.3) -- run "
|
||||
"scripts/calibrate-camera-room.py first.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
# --- Resolve paths relative to repo root ---
|
||||
repo_root = Path(__file__).resolve().parent.parent
|
||||
output_dir = repo_root / args.output
|
||||
@@ -193,6 +218,25 @@ def main():
|
||||
frame_h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
||||
print(f"Camera opened: {frame_w}x{frame_h}")
|
||||
|
||||
# --- Load calibration bundle (ADR-152 S2.1.3) ---
|
||||
calib_ctx = None
|
||||
if args.calibration:
|
||||
# Lazy import keeps the no-calibration path identical to the original.
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import calibration_lib
|
||||
|
||||
try:
|
||||
calib_ctx = calibration_lib.load_calibration_context(
|
||||
Path(args.calibration), frame_w, frame_h
|
||||
)
|
||||
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
||||
print(f"ERROR: Cannot load calibration bundle {args.calibration}: {exc}",
|
||||
file=sys.stderr)
|
||||
sys.exit(1)
|
||||
n_nodes = len(calib_ctx.transceiver_geometry.get("nodes", []))
|
||||
print(f"Calibration: {calib_ctx.calibration_id[:23]}... "
|
||||
f"({n_nodes} transceiver node(s)); emitting room-frame keypoint rays")
|
||||
|
||||
# --- Create PoseLandmarker ---
|
||||
options = PoseLandmarkerOptions(
|
||||
base_options=BaseOptions(model_asset_path=str(model_path)),
|
||||
@@ -287,6 +331,10 @@ def main():
|
||||
"n_visible": n_visible,
|
||||
"n_persons": n_persons,
|
||||
}
|
||||
if calib_ctx is not None:
|
||||
# Adds keypoints_room (bearing rays), camera_origin_room,
|
||||
# calibration_id, transceiver_geometry (ADR-152 S2.1.3).
|
||||
record = calibration_lib.augment_record(record, calib_ctx)
|
||||
out_file.write(json.dumps(record) + "\n")
|
||||
frame_count += 1
|
||||
total_confidence += confidence
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Segmented overnight empty-room CSI capture (ADR-135 baseline / MAE corpus).
|
||||
|
||||
Binds UDP once and writes fixed-duration JSONL segments with explicit names —
|
||||
no post-hoc renaming, no glob collisions with other recordings.
|
||||
|
||||
Usage:
|
||||
python scripts/overnight-empty-capture.py --segments 8 --segment-seconds 3300
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import struct
|
||||
import time
|
||||
|
||||
|
||||
def parse_csi_packet(data):
|
||||
"""ADR-018 binary CSI packet → dict (same layout as record-csi-udp.py)."""
|
||||
if len(data) < 8:
|
||||
return None
|
||||
node_id = data[4]
|
||||
rssi = struct.unpack("b", bytes([data[6]]))[0]
|
||||
channel = data[7]
|
||||
iq = data[8:]
|
||||
amplitudes = []
|
||||
for i in range(0, len(iq) - 1, 2):
|
||||
I = struct.unpack("b", bytes([iq[i]]))[0]
|
||||
Q = struct.unpack("b", bytes([iq[i + 1]]))[0]
|
||||
amplitudes.append(round((I * I + Q * Q) ** 0.5, 2))
|
||||
return {
|
||||
"type": "raw_csi",
|
||||
"ts_ns": time.time_ns(),
|
||||
"node_id": node_id,
|
||||
"rssi": rssi,
|
||||
"channel": channel,
|
||||
"subcarriers": len(iq) // 2,
|
||||
"amplitudes": amplitudes,
|
||||
"iq_hex": iq.hex(),
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--port", type=int, default=5005)
|
||||
ap.add_argument("--segments", type=int, default=8)
|
||||
ap.add_argument("--segment-seconds", type=int, default=3300)
|
||||
ap.add_argument("--output", default="data/recordings")
|
||||
ap.add_argument("--prefix", default="overnight-empty")
|
||||
args = ap.parse_args()
|
||||
|
||||
os.makedirs(args.output, exist_ok=True)
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
sock.bind(("0.0.0.0", args.port))
|
||||
sock.settimeout(2.0)
|
||||
|
||||
for seg in range(1, args.segments + 1):
|
||||
path = os.path.join(
|
||||
args.output, f"{args.prefix}-seg{seg}-{int(time.time())}.csi.jsonl"
|
||||
)
|
||||
n = 0
|
||||
t_end = time.time() + args.segment_seconds
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
while time.time() < t_end:
|
||||
try:
|
||||
data, _ = sock.recvfrom(4096)
|
||||
except socket.timeout:
|
||||
continue
|
||||
rec = parse_csi_packet(data)
|
||||
if rec is not None:
|
||||
f.write(json.dumps(rec) + "\n")
|
||||
n += 1
|
||||
print(f"segment {seg}: {n} frames -> {path}", flush=True)
|
||||
|
||||
print("capture complete", flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,8 @@
|
||||
"""Make scripts/ importable for the calibration tests (ADR-152 S2.1.3)."""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPTS_DIR = Path(__file__).resolve().parents[1]
|
||||
if str(SCRIPTS_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(SCRIPTS_DIR))
|
||||
@@ -0,0 +1,326 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Headless tests for the camera-room calibration pipeline (ADR-152 S2.1.3).
|
||||
|
||||
Covers calibration_lib.py end to end on synthetic data -- no camera, no
|
||||
display, no MediaPipe:
|
||||
* known extrinsics recovered from synthetic two-checkerboard corners
|
||||
* calibration bundle JSON round-trip + stable content hash
|
||||
* image->room keypoint transform correctness (rays pass through the
|
||||
original 3D points -- the projective, no-depth alignment of ADR-079
|
||||
labels into the shared room frame)
|
||||
* collect-ground-truth's no-calibration record path is byte-identical
|
||||
(augment_record with ctx=None is the identity)
|
||||
|
||||
Run: python -m pytest scripts/tests/ -q
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
import calibration_lib as cal
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Synthetic scene fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
IMG_W, IMG_H = 1280, 720
|
||||
K_GT = np.array(
|
||||
[[800.0, 0.0, 640.0],
|
||||
[0.0, 800.0, 360.0],
|
||||
[0.0, 0.0, 1.0]]
|
||||
)
|
||||
DIST_ZERO = np.zeros(5)
|
||||
DIST_MILD = np.array([-0.10, 0.02, 0.001, -0.001, 0.0])
|
||||
|
||||
BOARD_COLS, BOARD_ROWS = 9, 6
|
||||
SQUARE_M = 0.025
|
||||
|
||||
|
||||
def look_at_pose(camera_pos, target):
|
||||
"""Ground-truth camera pose: returns (R_cam_to_room, camera_center_room).
|
||||
|
||||
Camera convention: +z forward (optical axis), +x right, +y down.
|
||||
"""
|
||||
c = np.asarray(camera_pos, dtype=np.float64)
|
||||
fwd = np.asarray(target, dtype=np.float64) - c
|
||||
fwd /= np.linalg.norm(fwd)
|
||||
up_room = np.array([0.0, 0.0, 1.0])
|
||||
x_cam = np.cross(fwd, -up_room)
|
||||
x_cam /= np.linalg.norm(x_cam)
|
||||
y_cam = np.cross(fwd, x_cam)
|
||||
r_cam_to_room = np.stack([x_cam, y_cam, fwd], axis=1) # columns = camera axes in room
|
||||
return r_cam_to_room, c
|
||||
|
||||
|
||||
def room_to_cam(r_cam_to_room, center):
|
||||
"""Invert to the solvePnP (room->camera) convention: rvec, tvec."""
|
||||
r_room_to_cam = r_cam_to_room.T
|
||||
tvec = -r_room_to_cam @ center
|
||||
rvec, _ = cv2.Rodrigues(r_room_to_cam)
|
||||
return rvec, tvec.reshape(3, 1)
|
||||
|
||||
|
||||
def project_room_points(points_room, r_cam_to_room, center, k=K_GT, dist=DIST_ZERO):
|
||||
rvec, tvec = room_to_cam(r_cam_to_room, center)
|
||||
proj, _ = cv2.projectPoints(np.asarray(points_room, dtype=np.float64), rvec, tvec, k, dist)
|
||||
return proj.reshape(-1, 2)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def scene():
|
||||
"""A camera in the room looking at the wall + floor checkerboards."""
|
||||
r_gt, c_gt = look_at_pose(camera_pos=[1.5, 3.0, 1.3], target=[1.0, 0.5, 0.8])
|
||||
wall_room = cal.board_room_points(
|
||||
BOARD_COLS, BOARD_ROWS, SQUARE_M,
|
||||
origin=[0.5, 0.0, 1.6], u_axis=cal.parse_axis("+x"), v_axis=cal.parse_axis("-z"),
|
||||
)
|
||||
floor_room = cal.board_room_points(
|
||||
BOARD_COLS, BOARD_ROWS, SQUARE_M,
|
||||
origin=[1.0, 1.0, 0.0], u_axis=cal.parse_axis("+x"), v_axis=cal.parse_axis("+y"),
|
||||
)
|
||||
return r_gt, c_gt, wall_room, floor_room
|
||||
|
||||
|
||||
def make_bundle(r_gt, c_gt, dist=DIST_ZERO):
|
||||
return cal.make_bundle(
|
||||
camera_intrinsics={
|
||||
"image_size": [IMG_W, IMG_H],
|
||||
"camera_matrix": K_GT.tolist(),
|
||||
"dist_coeffs": dist.tolist(),
|
||||
"reprojection_error_px": 0.0,
|
||||
"source": "synthetic",
|
||||
},
|
||||
camera_to_room_extrinsics={
|
||||
"rotation": r_gt.tolist(),
|
||||
"translation_m": c_gt.tolist(),
|
||||
"rmse_px": 0.0,
|
||||
},
|
||||
checkerboard_spec={"cols": BOARD_COLS, "rows": BOARD_ROWS, "square_size_mm": 25.0},
|
||||
transceiver_geometry={
|
||||
"nodes": [
|
||||
{"id": "esp32-s3-a", "position_m": [0.1, 2.4, 1.1], "antenna_yaw_deg": 180.0},
|
||||
{"id": "esp32-c6-b", "position_m": [3.2, 0.3, 0.9]},
|
||||
],
|
||||
"units": "meters",
|
||||
"source": "file",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Extrinsics recovery from synthetic checkerboard corners
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestExtrinsicsRecovery:
|
||||
def test_two_board_combined_recovers_known_pose(self, scene):
|
||||
r_gt, c_gt, wall_room, floor_room = scene
|
||||
room_pts = np.concatenate([wall_room, floor_room], axis=0)
|
||||
img_pts = project_room_points(room_pts, r_gt, c_gt)
|
||||
|
||||
ext = cal.solve_extrinsics(room_pts, img_pts, K_GT, DIST_ZERO)
|
||||
|
||||
assert ext["rmse_px"] < 1e-3
|
||||
np.testing.assert_allclose(np.asarray(ext["translation_m"]), c_gt, atol=1e-4)
|
||||
r_delta = np.asarray(ext["rotation"]).T @ r_gt
|
||||
angle_deg = np.degrees(np.arccos(np.clip((np.trace(r_delta) - 1) / 2, -1, 1)))
|
||||
assert angle_deg < 0.01
|
||||
|
||||
def test_single_board_solves_agree(self, scene):
|
||||
# With correct corner ordering, each board alone recovers the same pose.
|
||||
r_gt, c_gt, wall_room, floor_room = scene
|
||||
ext_wall = cal.solve_extrinsics(
|
||||
wall_room, project_room_points(wall_room, r_gt, c_gt), K_GT, DIST_ZERO)
|
||||
ext_floor = cal.solve_extrinsics(
|
||||
floor_room, project_room_points(floor_room, r_gt, c_gt), K_GT, DIST_ZERO)
|
||||
consistency = cal.extrinsics_consistency(ext_wall, ext_floor)
|
||||
assert consistency["rotation_deg"] < 0.1
|
||||
assert consistency["translation_m"] < 1e-3
|
||||
|
||||
def test_reversed_corner_order_auto_recovered(self, scene):
|
||||
# findChessboardCorners may enumerate from either board end. A single
|
||||
# board cannot disambiguate that flip (centrosymmetric grid), but the
|
||||
# joint two-board solve can -- feed it a reversed wall ordering and
|
||||
# require the true pose back.
|
||||
r_gt, c_gt, wall_room, floor_room = scene
|
||||
wall_img = project_room_points(wall_room, r_gt, c_gt)
|
||||
floor_img = project_room_points(floor_room, r_gt, c_gt)
|
||||
ext = cal.solve_two_board_extrinsics(
|
||||
wall_room, wall_img[::-1].copy(), floor_room, floor_img,
|
||||
K_GT, DIST_ZERO)
|
||||
assert ext["wall_flipped"] is True
|
||||
assert ext["floor_flipped"] is False
|
||||
assert ext["rmse_px"] < 1e-3
|
||||
np.testing.assert_allclose(np.asarray(ext["translation_m"]), c_gt, atol=1e-3)
|
||||
|
||||
def test_joint_solver_matches_unflipped(self, scene):
|
||||
r_gt, c_gt, wall_room, floor_room = scene
|
||||
ext = cal.solve_two_board_extrinsics(
|
||||
wall_room, project_room_points(wall_room, r_gt, c_gt),
|
||||
floor_room, project_room_points(floor_room, r_gt, c_gt),
|
||||
K_GT, DIST_ZERO)
|
||||
assert ext["wall_flipped"] is False and ext["floor_flipped"] is False
|
||||
assert ext["per_board"]["wall"]["rmse_px"] < 1e-3
|
||||
assert ext["per_board"]["floor"]["rmse_px"] < 1e-3
|
||||
|
||||
def test_intrinsics_recovered_from_synthetic_views(self):
|
||||
# Several board views from different poses -> calibrateCamera should
|
||||
# get focal length / principal point close to ground truth.
|
||||
obj = cal.board_object_points(BOARD_COLS, BOARD_ROWS, SQUARE_M)
|
||||
poses = [
|
||||
([0.05, 1.2, 0.05], [0.10, 0.0, 0.06]),
|
||||
([-0.25, 1.0, 0.20], [0.10, 0.0, 0.06]),
|
||||
([0.45, 0.9, -0.15], [0.10, 0.0, 0.06]),
|
||||
([0.10, 1.4, 0.30], [0.10, 0.0, 0.06]),
|
||||
([-0.15, 0.8, -0.20], [0.10, 0.0, 0.06]),
|
||||
]
|
||||
corner_sets = []
|
||||
for cam_pos, target in poses:
|
||||
r, c = look_at_pose(cam_pos, target)
|
||||
# Embed the board rigidly in the y=0 plane (u=+x, v=+z) and view it.
|
||||
board_in_room = np.column_stack([obj[:, 0], obj[:, 2], obj[:, 1]])
|
||||
corner_sets.append(project_room_points(board_in_room, r, c))
|
||||
intr = cal.compute_intrinsics(corner_sets, (IMG_W, IMG_H),
|
||||
BOARD_COLS, BOARD_ROWS, SQUARE_M)
|
||||
k = np.asarray(intr["camera_matrix"])
|
||||
assert abs(k[0, 0] - K_GT[0, 0]) / K_GT[0, 0] < 0.05
|
||||
assert abs(k[1, 1] - K_GT[1, 1]) / K_GT[1, 1] < 0.05
|
||||
assert intr["reprojection_error_px"] < 1.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bundle round-trip + content hash
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestBundle:
|
||||
def test_save_load_roundtrip(self, scene, tmp_path):
|
||||
r_gt, c_gt, _, _ = scene
|
||||
bundle = make_bundle(r_gt, c_gt)
|
||||
path = tmp_path / "camera-room.json"
|
||||
cal.save_bundle(bundle, path)
|
||||
loaded = cal.load_bundle(path)
|
||||
assert loaded == bundle
|
||||
assert cal.calibration_id(loaded) == cal.calibration_id(bundle)
|
||||
|
||||
def test_bundle_schema_fields(self, scene):
|
||||
r_gt, c_gt, _, _ = scene
|
||||
bundle = make_bundle(r_gt, c_gt)
|
||||
for key in ("schema_version", "method", "calibrated_at", "room_frame",
|
||||
"checkerboard_spec", "camera_intrinsics",
|
||||
"camera_to_room_extrinsics", "transceiver_geometry"):
|
||||
assert key in bundle
|
||||
assert bundle["method"] == "two-checkerboard"
|
||||
|
||||
def test_calibration_id_changes_with_content(self, scene):
|
||||
r_gt, c_gt, _, _ = scene
|
||||
bundle_a = make_bundle(r_gt, c_gt)
|
||||
bundle_b = json.loads(json.dumps(bundle_a))
|
||||
bundle_b["transceiver_geometry"]["nodes"][0]["position_m"] = [0.2, 2.4, 1.1]
|
||||
assert cal.calibration_id(bundle_a) != cal.calibration_id(bundle_b)
|
||||
assert cal.calibration_id(bundle_a).startswith("sha256:")
|
||||
|
||||
def test_load_bundle_rejects_missing_keys(self, tmp_path):
|
||||
path = tmp_path / "bad.json"
|
||||
path.write_text('{"camera_intrinsics": {}}', encoding="utf-8")
|
||||
with pytest.raises(ValueError, match="missing key"):
|
||||
cal.load_bundle(path)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Keypoint transform: image -> room-frame bearing rays (projective alignment)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestKeypointTransform:
|
||||
PERSON_POINTS = np.array([
|
||||
[1.2, 1.5, 1.7], # head height
|
||||
[1.1, 1.5, 1.4], # shoulder
|
||||
[1.3, 1.6, 0.9], # hip
|
||||
[1.2, 1.5, 0.1], # ankle
|
||||
])
|
||||
|
||||
@pytest.mark.parametrize("dist", [DIST_ZERO, DIST_MILD], ids=["no-distortion", "mild-distortion"])
|
||||
def test_rays_pass_through_original_points(self, scene, dist):
|
||||
r_gt, c_gt, _, _ = scene
|
||||
img = project_room_points(self.PERSON_POINTS, r_gt, c_gt, dist=dist)
|
||||
kps_norm = (img / np.array([IMG_W, IMG_H])).tolist()
|
||||
|
||||
ctx = cal.CalibrationContext(make_bundle(r_gt, c_gt, dist=dist), IMG_W, IMG_H)
|
||||
origin, rays = ctx.transform_keypoints(kps_norm)
|
||||
|
||||
np.testing.assert_allclose(origin, c_gt, atol=1e-9)
|
||||
np.testing.assert_allclose(np.linalg.norm(rays, axis=1), 1.0, atol=1e-9)
|
||||
for point, ray in zip(self.PERSON_POINTS, rays):
|
||||
v = point - origin
|
||||
# Distance from the true 3D point to the recovered ray ~ 0, and
|
||||
# the point sits in FRONT of the camera along the ray.
|
||||
dist_to_ray = np.linalg.norm(v - np.dot(v, ray) * ray)
|
||||
assert dist_to_ray < 1e-4
|
||||
assert np.dot(v, ray) > 0
|
||||
|
||||
def test_resolution_scaling(self, scene):
|
||||
# Collection camera runs 640x360 while the bundle was made at
|
||||
# 1280x720 -- normalized keypoints must land on the same rays.
|
||||
r_gt, c_gt, _, _ = scene
|
||||
img = project_room_points(self.PERSON_POINTS, r_gt, c_gt)
|
||||
kps_norm = (img / np.array([IMG_W, IMG_H])).tolist()
|
||||
|
||||
ctx = cal.CalibrationContext(make_bundle(r_gt, c_gt), 640, 360)
|
||||
origin, rays = ctx.transform_keypoints(kps_norm)
|
||||
for point, ray in zip(self.PERSON_POINTS, rays):
|
||||
v = point - origin
|
||||
assert np.linalg.norm(v - np.dot(v, ray) * ray) < 1e-4
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# collect-ground-truth record path (import-level; no camera loop)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestRecordAugmentation:
|
||||
LEGACY_RECORD = {
|
||||
"ts_ns": 1775300000000000000,
|
||||
"keypoints": [[0.45, 0.12]] * 17,
|
||||
"confidence": 0.92,
|
||||
"n_visible": 14,
|
||||
"n_persons": 1,
|
||||
}
|
||||
|
||||
def test_no_calibration_is_byte_identical(self):
|
||||
# The collector's no---calibration path must emit exactly the
|
||||
# original ADR-079 JSONL line (back-compat guarantee).
|
||||
record = json.loads(json.dumps(self.LEGACY_RECORD))
|
||||
before = json.dumps(record)
|
||||
out = cal.augment_record(record, None)
|
||||
assert out is record
|
||||
assert json.dumps(out) == before
|
||||
assert set(out.keys()) == {"ts_ns", "keypoints", "confidence",
|
||||
"n_visible", "n_persons"}
|
||||
|
||||
def test_calibrated_record_gains_room_fields(self, scene):
|
||||
r_gt, c_gt, _, _ = scene
|
||||
bundle = make_bundle(r_gt, c_gt)
|
||||
ctx = cal.CalibrationContext(bundle, IMG_W, IMG_H)
|
||||
|
||||
record = json.loads(json.dumps(self.LEGACY_RECORD))
|
||||
out = cal.augment_record(record, ctx)
|
||||
|
||||
# Raw image coords preserved untouched; room representation added.
|
||||
assert out["keypoints"] == self.LEGACY_RECORD["keypoints"]
|
||||
assert len(out["keypoints_room"]) == 17
|
||||
assert all(len(ray) == 3 for ray in out["keypoints_room"])
|
||||
assert out["calibration_id"] == cal.calibration_id(bundle)
|
||||
assert out["transceiver_geometry"] == bundle["transceiver_geometry"]
|
||||
assert len(out["camera_origin_room"]) == 3
|
||||
json.dumps(out) # remains JSONL-serializable
|
||||
|
||||
def test_empty_keypoints_record(self, scene):
|
||||
r_gt, c_gt, _, _ = scene
|
||||
ctx = cal.CalibrationContext(make_bundle(r_gt, c_gt), IMG_W, IMG_H)
|
||||
record = {"ts_ns": 1, "keypoints": [], "confidence": 0.0,
|
||||
"n_visible": 0, "n_persons": 0}
|
||||
out = cal.augment_record(record, ctx)
|
||||
assert out["keypoints_room"] == []
|
||||
assert "calibration_id" in out
|
||||
Generated
+17
-12
@@ -7328,9 +7328,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ruvector-attention"
|
||||
version = "2.0.4"
|
||||
version = "2.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb4233c1cecd0ea826d95b787065b398489328885042247ff5ffcbb774e864ff"
|
||||
checksum = "a92e8e456458188d04aee946579aa7cf96d7b8f276cbf6094532b2c3f6d8cc0b"
|
||||
dependencies = [
|
||||
"rand 0.8.5",
|
||||
"rayon",
|
||||
@@ -7395,14 +7395,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ruvector-gnn"
|
||||
version = "2.0.5"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e17c1cf1ff3380026b299ff3c1ba3a5685c3d8d54700e6ab0b585b6cec21d7b"
|
||||
checksum = "a251f9ced8d3231395d922369edc803ef0fc513c7776128f7b4ef21f20dd1f4b"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"dashmap",
|
||||
"libc",
|
||||
"ndarray 0.16.1",
|
||||
"ndarray 0.17.2",
|
||||
"parking_lot",
|
||||
"rand 0.8.5",
|
||||
"rand_distr 0.4.3",
|
||||
@@ -7415,9 +7415,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ruvector-mincut"
|
||||
version = "2.0.4"
|
||||
version = "2.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6d62e10cbb7d80b1e2b72d55c1e3eb7f0c4c5e3f31984bc3baa9b7a02700741e"
|
||||
checksum = "d60947433f740d0f589a2911d7b72a02e07a916e7257e478b14386f0ff068fb7"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"crossbeam",
|
||||
@@ -7437,9 +7437,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ruvector-solver"
|
||||
version = "2.0.4"
|
||||
version = "2.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce69cbde4ee5747281edb1d987a8292940397723924262b6218fc19022cbf687"
|
||||
checksum = "9be7c4f61940ae8b451f88b9a629a08ee8ee5c8e6b00ab96ca10ecf59e70f558"
|
||||
dependencies = [
|
||||
"dashmap",
|
||||
"getrandom 0.2.17",
|
||||
@@ -10910,6 +10910,7 @@ version = "0.3.0"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"criterion",
|
||||
"ruvector-mincut",
|
||||
"wifi-densepose-bfld",
|
||||
"wifi-densepose-core",
|
||||
"wifi-densepose-geo",
|
||||
@@ -11040,7 +11041,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",
|
||||
@@ -11079,9 +11080,13 @@ dependencies = [
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"ureq 2.12.1",
|
||||
"wifi-densepose-bfld",
|
||||
"wifi-densepose-engine",
|
||||
"wifi-densepose-geo",
|
||||
"wifi-densepose-hardware",
|
||||
"wifi-densepose-signal",
|
||||
"wifi-densepose-wifiscan",
|
||||
"wifi-densepose-worldgraph",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -11098,7 +11103,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 +11134,7 @@ dependencies = [
|
||||
"num-traits",
|
||||
"petgraph",
|
||||
"proptest",
|
||||
"ruvector-attention 2.0.4",
|
||||
"ruvector-attention 2.1.0",
|
||||
"ruvector-attn-mincut",
|
||||
"ruvector-mincut",
|
||||
"ruvector-solver",
|
||||
|
||||
+6
-5
@@ -187,15 +187,16 @@ midstreamer-temporal-compare = "0.2"
|
||||
midstreamer-attractor = "0.2"
|
||||
|
||||
# ruvector integration (published on crates.io)
|
||||
# Vendored at v2.1.0 in vendor/ruvector; using crates.io versions until published.
|
||||
# Vendored at origin/main (a083bd77f) in vendor/ruvector; using crates.io versions
|
||||
# until published. Bumps per ADR-152 §2.6 (2026-06-10 vendor sync survey).
|
||||
ruvector-core = "2.2.0"
|
||||
ruvector-mincut = "2.0.4"
|
||||
ruvector-mincut = "2.0.6"
|
||||
ruvector-attn-mincut = "2.0.4"
|
||||
ruvector-temporal-tensor = "2.0.6"
|
||||
ruvector-solver = "2.0.4"
|
||||
ruvector-attention = "2.0.4"
|
||||
ruvector-solver = "2.0.6"
|
||||
ruvector-attention = "2.1.0"
|
||||
ruvector-crv = "0.1.1"
|
||||
ruvector-gnn = { version = "2.0.5", default-features = false }
|
||||
ruvector-gnn = { version = "2.2.0", default-features = false }
|
||||
|
||||
|
||||
# Internal crates
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::geometry::NodeGeometry;
|
||||
|
||||
/// Coarse posture an anchor establishes.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Posture {
|
||||
@@ -96,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,
|
||||
}
|
||||
}
|
||||
@@ -165,6 +165,17 @@ pub enum EnrollmentEvent {
|
||||
/// The accepted anchor.
|
||||
anchor: Anchor,
|
||||
},
|
||||
/// Transceiver geometry recorded for the session's nodes (ADR-152 §2.1.1).
|
||||
/// Typically appended right after `Started`; a later event supersedes an
|
||||
/// earlier one (latest wins), so a geometry correction is an append, not a
|
||||
/// rewrite. Sessions persisted before this variant existed replay cleanly —
|
||||
/// the variant is additive to the externally-tagged event encoding.
|
||||
GeometryRecorded {
|
||||
/// Per-node geometry records.
|
||||
geometry: Vec<NodeGeometry>,
|
||||
/// Unix seconds.
|
||||
at: i64,
|
||||
},
|
||||
/// An anchor failed the gate (re-prompt).
|
||||
AnchorRejected {
|
||||
/// Which anchor.
|
||||
@@ -230,6 +241,21 @@ impl EnrollmentSession {
|
||||
out
|
||||
}
|
||||
|
||||
/// Record the session's transceiver geometry (ADR-152 §2.1.1) — appends a
|
||||
/// [`EnrollmentEvent::GeometryRecorded`] event; the latest recording wins.
|
||||
pub fn record_geometry(&mut self, geometry: Vec<NodeGeometry>, at: i64) {
|
||||
self.apply(EnrollmentEvent::GeometryRecorded { geometry, at });
|
||||
}
|
||||
|
||||
/// The geometry snapshot in effect (latest `GeometryRecorded` event), if
|
||||
/// any was recorded. Derived from the event log, never stored separately.
|
||||
pub fn geometry(&self) -> Option<&[NodeGeometry]> {
|
||||
self.events.iter().rev().find_map(|ev| match ev {
|
||||
EnrollmentEvent::GeometryRecorded { geometry, .. } => Some(geometry.as_slice()),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The next anchor in the canonical sequence not yet accepted, if any.
|
||||
pub fn next_anchor(&self) -> Option<AnchorLabel> {
|
||||
let accepted = self.accepted_anchors();
|
||||
@@ -241,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.
|
||||
@@ -340,6 +363,47 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn geometry_recorded_latest_wins_and_roundtrips() {
|
||||
let mut s = EnrollmentSession::new("r", "b", 0);
|
||||
assert!(s.geometry().is_none(), "no geometry before recording");
|
||||
|
||||
s.record_geometry(vec![NodeGeometry::unknown(1)], 5);
|
||||
let corrected = 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)
|
||||
.with_distance(1, 3.0),
|
||||
];
|
||||
s.record_geometry(corrected.clone(), 10);
|
||||
|
||||
// Latest recording wins, derived from the event log.
|
||||
assert_eq!(s.geometry(), Some(corrected.as_slice()));
|
||||
|
||||
// The whole session (incl. geometry events) survives persistence.
|
||||
let json = serde_json::to_string(&s).unwrap();
|
||||
let back: EnrollmentSession = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back.geometry(), Some(corrected.as_slice()));
|
||||
assert_eq!(back.events.len(), s.events.len());
|
||||
}
|
||||
|
||||
/// Sessions persisted BEFORE the GeometryRecorded variant existed must
|
||||
/// deserialize cleanly and report no geometry (ADR-152 schema-compat rule).
|
||||
#[test]
|
||||
fn pre_geometry_session_json_loads() {
|
||||
let old_json = r#"{
|
||||
"room_id": "r",
|
||||
"baseline_id": "b",
|
||||
"events": [
|
||||
{"Started": {"room_id": "r", "baseline_id": "b", "at": 0}},
|
||||
{"AnchorRejected": {"label": "sit", "reason": "no person", "at": 1}}
|
||||
]
|
||||
}"#;
|
||||
let s: EnrollmentSession = serde_json::from_str(old_json).unwrap();
|
||||
assert!(s.geometry().is_none());
|
||||
assert_eq!(s.next_anchor(), Some(AnchorLabel::Empty));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn posture_mapping() {
|
||||
assert_eq!(AnchorLabel::StandStill.posture(), Some(Posture::Standing));
|
||||
|
||||
@@ -10,6 +10,7 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::{CalibrationError, Result};
|
||||
use crate::extract::AnchorFeature;
|
||||
use crate::geometry::NodeGeometry;
|
||||
use crate::specialist::{
|
||||
AnomalySpecialist, BreathingSpecialist, HeartbeatSpecialist, PostureSpecialist,
|
||||
PresenceSpecialist, RestlessnessSpecialist, SpecialistKind,
|
||||
@@ -26,6 +27,13 @@ pub struct SpecialistBank {
|
||||
pub trained_at_unix_s: i64,
|
||||
/// Number of anchors used.
|
||||
pub anchor_count: usize,
|
||||
/// Transceiver geometry snapshot the bank was trained under (ADR-152
|
||||
/// §2.1.1). Empty both for banks persisted before geometry existed (serde
|
||||
/// default — same pattern as `PresenceSpecialist::mean_dist_threshold`) and
|
||||
/// for enrollments where no geometry was recorded. Statistical specialists
|
||||
/// ignore it; the ADR-151 P6 LoRA heads will consume it (ADR-152 §2.1.2).
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub geometry: Vec<NodeGeometry>,
|
||||
|
||||
/// Presence gate (requires the `empty` + an occupied anchor).
|
||||
pub presence: Option<PresenceSpecialist>,
|
||||
@@ -65,6 +73,7 @@ impl SpecialistBank {
|
||||
baseline_id: baseline_id.into(),
|
||||
trained_at_unix_s: at_unix_s,
|
||||
anchor_count: anchors.len(),
|
||||
geometry: Vec::new(),
|
||||
presence: PresenceSpecialist::train(anchors),
|
||||
posture: PostureSpecialist::train(anchors),
|
||||
breathing: BreathingSpecialist::default(),
|
||||
@@ -74,6 +83,22 @@ impl SpecialistBank {
|
||||
})
|
||||
}
|
||||
|
||||
/// Attach the enrollment's transceiver-geometry snapshot (ADR-152 §2.1.1),
|
||||
/// builder style — typically `EnrollmentSession::geometry()` at train time.
|
||||
pub fn with_geometry(mut self, geometry: Vec<NodeGeometry>) -> Self {
|
||||
self.geometry = geometry;
|
||||
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
|
||||
@@ -178,6 +203,70 @@ mod tests {
|
||||
assert_eq!(back.anchor_count, 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn geometry_snapshot_roundtrips() {
|
||||
let geometry = vec![
|
||||
NodeGeometry::new(1, "tape-measure").with_position(0.0, 0.0, 1.0),
|
||||
NodeGeometry::unknown(2),
|
||||
];
|
||||
let bank = SpecialistBank::train("r", "base-1", &full_anchors(), 1000)
|
||||
.unwrap()
|
||||
.with_geometry(geometry.clone());
|
||||
let json = bank.to_json().unwrap();
|
||||
let back = SpecialistBank::from_json(&json).unwrap();
|
||||
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.
|
||||
#[test]
|
||||
fn pre_geometry_bank_json_loads() {
|
||||
let old_json = r#"{
|
||||
"room_id": "living-room",
|
||||
"baseline_id": "base-1",
|
||||
"trained_at_unix_s": 1000,
|
||||
"anchor_count": 2,
|
||||
"presence": {"threshold": 5.5, "occupied_var": 10.0},
|
||||
"posture": null,
|
||||
"breathing": {"min_score": 0.0},
|
||||
"heartbeat": {"min_score": 0.0},
|
||||
"restlessness": null,
|
||||
"anomaly": null
|
||||
}"#;
|
||||
let bank = SpecialistBank::from_json(old_json).unwrap();
|
||||
assert!(bank.geometry.is_empty(), "old banks carry no geometry");
|
||||
assert_eq!(bank.room_id, "living-room");
|
||||
assert!(bank.presence.is_some());
|
||||
// And a geometry-free bank serializes without the field (old shape).
|
||||
assert!(!bank.to_json().unwrap().contains("geometry"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn staleness() {
|
||||
let bank = SpecialistBank::train("r", "base-1", &full_anchors(), 1000).unwrap();
|
||||
|
||||
@@ -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,161 @@
|
||||
//! Transceiver-geometry records (ADR-152 §2.1.1, extends ADR-151 Stage 2).
|
||||
//!
|
||||
//! PerceptAlign (ADR-152 F1) diagnosed "coordinate overfitting": pose heads
|
||||
//! trained without an explicit layout model memorise the deployment-specific
|
||||
//! transceiver geometry and break in unseen rooms. The first, cheap half of
|
||||
//! the fix is to *record* the geometry at enrollment so every specialist bank
|
||||
//! knows the layout it was trained under.
|
||||
//!
|
||||
//! This module is the record only. The learned geometry *embeddings* that
|
||||
//! condition specialist heads (ADR-152 §2.1.2) are out of scope until the
|
||||
//! ADR-151 P6 LoRA heads exist — statistical specialists ignore geometry.
|
||||
//!
|
||||
//! Every field is optional **by design**: geometry is captured when the
|
||||
//! operator knows it (tape measure, checkerboard calibration, installer
|
||||
//! floor plan) and omitted when they don't. An all-unknown record is still
|
||||
//! useful — it pins down *which* nodes existed and that geometry was not
|
||||
//! measured, rather than leaving the question open.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Estimated node position in the room frame (meters).
|
||||
///
|
||||
/// The room frame is whatever frame the recording `method` defines (e.g. a
|
||||
/// tape-measure origin at a room corner, or the shared 3D frame of the
|
||||
/// two-checkerboard alignment, ADR-152 §2.1.3). Consistency *within* one
|
||||
/// enrollment is what matters; there is no global frame.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub struct PositionEstimate {
|
||||
/// X coordinate (meters).
|
||||
pub x_m: f32,
|
||||
/// Y coordinate (meters).
|
||||
pub y_m: f32,
|
||||
/// Z coordinate / height (meters).
|
||||
pub z_m: f32,
|
||||
}
|
||||
|
||||
/// Antenna boresight orientation (radians, room frame).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub struct AntennaOrientation {
|
||||
/// Azimuth from the room frame's +X axis, counter-clockwise (radians).
|
||||
pub azimuth_rad: f32,
|
||||
/// Elevation above the horizontal plane (radians).
|
||||
pub elevation_rad: f32,
|
||||
}
|
||||
|
||||
fn unknown_method() -> String {
|
||||
"unknown".to_string()
|
||||
}
|
||||
|
||||
/// Per-node transceiver geometry recorded at enrollment (ADR-152 §2.1.1).
|
||||
///
|
||||
/// Stored in the [`EnrollmentSession`](crate::EnrollmentSession) event log and
|
||||
/// snapshotted into the [`SpecialistBank`](crate::SpecialistBank), so a bank
|
||||
/// always carries the layout it was trained under. Schema-versioned: banks and
|
||||
/// sessions persisted before this record existed deserialize with no geometry
|
||||
/// (serde defaults), same pattern as `PresenceSpecialist::mean_dist_threshold`.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct NodeGeometry {
|
||||
/// Node this record describes (same id space as the multistatic fusion).
|
||||
pub node_id: u8,
|
||||
/// Estimated position, if measured.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub position: Option<PositionEstimate>,
|
||||
/// Antenna orientation, if measured.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub orientation: Option<AntennaOrientation>,
|
||||
/// Known distances to other nodes (node_id → meters). Empty = not measured.
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub distances_m: BTreeMap<u8, f32>,
|
||||
/// How the geometry was obtained — free-form provenance, e.g.
|
||||
/// `"tape-measure"`, `"checkerboard"`, `"floor-plan"`, `"unknown"`.
|
||||
#[serde(default = "unknown_method")]
|
||||
pub method: String,
|
||||
}
|
||||
|
||||
impl NodeGeometry {
|
||||
/// A record with everything unknown except the node id.
|
||||
pub fn unknown(node_id: u8) -> Self {
|
||||
Self::new(node_id, "unknown")
|
||||
}
|
||||
|
||||
/// A record with no measurements yet, tagged with its provenance method.
|
||||
pub fn new(node_id: u8, method: impl Into<String>) -> Self {
|
||||
Self {
|
||||
node_id,
|
||||
position: None,
|
||||
orientation: None,
|
||||
distances_m: BTreeMap::new(),
|
||||
method: method.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the position estimate (builder style).
|
||||
pub fn with_position(mut self, x_m: f32, y_m: f32, z_m: f32) -> Self {
|
||||
self.position = Some(PositionEstimate { x_m, y_m, z_m });
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the antenna orientation (builder style).
|
||||
pub fn with_orientation(mut self, azimuth_rad: f32, elevation_rad: f32) -> Self {
|
||||
self.orientation = Some(AntennaOrientation {
|
||||
azimuth_rad,
|
||||
elevation_rad,
|
||||
});
|
||||
self
|
||||
}
|
||||
|
||||
/// Record a known distance to another node (builder style).
|
||||
pub fn with_distance(mut self, other_node_id: u8, meters: f32) -> Self {
|
||||
self.distances_m.insert(other_node_id, meters);
|
||||
self
|
||||
}
|
||||
|
||||
/// `true` when nothing beyond the node id was measured.
|
||||
pub fn is_unmeasured(&self) -> bool {
|
||||
self.position.is_none() && self.orientation.is_none() && self.distances_m.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn full_record_roundtrips() {
|
||||
let g = NodeGeometry::new(1, "tape-measure")
|
||||
.with_position(0.5, 2.0, 1.2)
|
||||
.with_orientation(std::f32::consts::FRAC_PI_2, 0.0)
|
||||
.with_distance(2, 3.4);
|
||||
let json = serde_json::to_string(&g).unwrap();
|
||||
let back: NodeGeometry = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back, g);
|
||||
assert_eq!(back.distances_m.get(&2), Some(&3.4));
|
||||
assert!(!back.is_unmeasured());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_optional_empty_roundtrips() {
|
||||
let g = NodeGeometry::unknown(7);
|
||||
assert!(g.is_unmeasured());
|
||||
let json = serde_json::to_string(&g).unwrap();
|
||||
// Optional fields must be omitted, not serialized as null/empty.
|
||||
assert!(!json.contains("position"));
|
||||
assert!(!json.contains("orientation"));
|
||||
assert!(!json.contains("distances_m"));
|
||||
let back: NodeGeometry = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back, g);
|
||||
assert_eq!(back.method, "unknown");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn minimal_json_defaults_cleanly() {
|
||||
// A record written by a producer that only knew the node id.
|
||||
let g: NodeGeometry = serde_json::from_str(r#"{"node_id":3}"#).unwrap();
|
||||
assert_eq!(g.node_id, 3);
|
||||
assert!(g.is_unmeasured());
|
||||
assert_eq!(g.method, "unknown");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,499 @@
|
||||
//! Geometry embedding — deterministic featurization of transceiver layout
|
||||
//! (ADR-152 §2.1.2, the second half of the PerceptAlign fix).
|
||||
//!
|
||||
//! §2.1.1 ([`geometry`](crate::geometry)) *records* the layout; this module
|
||||
//! turns that record into a fixed-length conditioning vector. PerceptAlign
|
||||
//! fuses transceiver-position embeddings with CSI features so pose heads stop
|
||||
//! memorising the deployment layout; transplanted to our per-room banks, the
|
||||
//! ADR-151 P6 LoRA heads will concatenate this vector with the backbone
|
||||
//! embedding. Statistical specialists (current) ignore it. The crate is pure
|
||||
//! Rust and edge-deployable (no torch/candle), so the "embedding" is **not a
|
||||
//! trained network** — it is a deterministic, well-conditioned featurization;
|
||||
//! the learned part (if any) lives in the head that consumes it.
|
||||
//!
|
||||
//! Properties, by construction: **fixed dimension** ([`GeometryEmbedding::DIM`]
|
||||
//! = 32) for any node count (designed for 1..=8; more nodes still aggregate,
|
||||
//! only the per-node flag slots truncate); **permutation-invariant** (nodes
|
||||
//! sorted by `node_id`; aggregates are order-free); and **total** — missing
|
||||
//! data degrades gracefully: an all-unknown layout (or empty slice) yields a
|
||||
//! well-defined vector, never `NaN`/`inf`; adversarial inputs (non-finite
|
||||
//! coordinates, absurd magnitudes) are treated as unmeasured.
|
||||
//!
|
||||
//! ## Slot layout (v1)
|
||||
//!
|
||||
//! Positions/distances are raw meters (room-scale values are already
|
||||
//! O(1)–O(10)); angles in radians; fractions in `[0, 1]`. Unmeasurable
|
||||
//! slots are `0.0`.
|
||||
//!
|
||||
//! | Slot | Content | Units / range |
|
||||
//! |-------|---------|----------------|
|
||||
//! | 0 | node count / 8 | `[0, 2]` (clamped; 8 nodes → 1.0) |
|
||||
//! | 1 | fraction of nodes with a position | `[0, 1]` |
|
||||
//! | 2 | fraction of nodes with an orientation | `[0, 1]` |
|
||||
//! | 3 | fraction of nodes with ≥1 measured inter-node distance | `[0, 1]` |
|
||||
//! | 4–6 | position centroid (x, y, z) | m, clamped ±[`MAX_COORD_M`] |
|
||||
//! | 7–9 | position std-dev per axis (x, y, z) | m, `[0,` [`MAX_COORD_M`]`]` |
|
||||
//! | 10–12 | pairwise position distance min / mean / max | m |
|
||||
//! | 13–15 | inter-node distance min / mean / max — measured `distances_m`, falling back to position-derived distance per pair | m |
|
||||
//! | 16 | measured-distance pair coverage (measured pairs / possible pairs) | `[0, 1]` |
|
||||
//! | 17–18 | azimuth circular mean resultant vector (cos, sin components) | `[-1, 1]` |
|
||||
//! | 19 | azimuth concentration (mean resultant length `R`; 1 = all boresights parallel) | `[0, 1]` |
|
||||
//! | 20 | mean elevation | rad, `[-π/2, π/2]` |
|
||||
//! | 21–22 | geometric diversity: eigenvalue ratios `λ2/λ1`, `λ3/λ1` of the position covariance — 0 = collinear/degenerate, →1 = isotropic spread (chosen over polygon area: defined for any node count, no 2-D planarity assumption) | `[0, 1]` |
|
||||
//! | 23 | dominant spread scale `sqrt(λ1)` | m |
|
||||
//! | 24–31 | per-node measurement flags, nodes sorted by `node_id`, rank `i` → slot `24+i` (first 8 nodes): `0` = no node at this rank, else `0.25` (node exists) `+0.25` (position) `+0.25` (orientation) `+0.25` (≥1 measured distance) | `{0}` ∪ `[0.25, 1]` |
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::geometry::NodeGeometry;
|
||||
|
||||
/// Coordinates / distances beyond this magnitude (meters) are treated as
|
||||
/// unmeasured — rooms are not kilometer-scale, and the guard keeps
|
||||
/// adversarial values from overflowing the covariance into `inf`.
|
||||
pub const MAX_COORD_M: f32 = 1_000.0;
|
||||
|
||||
/// Number of per-node flag slots (slots 24..32); designed node count 1..=8.
|
||||
const NODE_SLOTS: usize = 8;
|
||||
|
||||
fn schema_v1() -> u32 {
|
||||
GeometryEmbedding::SCHEMA_VERSION
|
||||
}
|
||||
|
||||
/// Fixed-length featurization of a room's transceiver layout (ADR-152 §2.1.2).
|
||||
///
|
||||
/// Computed deterministically from the [`NodeGeometry`] snapshot via
|
||||
/// [`GeometryEmbedding::from_nodes`]; the conditioning input the ADR-151 P6
|
||||
/// LoRA heads concatenate with the backbone embedding. Not stored in the bank
|
||||
/// — derive it via [`SpecialistBank::geometry_embedding`](crate::SpecialistBank::geometry_embedding)
|
||||
/// — but schema-versioned and serde-serializable (the `NodeGeometry` compat
|
||||
/// pattern) for callers that snapshot it alongside trained head weights.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct GeometryEmbedding {
|
||||
/// Slot-layout version; bump when the slot table changes meaning.
|
||||
#[serde(default = "schema_v1")]
|
||||
pub schema_version: u32,
|
||||
/// The embedding vector — see the module docs for the slot table.
|
||||
/// Invariant: every value is finite (never `NaN`/`inf`).
|
||||
pub values: [f32; GeometryEmbedding::DIM],
|
||||
}
|
||||
|
||||
impl Default for GeometryEmbedding {
|
||||
/// All slots zero — the embedding of an empty layout.
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
schema_version: Self::SCHEMA_VERSION,
|
||||
values: [0.0; Self::DIM],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl GeometryEmbedding {
|
||||
/// Output dimension. Fixed regardless of node count.
|
||||
pub const DIM: usize = 32;
|
||||
|
||||
/// Current slot-layout version.
|
||||
pub const SCHEMA_VERSION: u32 = 1;
|
||||
|
||||
/// The embedding as a slice (always [`Self::DIM`] long).
|
||||
pub fn as_slice(&self) -> &[f32] {
|
||||
&self.values
|
||||
}
|
||||
|
||||
/// Compute the embedding from a geometry snapshot. Permutation-invariant
|
||||
/// (nodes are sorted by `node_id` internally) and total: any input —
|
||||
/// empty, all-unknown, non-finite — produces a fully finite vector.
|
||||
pub fn from_nodes(nodes: &[NodeGeometry]) -> Self {
|
||||
let mut v = [0.0f32; Self::DIM];
|
||||
|
||||
// Permutation invariance: order by node_id before per-node slots.
|
||||
let mut sorted: Vec<&NodeGeometry> = nodes.iter().collect();
|
||||
sorted.sort_by_key(|g| g.node_id);
|
||||
let n = sorted.len();
|
||||
if n == 0 {
|
||||
return Self::default();
|
||||
}
|
||||
|
||||
// Sanitized views: a measurement with non-finite or absurd components
|
||||
// counts as not taken at all.
|
||||
let positions: Vec<Option<[f32; 3]>> = sorted.iter().map(|g| valid_position(g)).collect();
|
||||
let orientations: Vec<Option<(f32, f32)>> =
|
||||
sorted.iter().map(|g| valid_orientation(g)).collect();
|
||||
let measured = measured_pairs(&sorted);
|
||||
let node_has_dist = |id: u8| measured.keys().any(|&(a, b)| a == id || b == id);
|
||||
let has_dist: Vec<bool> = sorted.iter().map(|g| node_has_dist(g.node_id)).collect();
|
||||
|
||||
// Slots 0–3: node count + measurement-presence fractions.
|
||||
let nf = n as f32;
|
||||
v[0] = (nf / NODE_SLOTS as f32).min(2.0);
|
||||
v[1] = positions.iter().flatten().count() as f32 / nf;
|
||||
v[2] = orientations.iter().flatten().count() as f32 / nf;
|
||||
v[3] = has_dist.iter().filter(|&&d| d).count() as f32 / nf;
|
||||
|
||||
// Slots 4–9: centroid + per-axis std of the known positions.
|
||||
let known: Vec<[f32; 3]> = positions.iter().flatten().copied().collect();
|
||||
if !known.is_empty() {
|
||||
let kf = known.len() as f32;
|
||||
let mut centroid = [0.0f32; 3];
|
||||
for p in &known {
|
||||
for (c, x) in centroid.iter_mut().zip(p) {
|
||||
*c += x / kf;
|
||||
}
|
||||
}
|
||||
for axis in 0..3 {
|
||||
v[4 + axis] = clamp_m(centroid[axis]);
|
||||
let mut var = 0.0;
|
||||
for p in &known {
|
||||
var += (p[axis] - centroid[axis]).powi(2) / kf;
|
||||
}
|
||||
v[7 + axis] = clamp_m(var.max(0.0).sqrt());
|
||||
}
|
||||
|
||||
// Slots 10–12: pairwise position distance stats.
|
||||
let mut dists = Vec::new();
|
||||
for i in 0..known.len() {
|
||||
for j in (i + 1)..known.len() {
|
||||
dists.push(euclidean(&known[i], &known[j]));
|
||||
}
|
||||
}
|
||||
write_min_mean_max(&mut v, 10, &dists);
|
||||
|
||||
// Slots 21–23: geometric diversity from the position covariance
|
||||
// eigenstructure (see module docs for why over polygon area).
|
||||
let (l1, l2, l3) = covariance_eigenvalues(&known, ¢roid);
|
||||
if l1 > f32::EPSILON {
|
||||
v[21] = (l2 / l1).clamp(0.0, 1.0);
|
||||
v[22] = (l3 / l1).clamp(0.0, 1.0);
|
||||
}
|
||||
v[23] = clamp_m(l1.max(0.0).sqrt());
|
||||
}
|
||||
|
||||
// Slots 13–16: inter-node distances — measured first, position fallback.
|
||||
let mut inter = Vec::new();
|
||||
for i in 0..n {
|
||||
for j in (i + 1)..n {
|
||||
let key = pair_key(sorted[i].node_id, sorted[j].node_id);
|
||||
if let Some(&d) = measured.get(&key) {
|
||||
inter.push(d);
|
||||
} else if let (Some(a), Some(b)) = (&positions[i], &positions[j]) {
|
||||
inter.push(euclidean(a, b));
|
||||
}
|
||||
}
|
||||
}
|
||||
write_min_mean_max(&mut v, 13, &inter);
|
||||
let possible_pairs = n * n.saturating_sub(1) / 2;
|
||||
if possible_pairs > 0 {
|
||||
v[16] = (measured.len() as f32 / possible_pairs as f32).clamp(0.0, 1.0);
|
||||
}
|
||||
|
||||
// Slots 17–20: orientation statistics (circular mean of azimuth).
|
||||
let known_orient: Vec<(f32, f32)> = orientations.iter().flatten().copied().collect();
|
||||
if !known_orient.is_empty() {
|
||||
let of = known_orient.len() as f32;
|
||||
let c = known_orient.iter().map(|(az, _)| az.cos()).sum::<f32>() / of;
|
||||
let s = known_orient.iter().map(|(az, _)| az.sin()).sum::<f32>() / of;
|
||||
v[17] = c.clamp(-1.0, 1.0);
|
||||
v[18] = s.clamp(-1.0, 1.0);
|
||||
v[19] = (c * c + s * s).sqrt().clamp(0.0, 1.0);
|
||||
let el = known_orient.iter().map(|(_, e)| e).sum::<f32>() / of;
|
||||
v[20] = el.clamp(-std::f32::consts::FRAC_PI_2, std::f32::consts::FRAC_PI_2);
|
||||
}
|
||||
|
||||
// Slots 24–31: per-node measurement flags (first NODE_SLOTS by id).
|
||||
for i in 0..n.min(NODE_SLOTS) {
|
||||
v[24 + i] = 0.25
|
||||
+ 0.25 * f32::from(positions[i].is_some() as u8)
|
||||
+ 0.25 * f32::from(orientations[i].is_some() as u8)
|
||||
+ 0.25 * f32::from(has_dist[i] as u8);
|
||||
}
|
||||
|
||||
// The finite invariant must hold whatever happened above.
|
||||
for x in &mut v {
|
||||
if !x.is_finite() {
|
||||
*x = 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
Self {
|
||||
schema_version: Self::SCHEMA_VERSION,
|
||||
values: v,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A position whose components are all finite and room-scale, else `None`.
|
||||
fn valid_position(g: &NodeGeometry) -> Option<[f32; 3]> {
|
||||
let p = g.position?;
|
||||
let ok = |c: f32| c.is_finite() && c.abs() <= MAX_COORD_M;
|
||||
(ok(p.x_m) && ok(p.y_m) && ok(p.z_m)).then_some([p.x_m, p.y_m, p.z_m])
|
||||
}
|
||||
|
||||
/// An orientation whose angles are both finite, else `None`.
|
||||
fn valid_orientation(g: &NodeGeometry) -> Option<(f32, f32)> {
|
||||
let o = g.orientation?;
|
||||
let ok = o.azimuth_rad.is_finite() && o.elevation_rad.is_finite();
|
||||
ok.then_some((o.azimuth_rad, o.elevation_rad))
|
||||
}
|
||||
|
||||
/// Canonical unordered pair key.
|
||||
fn pair_key(a: u8, b: u8) -> (u8, u8) {
|
||||
(a.min(b), a.max(b))
|
||||
}
|
||||
|
||||
/// Valid measured distances between *enrolled* nodes, deduplicated to
|
||||
/// unordered pairs (both directions recorded → averaged); distances to
|
||||
/// non-enrolled node ids are ignored.
|
||||
fn measured_pairs(sorted: &[&NodeGeometry]) -> BTreeMap<(u8, u8), f32> {
|
||||
let ids: Vec<u8> = sorted.iter().map(|g| g.node_id).collect();
|
||||
let mut sums: BTreeMap<(u8, u8), (f32, u32)> = BTreeMap::new();
|
||||
for g in sorted {
|
||||
for (&other, &d) in &g.distances_m {
|
||||
let pair_ok = other != g.node_id && ids.contains(&other);
|
||||
if pair_ok && d.is_finite() && d > 0.0 && d <= MAX_COORD_M {
|
||||
let e = sums.entry(pair_key(g.node_id, other)).or_insert((0.0, 0));
|
||||
e.0 += d;
|
||||
e.1 += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
sums.into_iter()
|
||||
.map(|(k, (sum, n))| (k, sum / n as f32))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn euclidean(a: &[f32; 3], b: &[f32; 3]) -> f32 {
|
||||
let mut d2 = 0.0;
|
||||
for k in 0..3 {
|
||||
d2 += (a[k] - b[k]).powi(2);
|
||||
}
|
||||
d2.sqrt()
|
||||
}
|
||||
|
||||
/// Write min/mean/max of a sample into slots `base..base+3` (left at zero
|
||||
/// when the sample is empty), clamped to the meters range.
|
||||
fn write_min_mean_max(v: &mut [f32; GeometryEmbedding::DIM], base: usize, xs: &[f32]) {
|
||||
if xs.is_empty() {
|
||||
return;
|
||||
}
|
||||
let (mut min, mut max, mut sum) = (f32::INFINITY, f32::NEG_INFINITY, 0.0);
|
||||
for &x in xs {
|
||||
min = min.min(x);
|
||||
max = max.max(x);
|
||||
sum += x;
|
||||
}
|
||||
v[base] = clamp_m(min);
|
||||
v[base + 1] = clamp_m(sum / xs.len() as f32);
|
||||
v[base + 2] = clamp_m(max);
|
||||
}
|
||||
|
||||
/// Clamp a meters-valued slot into ±[`MAX_COORD_M`], mapping non-finite to 0.
|
||||
fn clamp_m(x: f32) -> f32 {
|
||||
if x.is_finite() {
|
||||
x.clamp(-MAX_COORD_M, MAX_COORD_M)
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Eigenvalues `λ1 ≥ λ2 ≥ λ3 ≥ 0` of the 3×3 position covariance, via the
|
||||
/// closed-form trigonometric solution for symmetric matrices (no linear-
|
||||
/// algebra dependency; f64 internally for conditioning).
|
||||
fn covariance_eigenvalues(points: &[[f32; 3]], centroid: &[f32; 3]) -> (f32, f32, f32) {
|
||||
let nf = points.len() as f64;
|
||||
// Upper triangle of the symmetric covariance: (xx, yy, zz, xy, xz, yz).
|
||||
const IJ: [(usize, usize); 6] = [(0, 0), (1, 1), (2, 2), (0, 1), (0, 2), (1, 2)];
|
||||
let mut m = [0.0f64; 6];
|
||||
for p in points {
|
||||
let d: [f64; 3] = std::array::from_fn(|i| (p[i] - centroid[i]) as f64);
|
||||
for (k, &(i, j)) in IJ.iter().enumerate() {
|
||||
m[k] += d[i] * d[j] / nf;
|
||||
}
|
||||
}
|
||||
let (a, b, c, d, e, f) = (m[0], m[1], m[2], m[3], m[4], m[5]);
|
||||
let p1 = d * d + e * e + f * f;
|
||||
let q = (a + b + c) / 3.0;
|
||||
let p2 = (a - q).powi(2) + (b - q).powi(2) + (c - q).powi(2) + 2.0 * p1;
|
||||
let p = (p2 / 6.0).sqrt();
|
||||
let (l1, l2, l3) = if p < 1e-12 {
|
||||
(q, q, q) // (Near-)isotropic: all eigenvalues equal — diagonal incl.
|
||||
} else {
|
||||
// r = det((M - qI)/p) / 2, clamped into acos' domain.
|
||||
let (ba, bb, bc) = ((a - q) / p, (b - q) / p, (c - q) / p);
|
||||
let (bd, be, bf) = (d / p, e / p, f / p);
|
||||
let det = ba * (bb * bc - bf * bf) - bd * (bd * bc - bf * be) + be * (bd * bf - bb * be);
|
||||
let phi = (det / 2.0).clamp(-1.0, 1.0).acos() / 3.0;
|
||||
let e1 = q + 2.0 * p * phi.cos();
|
||||
let e3 = q + 2.0 * p * (phi + 2.0 * std::f64::consts::PI / 3.0).cos();
|
||||
(e1, 3.0 * q - e1 - e3, e3)
|
||||
};
|
||||
// PSD matrix: tiny negatives are numerical noise — clamp.
|
||||
(l1.max(0.0) as f32, l2.max(0.0) as f32, l3.max(0.0) as f32)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A fully-measured node at `(x, y, 1)` with boresight toward +Y.
|
||||
fn node(id: u8, x: f32, y: f32) -> NodeGeometry {
|
||||
NodeGeometry::new(id, "tape-measure")
|
||||
.with_position(x, y, 1.0)
|
||||
.with_orientation(std::f32::consts::FRAC_PI_2, 0.1)
|
||||
}
|
||||
|
||||
/// 3 nodes on a 3-4-5 triangle; the (1,2) edge also measured by tape.
|
||||
fn full_layout() -> Vec<NodeGeometry> {
|
||||
vec![
|
||||
node(1, 0.0, 0.0).with_distance(2, 3.0),
|
||||
node(2, 3.0, 0.0).with_distance(1, 3.0),
|
||||
node(3, 0.0, 4.0),
|
||||
]
|
||||
}
|
||||
|
||||
fn assert_all_finite(e: &GeometryEmbedding) {
|
||||
for (i, x) in e.values.iter().enumerate() {
|
||||
assert!(x.is_finite(), "slot {i} is not finite: {x}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dimension_stable_and_empty_input_is_all_zero() {
|
||||
assert_eq!(GeometryEmbedding::DIM, 32);
|
||||
let full = GeometryEmbedding::from_nodes(&full_layout());
|
||||
assert_eq!(full.as_slice().len(), GeometryEmbedding::DIM);
|
||||
let empty = GeometryEmbedding::from_nodes(&[]);
|
||||
assert_eq!(empty, GeometryEmbedding::default(), "all-zero");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_unknown_layout_degrades_gracefully() {
|
||||
let nodes = vec![NodeGeometry::unknown(1), NodeGeometry::unknown(2)];
|
||||
let e = GeometryEmbedding::from_nodes(&nodes);
|
||||
assert_all_finite(&e);
|
||||
assert!((e.values[0] - 2.0 / 8.0).abs() < 1e-6, "node count slot");
|
||||
// No measurements: presence fractions and all stats at zero …
|
||||
for slot in 1..24 {
|
||||
assert_eq!(e.values[slot], 0.0, "slot {slot} should be 0");
|
||||
}
|
||||
// … but the per-node existence flags still say two nodes were there.
|
||||
assert_eq!(&e.values[24..27], &[0.25, 0.25, 0.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_node_has_no_pairwise_stats() {
|
||||
let n = NodeGeometry::new(5, "t")
|
||||
.with_position(1.0, 2.0, 1.5)
|
||||
.with_orientation(0.0, 0.0);
|
||||
let e = GeometryEmbedding::from_nodes(&[n]);
|
||||
assert_all_finite(&e);
|
||||
assert_eq!(&e.values[4..7], &[1.0, 2.0, 1.5], "centroid = the node");
|
||||
assert_eq!(&e.values[7..10], &[0.0, 0.0, 0.0], "no spread");
|
||||
assert_eq!(&e.values[10..17], &[0.0; 7], "no pairs");
|
||||
assert_eq!(e.values[17], 1.0, "cos(0)");
|
||||
assert_eq!(e.values[19], 1.0, "single boresight is fully concentrated");
|
||||
assert_eq!(e.values[24], 0.75, "position + orientation, no distances");
|
||||
}
|
||||
|
||||
/// Full-measurement layout: every slot family lands where the geometry
|
||||
/// says it should, and shuffling node order changes nothing.
|
||||
#[test]
|
||||
fn full_layout_statistics_and_permutation_invariance() {
|
||||
let nodes = full_layout();
|
||||
let e = GeometryEmbedding::from_nodes(&nodes);
|
||||
assert!((e.values[1] - 1.0).abs() < 1e-6, "all positioned");
|
||||
assert!((e.values[2] - 1.0).abs() < 1e-6, "all oriented");
|
||||
// 3-4-5 triangle: position-pair distances {3, 4, 5}.
|
||||
assert!((e.values[10] - 3.0).abs() < 1e-5, "min dist");
|
||||
assert!((e.values[11] - 4.0).abs() < 1e-5, "mean dist");
|
||||
assert!((e.values[12] - 5.0).abs() < 1e-5, "max dist");
|
||||
// Inter-node stats: pair (1,2) measured, (1,3)/(2,3) from positions.
|
||||
assert!((e.values[14] - 4.0).abs() < 1e-5, "mean inter-node dist");
|
||||
assert!((e.values[16] - 1.0 / 3.0).abs() < 1e-6, "1 of 3 measured");
|
||||
// Parallel boresights: fully concentrated, pointing +Y.
|
||||
assert!(e.values[17].abs() < 1e-6, "cos(π/2)");
|
||||
assert!((e.values[18] - 1.0).abs() < 1e-5, "sin(π/2)");
|
||||
assert!((e.values[19] - 1.0).abs() < 1e-5, "concentration");
|
||||
assert!((e.values[20] - 0.1).abs() < 1e-5, "mean elevation");
|
||||
// Coplanar triangle: λ1 ≈ 4.32, λ2 ≈ 1.23 (3-4-5 covariance), λ3 = 0.
|
||||
assert!((e.values[21] - 0.286).abs() < 0.01, "λ2/λ1 planar");
|
||||
assert!(e.values[22] < 1e-5, "λ3/λ1 ≈ 0 — coplanar nodes");
|
||||
assert!(e.values[23] > 0.5, "dominant spread is meter-scale");
|
||||
// Node 3 (rank 2) recorded no distances; nodes 1, 2 did.
|
||||
assert_eq!(&e.values[24..27], &[1.0, 1.0, 0.75]);
|
||||
|
||||
let mut shuffled = nodes;
|
||||
shuffled.rotate_left(1);
|
||||
shuffled.swap(0, 1);
|
||||
assert_eq!(e, GeometryEmbedding::from_nodes(&shuffled));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn measured_distance_overrides_position_distance() {
|
||||
// Positions say 3 m apart, the tape measure said 2.5 m: measured wins.
|
||||
let nodes = vec![
|
||||
NodeGeometry::new(1, "t")
|
||||
.with_position(0.0, 0.0, 1.0)
|
||||
.with_distance(2, 2.5),
|
||||
NodeGeometry::new(2, "t").with_position(3.0, 0.0, 1.0),
|
||||
];
|
||||
let e = GeometryEmbedding::from_nodes(&nodes);
|
||||
assert!((e.values[10] - 3.0).abs() < 1e-5, "position pair stat raw");
|
||||
assert!((e.values[14] - 2.5).abs() < 1e-5, "measured wins");
|
||||
assert!((e.values[16] - 1.0).abs() < 1e-6, "full pair coverage");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adversarial_inputs_never_produce_nan() {
|
||||
let nodes = vec![
|
||||
NodeGeometry::new(1, "garbage")
|
||||
.with_position(f32::NAN, f32::INFINITY, -0.0)
|
||||
.with_orientation(f32::NAN, f32::NEG_INFINITY)
|
||||
.with_distance(2, f32::NAN)
|
||||
.with_distance(3, -5.0)
|
||||
.with_distance(1, 1.0), // self-distance: ignored
|
||||
NodeGeometry::new(2, "garbage")
|
||||
.with_position(1e30, 1e30, 1e30)
|
||||
.with_distance(99, 4.0), // unknown node: ignored
|
||||
NodeGeometry::new(3, "garbage").with_position(2.0, 0.0, 1.0),
|
||||
];
|
||||
let e = GeometryEmbedding::from_nodes(&nodes);
|
||||
assert_all_finite(&e);
|
||||
// Only node 3's position survived sanitization.
|
||||
assert!((e.values[1] - 1.0 / 3.0).abs() < 1e-6);
|
||||
assert_eq!(e.values[2], 0.0, "no valid orientations");
|
||||
assert_eq!(e.values[16], 0.0, "no valid measured pairs");
|
||||
assert!(e.values.iter().all(|x| x.abs() <= MAX_COORD_M), "bounded");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn more_than_eight_nodes_still_aggregates() {
|
||||
let nodes: Vec<NodeGeometry> = (0..12)
|
||||
.map(|i| NodeGeometry::new(i, "plan").with_position(i as f32, 0.0, 1.0))
|
||||
.collect();
|
||||
let e = GeometryEmbedding::from_nodes(&nodes);
|
||||
assert!((e.values[0] - 12.0 / 8.0).abs() < 1e-6);
|
||||
// All 8 flag slots filled (positions known, ranks 0..8 by node_id).
|
||||
assert!(e.values[24..32].iter().all(|&f| f == 0.5));
|
||||
// Collinear nodes: zero planar/volume diversity, meter-scale spread.
|
||||
assert!(e.values[21] < 1e-5);
|
||||
assert!(e.values[22] < 1e-5);
|
||||
assert!(e.values[23] > 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serde_roundtrip_and_schema_default() {
|
||||
let e = GeometryEmbedding::from_nodes(&full_layout());
|
||||
let json = serde_json::to_string(&e).unwrap();
|
||||
let back: GeometryEmbedding = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back, e);
|
||||
assert_eq!(back.schema_version, GeometryEmbedding::SCHEMA_VERSION);
|
||||
// JSON written by a pre-versioning producer (no version field)
|
||||
// defaults to the current schema — the NodeGeometry pattern.
|
||||
let vals = serde_json::to_string(&e.values).unwrap();
|
||||
let bare = format!("{{\"values\":{vals}}}");
|
||||
let from_bare: GeometryEmbedding = serde_json::from_str(&bare).unwrap();
|
||||
assert_eq!(from_bare.schema_version, 1);
|
||||
assert_eq!(from_bare.values, e.values);
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,10 @@
|
||||
//!
|
||||
//! Stages (ADR-151 §1.3):
|
||||
//! 1. **baseline** — empty-room environmental fingerprint (ADR-135; consumed here).
|
||||
//! 2. **enroll** — guided anchors with an adaptive quality gate ([`anchor`], [`enrollment`]).
|
||||
//! 2. **enroll** — guided anchors with an adaptive quality gate ([`anchor`],
|
||||
//! [`enrollment`]) plus an optional transceiver-geometry record ([`geometry`],
|
||||
//! 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`]).
|
||||
@@ -19,19 +22,23 @@
|
||||
#![warn(missing_docs)]
|
||||
|
||||
pub mod anchor;
|
||||
pub mod bank;
|
||||
pub mod enrollment;
|
||||
pub mod error;
|
||||
pub mod extract;
|
||||
pub mod specialist;
|
||||
pub mod bank;
|
||||
pub mod runtime;
|
||||
pub mod geometry;
|
||||
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;
|
||||
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};
|
||||
|
||||
@@ -20,6 +20,7 @@ use std::collections::BTreeMap;
|
||||
|
||||
use crate::bank::SpecialistBank;
|
||||
use crate::extract::Features;
|
||||
use crate::geometry::NodeGeometry;
|
||||
use crate::runtime::{MixtureOfSpecialists, RoomState};
|
||||
use crate::specialist::SpecialistReading;
|
||||
|
||||
@@ -45,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 {
|
||||
@@ -60,6 +66,26 @@ impl MultiNodeMixture {
|
||||
self.nodes.len()
|
||||
}
|
||||
|
||||
/// The transceiver-geometry snapshot a node's bank was trained under
|
||||
/// (ADR-152 §2.1.1), if its enrollment recorded one. Threaded through for
|
||||
/// the fusion logic; **not used algorithmically yet** — geometry-aware
|
||||
/// fusion is the §2.1.2 learned-embedding work (ADR-151 P6).
|
||||
pub fn node_geometry(&self, node_id: u8) -> Option<&[NodeGeometry]> {
|
||||
self.nodes
|
||||
.get(&node_id)
|
||||
.map(|e| e.mixture.bank().geometry.as_slice())
|
||||
.filter(|g| !g.is_empty())
|
||||
}
|
||||
|
||||
/// All registered nodes' geometry snapshots, keyed by node id. Nodes whose
|
||||
/// banks carry no geometry are omitted.
|
||||
pub fn geometries(&self) -> BTreeMap<u8, &[NodeGeometry]> {
|
||||
self.nodes
|
||||
.keys()
|
||||
.filter_map(|&id| self.node_geometry(id).map(|g| (id, g)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Fuse per-node feature windows into one room state. Nodes without a feature
|
||||
/// entry this window are skipped.
|
||||
pub fn infer(&self, per_node: &BTreeMap<u8, Features>) -> RoomState {
|
||||
@@ -109,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 },
|
||||
@@ -205,6 +229,22 @@ mod tests {
|
||||
assert_eq!(m.node_count(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn geometry_threads_through_to_fusion() {
|
||||
let geo1 = vec![NodeGeometry::new(1, "tape-measure")
|
||||
.with_position(0.0, 0.0, 1.0)
|
||||
.with_distance(2, 3.0)];
|
||||
let mut m = MultiNodeMixture::new();
|
||||
m.add_node(1, bank("b1").with_geometry(geo1.clone()), "b1");
|
||||
m.add_node(2, bank("b1"), "b1"); // no geometry recorded for node 2
|
||||
assert_eq!(m.node_geometry(1), Some(geo1.as_slice()));
|
||||
assert_eq!(m.node_geometry(2), None, "geometry-free bank reads None");
|
||||
assert_eq!(m.node_geometry(9), None, "unknown node reads None");
|
||||
let all = m.geometries();
|
||||
assert_eq!(all.len(), 1);
|
||||
assert_eq!(all.get(&1), Some(&geo1.as_slice()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presence_or_across_nodes() {
|
||||
let mut m = MultiNodeMixture::new();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ use num_complex::Complex64;
|
||||
use wifi_densepose_calibration::extract::Features;
|
||||
use wifi_densepose_calibration::{
|
||||
AnchorFeature, AnchorLabel, AnchorQualityGate, AnchorRecorder, EnrollmentEvent,
|
||||
EnrollmentSession, MixtureOfSpecialists, SpecialistBank, SpecialistKind,
|
||||
EnrollmentSession, MixtureOfSpecialists, NodeGeometry, SpecialistBank, SpecialistKind,
|
||||
};
|
||||
use wifi_densepose_core::types::{AntennaConfig, CsiFrame, CsiMetadata, DeviceId, FrequencyBand};
|
||||
use wifi_densepose_signal::{BaselineCalibration, CalibrationConfig, CalibrationRecorder};
|
||||
@@ -271,6 +271,19 @@ fn full_loop_baseline_enroll_extract_train_infer() {
|
||||
// -- Stage 2: guided-anchor enrollment with the quality gate -------------
|
||||
let gate = AnchorQualityGate::default();
|
||||
let mut session = EnrollmentSession::new(room_id, &baseline_id, 1_700_000_000);
|
||||
|
||||
// Transceiver geometry recorded at session start (ADR-152 §2.1.1): a
|
||||
// two-node layout, one tape-measured, one unknown — all fields optional.
|
||||
let geometry = vec![
|
||||
NodeGeometry::new(1, "tape-measure")
|
||||
.with_position(0.0, 0.0, 1.2)
|
||||
.with_orientation(0.0, 0.0)
|
||||
.with_distance(2, 3.5),
|
||||
NodeGeometry::unknown(2),
|
||||
];
|
||||
session.record_geometry(geometry.clone(), 1_700_000_000);
|
||||
assert_eq!(session.geometry(), Some(geometry.as_slice()));
|
||||
|
||||
let mut features: Vec<AnchorFeature> = Vec::new();
|
||||
|
||||
for (i, label) in AnchorLabel::SEQUENCE.into_iter().enumerate() {
|
||||
@@ -345,8 +358,10 @@ fn full_loop_baseline_enroll_extract_train_infer() {
|
||||
);
|
||||
|
||||
// -- Stage 4: train the specialist bank + JSON persistence round-trip ----
|
||||
// The bank snapshots the geometry the enrollment recorded (ADR-152 §2.1.1).
|
||||
let bank = SpecialistBank::train(room_id, &baseline_id, &features, 1_700_000_400)
|
||||
.expect("bank training");
|
||||
.expect("bank training")
|
||||
.with_geometry(session.geometry().map(<[_]>::to_vec).unwrap_or_default());
|
||||
assert_eq!(bank.room_id, room_id);
|
||||
assert_eq!(bank.anchor_count, 8);
|
||||
let kinds = bank.trained_kinds();
|
||||
@@ -373,6 +388,10 @@ fn full_loop_baseline_enroll_extract_train_infer() {
|
||||
bank.presence.as_ref().map(|p| p.threshold),
|
||||
"presence threshold must survive persistence"
|
||||
);
|
||||
assert_eq!(
|
||||
reloaded.geometry, geometry,
|
||||
"the enrollment geometry snapshot must survive bank persistence"
|
||||
);
|
||||
|
||||
// -- Stage 5: runtime inference through the mixture ----------------------
|
||||
let mix = MixtureOfSpecialists::new(reloaded);
|
||||
|
||||
@@ -8,22 +8,24 @@
|
||||
//!
|
||||
//! # Wire format parsed here (option b — local parser, no cross-crate dep)
|
||||
//!
|
||||
//! Authoritative layout: firmware `csi_collector.c` (ADR-018 + ADR-110).
|
||||
//!
|
||||
//! Offset Size Field
|
||||
//! ────── ──── ─────────────────────────────────────────────────────────────
|
||||
//! 0 4 Magic: 0xC511_0001 (LE u32)
|
||||
//! 4 1 node_id (u8)
|
||||
//! 5 1 n_antennas (u8)
|
||||
//! 6 1 n_subcarriers (u8)
|
||||
//! 7 1 (reserved)
|
||||
//! 8 2 freq_mhz (LE u16)
|
||||
//! 10 4 sequence (LE u32)
|
||||
//! 14 1 rssi (i8)
|
||||
//! 15 1 noise_floor (i8)
|
||||
//! 16 4 (reserved / padding)
|
||||
//! 6 2 n_subcarriers (LE u16 — 256 for ESP32-C6 HE-SU frames, #1005)
|
||||
//! 8 4 freq_mhz (LE u32)
|
||||
//! 12 4 sequence (LE u32)
|
||||
//! 16 1 rssi (i8)
|
||||
//! 17 1 noise_floor (i8)
|
||||
//! 18 1 PPDU type (ADR-110: 0=HT/legacy, 1=HE-SU, 2=HE-MU, 3=HE-TB)
|
||||
//! 19 1 flags (ADR-110: bit0 bw40, bit4 time-sync valid)
|
||||
//! 20 2 × n_antennas × n_subcarriers IQ pairs: i_val (i8), q_val (i8)
|
||||
//!
|
||||
//! This parser mirrors `parse_esp32_frame` in
|
||||
//! `wifi-densepose-sensing-server/src/csi.rs` exactly (same magic, same layout).
|
||||
//! `wifi-densepose-sensing-server/src/csi.rs` (same magic, same layout).
|
||||
|
||||
use anyhow::{bail, Result};
|
||||
use clap::Args;
|
||||
@@ -261,11 +263,15 @@ pub(crate) fn parse_csi_packet(buf: &[u8], tier: &str) -> Option<CsiFrame> {
|
||||
|
||||
let node_id = buf[4];
|
||||
let n_antennas = buf[5] as usize;
|
||||
let n_subcarriers = buf[6] as usize;
|
||||
let freq_mhz = u16::from_le_bytes([buf[8], buf[9]]);
|
||||
let _sequence = u32::from_le_bytes([buf[10], buf[11], buf[12], buf[13]]);
|
||||
let rssi = buf[14] as i8;
|
||||
let noise_floor = buf[15] as i8;
|
||||
// u16 since ADR-110 / #1005: ESP32-C6 HE-SU frames carry 256 bins
|
||||
// (the old single-byte read decoded 256 = 0x0100 LE as 0 subcarriers).
|
||||
let n_subcarriers = u16::from_le_bytes([buf[6], buf[7]]) as usize;
|
||||
let freq_mhz = u32::from_le_bytes([buf[8], buf[9], buf[10], buf[11]]);
|
||||
let freq_mhz = u16::try_from(freq_mhz).unwrap_or(0);
|
||||
let _sequence = u32::from_le_bytes([buf[12], buf[13], buf[14], buf[15]]);
|
||||
let rssi = buf[16] as i8;
|
||||
let noise_floor = buf[17] as i8;
|
||||
let _ppdu_type = buf[18]; // ADR-110; baseline tier gating is by count
|
||||
|
||||
let n_pairs = n_antennas * n_subcarriers;
|
||||
let iq_start = 20usize;
|
||||
@@ -414,24 +420,53 @@ mod tests {
|
||||
assert!(parse_csi_packet(&buf, "ht20").is_none());
|
||||
}
|
||||
|
||||
/// Build an ADR-018 frame (correct firmware layout, ADR-110 bytes 18-19).
|
||||
fn build_frame(n_subcarriers: u16, ppdu: u8) -> Vec<u8> {
|
||||
let mut buf = vec![0u8; 20 + n_subcarriers as usize * 2];
|
||||
buf[0..4].copy_from_slice(&0xC511_0001u32.to_le_bytes());
|
||||
buf[4] = 12; // node_id
|
||||
buf[5] = 1; // n_antennas
|
||||
buf[6..8].copy_from_slice(&n_subcarriers.to_le_bytes());
|
||||
buf[8..12].copy_from_slice(&2432u32.to_le_bytes()); // freq_mhz
|
||||
buf[12..16].copy_from_slice(&11610u32.to_le_bytes()); // sequence
|
||||
buf[16] = (-40i8) as u8; // rssi
|
||||
buf[17] = (-87i8) as u8; // noise floor
|
||||
buf[18] = ppdu;
|
||||
buf[19] = 0x10; // time-sync valid
|
||||
for k in 0..n_subcarriers as usize {
|
||||
buf[20 + k * 2] = (10 + (k % 100) as i8) as u8;
|
||||
buf[20 + k * 2 + 1] = (k % 50) as u8;
|
||||
}
|
||||
buf
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_csi_packet_valid() {
|
||||
let mut buf = vec![0u8; 24]; // 20-byte header + 2 IQ pairs (1 antenna, 2 subcarriers)
|
||||
// Magic 0xC511_0001 LE
|
||||
buf[0] = 0x01; buf[1] = 0x00; buf[2] = 0x11; buf[3] = 0xC5;
|
||||
buf[5] = 1; // n_antennas
|
||||
buf[6] = 2; // n_subcarriers
|
||||
// freq_mhz = 2437 (channel 6)
|
||||
buf[8] = 0x85; buf[9] = 0x09;
|
||||
// IQ pairs at offset 20: (10, 20), (−5, 15)
|
||||
buf[20] = 10i8 as u8; buf[21] = 20i8 as u8;
|
||||
buf[22] = (-5i8) as u8; buf[23] = 15i8 as u8;
|
||||
|
||||
let buf = build_frame(2, 0);
|
||||
let frame = parse_csi_packet(&buf, "ht20");
|
||||
assert!(frame.is_some());
|
||||
let f = frame.unwrap();
|
||||
assert_eq!(f.num_spatial_streams(), 1);
|
||||
assert_eq!(f.num_subcarriers(), 2);
|
||||
assert_eq!(f.metadata.rssi_dbm, -40);
|
||||
assert_eq!(f.metadata.noise_floor_dbm, -87);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_csi_packet_he_su_256_bins() {
|
||||
// ESP32-C6 HE-SU frame (issue #1005): n_subcarriers = 256 = 0x0100 LE.
|
||||
// The pre-#1005 single-byte read decoded this as 0 subcarriers.
|
||||
let buf = build_frame(256, 1);
|
||||
assert_eq!(buf.len(), 532); // matches the live wire size
|
||||
let f = parse_csi_packet(&buf, "he20").expect("256-bin HE frame must parse");
|
||||
assert_eq!(f.num_subcarriers(), 256);
|
||||
assert_eq!(f.metadata.rssi_dbm, -40);
|
||||
// A 256-bin frame is accepted by the he20 recorder (num_subcarriers
|
||||
// tier total) and rejected by ht20 (52/64) — no HT/HE mixing.
|
||||
let mut he = wifi_densepose_signal::CalibrationRecorder::new(tier_config("he20"));
|
||||
assert!(he.record(&f).is_ok());
|
||||
let mut ht = wifi_densepose_signal::CalibrationRecorder::new(tier_config("ht20"));
|
||||
assert!(ht.record(&f).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -39,7 +39,8 @@ use tokio::sync::{mpsc, oneshot, RwLock};
|
||||
use tower_http::cors::CorsLayer;
|
||||
use wifi_densepose_calibration::extract::{AnchorFeature, Features};
|
||||
use wifi_densepose_calibration::{
|
||||
AnchorLabel, AnchorQualityGate, AnchorRecorder, MixtureOfSpecialists, SpecialistBank,
|
||||
AnchorLabel, AnchorQualityGate, AnchorRecorder, MixtureOfSpecialists, NodeGeometry,
|
||||
SpecialistBank,
|
||||
};
|
||||
use wifi_densepose_core::types::CsiFrame;
|
||||
use wifi_densepose_signal::{BaselineCalibration, CalibrationRecorder};
|
||||
@@ -207,6 +208,9 @@ struct RoomEnroll {
|
||||
baseline_id: String,
|
||||
fs_hz: f32,
|
||||
anchors: Vec<AnchorFeature>,
|
||||
/// Transceiver geometry recorded via `POST /enroll/geometry` (ADR-152
|
||||
/// §2.1.1); latest recording wins. Snapshotted into the bank at train time.
|
||||
geometry: Vec<NodeGeometry>,
|
||||
}
|
||||
|
||||
/// Result of capturing one anchor (`POST /enroll/anchor`).
|
||||
@@ -299,6 +303,7 @@ fn build_router(state: ApiState) -> Router {
|
||||
.route("/api/v1/room/state", get(room_state))
|
||||
.route("/api/v1/room/train", post(train_room))
|
||||
.route("/api/v1/enroll/anchor", post(enroll_anchor))
|
||||
.route("/api/v1/enroll/geometry", post(enroll_geometry))
|
||||
.route("/api/v1/enroll/status", get(enroll_status))
|
||||
.layer(CorsLayer::permissive())
|
||||
.with_state(state)
|
||||
@@ -670,8 +675,9 @@ async fn descriptor() -> impl IntoResponse {
|
||||
"GET /api/v1/calibration/result": "last finalized baseline summary",
|
||||
"GET /api/v1/calibration/baselines": "list persisted baseline files",
|
||||
"GET /api/v1/room/state?bank=<name>": "live mixture-of-specialists RoomState over the CSI window",
|
||||
"POST /api/v1/room/train": "{ room_id, baseline_id, anchors[]? } → train + persist a specialist bank (anchors[] optional if enrolled in-server)",
|
||||
"POST /api/v1/room/train": "{ room_id, baseline_id, anchors[]?, geometry[]? } → train + persist a specialist bank (anchors[]/geometry[] optional if enrolled in-server)",
|
||||
"POST /api/v1/enroll/anchor": "{ room_id, baseline, label, duration_s? } → capture one guided anchor (blocks for the capture)",
|
||||
"POST /api/v1/enroll/geometry": "{ room_id, geometry: [NodeGeometry…] } → record transceiver geometry for the room (ADR-152 §2.1.1; latest wins)",
|
||||
"GET /api/v1/enroll/status?room=<id>": "enrollment progress (accepted anchors, next, complete)"
|
||||
}
|
||||
}))
|
||||
@@ -740,11 +746,18 @@ struct TrainRequest {
|
||||
baseline_id: String,
|
||||
#[serde(default)]
|
||||
anchors: Vec<AnchorFeature>,
|
||||
/// Optional transceiver geometry (ADR-152 §2.1.1). Falls back to the
|
||||
/// geometry recorded in-server via `POST /enroll/geometry`; absent both,
|
||||
/// the bank trains geometry-free (valid, but no geometry conditioning).
|
||||
#[serde(default)]
|
||||
geometry: Vec<NodeGeometry>,
|
||||
}
|
||||
|
||||
/// Train a per-room specialist bank and persist it as `<output_dir>/<room_id>.json`
|
||||
/// (the name `room-state` reads back). Uses the posted `anchors` if present, else
|
||||
/// falls back to the in-server enrollment accumulated via `POST /enroll/anchor`.
|
||||
/// The enrollment's transceiver-geometry snapshot (posted `geometry` or the
|
||||
/// `POST /enroll/geometry` record) is threaded into the bank (ADR-152 §2.1.1).
|
||||
async fn train_room(State(st): State<ApiState>, Json(req): Json<TrainRequest>) -> impl IntoResponse {
|
||||
let (anchors, baseline_id) = if !req.anchors.is_empty() {
|
||||
(req.anchors.clone(), req.baseline_id.clone())
|
||||
@@ -756,11 +769,25 @@ async fn train_room(State(st): State<ApiState>, Json(req): Json<TrainRequest>) -
|
||||
}
|
||||
}
|
||||
};
|
||||
let geometry = if !req.geometry.is_empty() {
|
||||
req.geometry.clone()
|
||||
} else {
|
||||
st.enroll.read().await.get(&req.room_id).map(|re| re.geometry.clone()).unwrap_or_default()
|
||||
};
|
||||
let at = (unix_ms() / 1000) as i64;
|
||||
let bank = match SpecialistBank::train(&req.room_id, &baseline_id, &anchors, at) {
|
||||
Ok(b) => b,
|
||||
Err(e) => return (StatusCode::BAD_REQUEST, Json(serde_json::json!({"error": format!("training failed: {e}")}))).into_response(),
|
||||
};
|
||||
let bank = if geometry.is_empty() {
|
||||
eprintln!(
|
||||
"[calibrate-serve] no transceiver geometry recorded for room '{}' — bank will not support geometry conditioning (ADR-152 §2.1.2)",
|
||||
req.room_id
|
||||
);
|
||||
bank
|
||||
} else {
|
||||
bank.with_geometry(geometry)
|
||||
};
|
||||
let name = sanitize_room_id(&req.room_id);
|
||||
let dir = { st.status.read().await.output_dir.clone() };
|
||||
let path = format!("{dir}/{name}.json");
|
||||
@@ -777,10 +804,37 @@ async fn train_room(State(st): State<ApiState>, Json(req): Json<TrainRequest>) -
|
||||
"bank": name, // pass as ?bank=<name> to /room/state
|
||||
"anchor_count": bank.anchor_count,
|
||||
"specialists": kinds,
|
||||
"geometry_nodes": bank.geometry.len(),
|
||||
"path": path,
|
||||
}))).into_response()
|
||||
}
|
||||
|
||||
/// Body for `POST /api/v1/enroll/geometry`.
|
||||
#[derive(Deserialize)]
|
||||
struct EnrollGeometryBody {
|
||||
room_id: String,
|
||||
/// Per-node transceiver geometry records (ADR-152 §2.1.1).
|
||||
geometry: Vec<NodeGeometry>,
|
||||
}
|
||||
|
||||
/// Record the room's transceiver geometry (ADR-152 §2.1.1) into the in-server
|
||||
/// enrollment; the next `POST /room/train` snapshots it into the bank. A later
|
||||
/// POST supersedes an earlier one (latest wins), mirroring
|
||||
/// `EnrollmentSession::record_geometry`.
|
||||
async fn enroll_geometry(State(st): State<ApiState>, Json(b): Json<EnrollGeometryBody>) -> impl IntoResponse {
|
||||
if b.geometry.is_empty() {
|
||||
return (StatusCode::BAD_REQUEST, Json(serde_json::json!({"error":"geometry must be a non-empty array of NodeGeometry records"}))).into_response();
|
||||
}
|
||||
let nodes = b.geometry.len();
|
||||
{
|
||||
let mut map = st.enroll.write().await;
|
||||
let re = map.entry(b.room_id.clone()).or_insert_with(RoomEnroll::default);
|
||||
re.geometry = b.geometry;
|
||||
}
|
||||
eprintln!("[calibrate-serve] enroll geometry room={} nodes={nodes}", b.room_id);
|
||||
(StatusCode::OK, Json(serde_json::json!({"room_id": b.room_id, "geometry_nodes": nodes}))).into_response()
|
||||
}
|
||||
|
||||
/// Body for `POST /api/v1/enroll/anchor`.
|
||||
#[derive(Deserialize)]
|
||||
struct EnrollAnchorBody {
|
||||
@@ -1086,6 +1140,59 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// ADR-152 §2.1.1: geometry threads into the trained bank through both API
|
||||
/// paths — inline in the train request, or recorded via /enroll/geometry —
|
||||
/// and a geometry-free train still produces a valid (unconditioned) bank.
|
||||
#[tokio::test]
|
||||
async fn train_threads_geometry_into_bank() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let app = build_router(test_state(dir.path().to_str().unwrap()));
|
||||
let anchors = r#"[
|
||||
{"room_id":"g","label":"empty","features":{"mean":1.0,"variance":1.0,"motion":0.1,"breathing_score":0.0,"breathing_hz":0.0,"heart_score":0.0,"heart_hz":0.0}},
|
||||
{"room_id":"g","label":"stand_still","features":{"mean":1.0,"variance":10.0,"motion":0.2,"breathing_score":0.0,"breathing_hz":0.0,"heart_score":0.0,"heart_hz":0.0}}
|
||||
]"#;
|
||||
let load_bank = |name: &str| {
|
||||
let raw = std::fs::read_to_string(dir.path().join(format!("{name}.json"))).unwrap();
|
||||
SpecialistBank::from_json(&raw).unwrap()
|
||||
};
|
||||
|
||||
// (1) geometry inline in the train request.
|
||||
let body = format!(
|
||||
r#"{{"room_id":"g1","baseline_id":"b","anchors":{anchors},
|
||||
"geometry":[{{"node_id":1,"position":{{"x_m":0.0,"y_m":0.0,"z_m":1.0}},"method":"tape-measure"}},{{"node_id":2}}]}}"#
|
||||
);
|
||||
assert_eq!(req(app.clone(), "POST", "/api/v1/room/train", Some(&body)).await, StatusCode::OK);
|
||||
let bank = load_bank("g1");
|
||||
assert_eq!(bank.geometry.len(), 2);
|
||||
assert_eq!(bank.geometry[0].method, "tape-measure");
|
||||
assert_eq!(bank.geometry[1].node_id, 2);
|
||||
|
||||
// (2) geometry recorded via /enroll/geometry; train body omits it.
|
||||
assert_eq!(
|
||||
req(app.clone(), "POST", "/api/v1/enroll/geometry",
|
||||
Some(r#"{"room_id":"g2","geometry":[{"node_id":7,"method":"floor-plan"}]}"#)).await,
|
||||
StatusCode::OK
|
||||
);
|
||||
let body2 = format!(r#"{{"room_id":"g2","baseline_id":"b","anchors":{anchors}}}"#);
|
||||
assert_eq!(req(app.clone(), "POST", "/api/v1/room/train", Some(&body2)).await, StatusCode::OK);
|
||||
let bank2 = load_bank("g2");
|
||||
assert_eq!(bank2.geometry.len(), 1);
|
||||
assert_eq!(bank2.geometry[0].node_id, 7);
|
||||
|
||||
// (3) no geometry anywhere → valid geometry-free bank (note logged).
|
||||
let body3 = format!(r#"{{"room_id":"g3","baseline_id":"b","anchors":{anchors}}}"#);
|
||||
assert_eq!(req(app.clone(), "POST", "/api/v1/room/train", Some(&body3)).await, StatusCode::OK);
|
||||
let bank3 = load_bank("g3");
|
||||
assert!(bank3.geometry.is_empty());
|
||||
assert!(bank3.presence.is_some(), "bank still trains without geometry");
|
||||
|
||||
// (4) empty geometry array is rejected.
|
||||
assert_eq!(
|
||||
req(app, "POST", "/api/v1/enroll/geometry", Some(r#"{"room_id":"g4","geometry":[]}"#)).await,
|
||||
StatusCode::BAD_REQUEST
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn enroll_status_empty_and_bad_label() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -11,7 +11,7 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
use tokio::net::UdpSocket;
|
||||
use wifi_densepose_calibration::{
|
||||
Anchor, AnchorLabel, AnchorQualityGate, AnchorRecorder, EnrollmentEvent, EnrollmentSession,
|
||||
MixtureOfSpecialists, MultiNodeMixture, SpecialistBank,
|
||||
MixtureOfSpecialists, MultiNodeMixture, NodeGeometry, SpecialistBank,
|
||||
};
|
||||
use wifi_densepose_calibration::extract::{AnchorFeature, Features};
|
||||
use wifi_densepose_core::types::CsiFrame;
|
||||
@@ -226,20 +226,50 @@ pub struct TrainRoomArgs {
|
||||
/// Output specialist-bank file.
|
||||
#[arg(long, default_value = "./room-bank.json")]
|
||||
pub output: String,
|
||||
/// Optional transceiver-geometry file: a JSON array of `NodeGeometry`
|
||||
/// records (ADR-152 §2.1.1). Recorded into the enrollment session before
|
||||
/// training so the bank carries the layout it was trained under.
|
||||
#[arg(long)]
|
||||
pub geometry: Option<String>,
|
||||
}
|
||||
|
||||
/// Execute `train-room`.
|
||||
///
|
||||
/// If the enrollment session carries a transceiver-geometry snapshot (recorded
|
||||
/// at enroll time or supplied here via `--geometry`), it is threaded into the
|
||||
/// bank (ADR-152 §2.1.1); a geometry-free enrollment still trains a valid bank.
|
||||
pub async fn train_room(args: TrainRoomArgs) -> Result<()> {
|
||||
let raw = std::fs::read_to_string(&args.enrollment)
|
||||
.map_err(|e| anyhow::anyhow!("cannot read {}: {e} — run `enroll` first", args.enrollment))?;
|
||||
let data: EnrollmentData =
|
||||
let mut data: EnrollmentData =
|
||||
serde_json::from_str(&raw).map_err(|e| anyhow::anyhow!("invalid enrollment: {e}"))?;
|
||||
if data.anchors.is_empty() {
|
||||
bail!("no accepted anchors in {} — re-run enroll", args.enrollment);
|
||||
}
|
||||
|
||||
let bank = SpecialistBank::train(&data.room_id, &data.baseline_id, &data.anchors, now_unix())
|
||||
if let Some(path) = &args.geometry {
|
||||
let graw = std::fs::read_to_string(path)
|
||||
.map_err(|e| anyhow::anyhow!("cannot read geometry {path}: {e}"))?;
|
||||
let geometry: Vec<NodeGeometry> = serde_json::from_str(&graw).map_err(|e| {
|
||||
anyhow::anyhow!("invalid geometry {path}: {e} (expected a JSON array of NodeGeometry records)")
|
||||
})?;
|
||||
data.session.record_geometry(geometry, now_unix());
|
||||
}
|
||||
|
||||
let mut bank = SpecialistBank::train(&data.room_id, &data.baseline_id, &data.anchors, now_unix())
|
||||
.map_err(|e| anyhow::anyhow!("training failed: {e}"))?;
|
||||
match data.session.geometry() {
|
||||
Some(g) if !g.is_empty() => {
|
||||
bank = bank.with_geometry(g.to_vec());
|
||||
eprintln!(
|
||||
"[train-room] geometry: {} node(s) snapshotted into the bank (ADR-152 §2.1.1)",
|
||||
bank.geometry.len()
|
||||
);
|
||||
}
|
||||
_ => eprintln!(
|
||||
"[train-room] no transceiver geometry recorded — bank will not support geometry conditioning (ADR-152 §2.1.2)"
|
||||
),
|
||||
}
|
||||
std::fs::write(&args.output, bank.to_json().map_err(|e| anyhow::anyhow!("{e}"))?)
|
||||
.map_err(|e| anyhow::anyhow!("cannot write {}: {e}", args.output))?;
|
||||
|
||||
@@ -456,3 +486,141 @@ async fn room_watch_multi(args: RoomWatchArgs) -> Result<()> {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn feature(label: AnchorLabel, variance: f32, motion: f32) -> AnchorFeature {
|
||||
AnchorFeature {
|
||||
room_id: "t".into(),
|
||||
label,
|
||||
features: Features {
|
||||
mean: 1.0,
|
||||
variance,
|
||||
motion,
|
||||
breathing_score: 0.0,
|
||||
breathing_hz: 0.0,
|
||||
heart_score: 0.0,
|
||||
heart_hz: 0.0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Write a minimal valid enrollment file (two anchors, no geometry event).
|
||||
fn write_enrollment(dir: &std::path::Path) -> String {
|
||||
let data = EnrollmentData {
|
||||
room_id: "t".into(),
|
||||
baseline_id: "base-1".into(),
|
||||
fs_hz: 15.0,
|
||||
anchors: vec![
|
||||
feature(AnchorLabel::Empty, 1.0, 0.1),
|
||||
feature(AnchorLabel::StandStill, 10.0, 0.2),
|
||||
],
|
||||
session: EnrollmentSession::new("t", "base-1", 1000),
|
||||
};
|
||||
let path = dir.join("enrollment.json");
|
||||
std::fs::write(&path, serde_json::to_string(&data).unwrap()).unwrap();
|
||||
path.to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
fn trained_bank(out: &std::path::Path) -> SpecialistBank {
|
||||
SpecialistBank::from_json(&std::fs::read_to_string(out).unwrap()).unwrap()
|
||||
}
|
||||
|
||||
/// ADR-152 §2.1.1: `--geometry` records into the session and the bank
|
||||
/// snapshots it — enrollment geometry reaches the trained bank.
|
||||
#[tokio::test]
|
||||
async fn train_room_threads_geometry_when_provided() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let enrollment = write_enrollment(dir.path());
|
||||
let geometry = vec![
|
||||
NodeGeometry::new(1, "tape-measure").with_position(0.0, 0.0, 1.0),
|
||||
NodeGeometry::unknown(2),
|
||||
];
|
||||
let gpath = dir.path().join("geometry.json");
|
||||
std::fs::write(&gpath, serde_json::to_string(&geometry).unwrap()).unwrap();
|
||||
let out = dir.path().join("bank.json");
|
||||
|
||||
train_room(TrainRoomArgs {
|
||||
enrollment,
|
||||
output: out.to_string_lossy().into_owned(),
|
||||
geometry: Some(gpath.to_string_lossy().into_owned()),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(trained_bank(&out).geometry, geometry);
|
||||
}
|
||||
|
||||
/// A geometry-free enrollment still trains a valid bank (optional by
|
||||
/// design) — it just carries no snapshot.
|
||||
#[tokio::test]
|
||||
async fn train_room_without_geometry_yields_geometry_free_bank() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let enrollment = write_enrollment(dir.path());
|
||||
let out = dir.path().join("bank.json");
|
||||
|
||||
train_room(TrainRoomArgs {
|
||||
enrollment,
|
||||
output: out.to_string_lossy().into_owned(),
|
||||
geometry: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let bank = trained_bank(&out);
|
||||
assert!(bank.geometry.is_empty());
|
||||
assert!(bank.presence.is_some(), "bank still trains without geometry");
|
||||
}
|
||||
|
||||
/// Geometry recorded at enroll time (in the session event log) is picked up
|
||||
/// without the `--geometry` flag.
|
||||
#[tokio::test]
|
||||
async fn train_room_uses_session_geometry() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let geometry = vec![NodeGeometry::new(3, "floor-plan").with_position(1.0, 2.0, 1.5)];
|
||||
let mut session = EnrollmentSession::new("t", "base-1", 1000);
|
||||
session.record_geometry(geometry.clone(), 1000);
|
||||
let data = EnrollmentData {
|
||||
room_id: "t".into(),
|
||||
baseline_id: "base-1".into(),
|
||||
fs_hz: 15.0,
|
||||
anchors: vec![
|
||||
feature(AnchorLabel::Empty, 1.0, 0.1),
|
||||
feature(AnchorLabel::StandStill, 10.0, 0.2),
|
||||
],
|
||||
session,
|
||||
};
|
||||
let epath = dir.path().join("enrollment.json");
|
||||
std::fs::write(&epath, serde_json::to_string(&data).unwrap()).unwrap();
|
||||
let out = dir.path().join("bank.json");
|
||||
|
||||
train_room(TrainRoomArgs {
|
||||
enrollment: epath.to_string_lossy().into_owned(),
|
||||
output: out.to_string_lossy().into_owned(),
|
||||
geometry: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(trained_bank(&out).geometry, geometry);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn train_room_rejects_invalid_geometry_file() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let enrollment = write_enrollment(dir.path());
|
||||
let gpath = dir.path().join("geometry.json");
|
||||
std::fs::write(&gpath, r#"{"not":"an array"}"#).unwrap();
|
||||
|
||||
let err = train_room(TrainRoomArgs {
|
||||
enrollment,
|
||||
output: dir.path().join("bank.json").to_string_lossy().into_owned(),
|
||||
geometry: Some(gpath.to_string_lossy().into_owned()),
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("invalid geometry"), "{err}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -563,6 +563,12 @@ impl crate::traits::CanonicalFrame for CsiFrame {
|
||||
/// (each fixed-width LE; `device_id` length-prefixed; `calibration_id` as
|
||||
/// 16 UUID bytes or 16 zero bytes for `None`) ‖ `(nrows, ncols)` as u32 LE
|
||||
/// ‖ complex payload as `ComplexSample::to_le_bytes()` in stream-major order.
|
||||
///
|
||||
/// # Panics
|
||||
/// If `calibration_id` is `Some(Uuid::nil())`: the nil UUID is the wire
|
||||
/// sentinel for `None`, so encoding it would alias two distinct frames to
|
||||
/// the same bytes (and the same witness hash) — a non-injective encoding
|
||||
/// is refused rather than silently produced.
|
||||
fn to_canonical_bytes(&self) -> Vec<u8> {
|
||||
let m = &self.metadata;
|
||||
// 16 (id) + ~48 (meta) + 8 (shape) + 16 * n_samples
|
||||
@@ -600,7 +606,17 @@ impl crate::traits::CanonicalFrame for CsiFrame {
|
||||
b.extend_from_slice(&m.noise_floor_dbm.to_le_bytes());
|
||||
b.extend_from_slice(&m.sequence_number.to_le_bytes());
|
||||
match m.calibration_id {
|
||||
Some(id) => b.extend_from_slice(id.as_bytes()),
|
||||
Some(id) => {
|
||||
// Some(nil) would alias the None sentinel on the wire: the
|
||||
// bytes would decode to a *different* frame (calibration_id
|
||||
// None) with the same witness. Refuse the non-injective
|
||||
// encoding (see the trait-impl `# Panics` doc).
|
||||
assert!(
|
||||
id != Uuid::nil(),
|
||||
"calibration_id Some(Uuid::nil()) is unencodable: nil is the None sentinel"
|
||||
);
|
||||
b.extend_from_slice(id.as_bytes());
|
||||
}
|
||||
None => b.extend_from_slice(&[0u8; 16]),
|
||||
}
|
||||
b.extend_from_slice(&m.model_id.to_le_bytes());
|
||||
@@ -616,6 +632,205 @@ impl crate::traits::CanonicalFrame for CsiFrame {
|
||||
}
|
||||
}
|
||||
|
||||
/// Errors decoding a frame from its canonical bytes.
|
||||
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
|
||||
pub enum CanonicalDecodeError {
|
||||
/// The buffer ended before the layout was fully read.
|
||||
#[error("canonical buffer truncated at byte {at} (need {need} more)")]
|
||||
Truncated {
|
||||
/// Byte offset where reading failed.
|
||||
at: usize,
|
||||
/// How many more bytes were needed.
|
||||
need: usize,
|
||||
},
|
||||
/// A discriminant byte held an unknown value.
|
||||
#[error("invalid {field} discriminant {value}")]
|
||||
BadDiscriminant {
|
||||
/// Which field failed.
|
||||
field: &'static str,
|
||||
/// The offending byte.
|
||||
value: u8,
|
||||
},
|
||||
/// The device-id bytes were not UTF-8.
|
||||
#[error("device id is not valid UTF-8")]
|
||||
BadDeviceId,
|
||||
/// Shape (nrows × ncols) disagrees with the remaining payload length.
|
||||
#[error("payload length mismatch: shape {rows}x{cols} needs {expect} bytes, found {found}")]
|
||||
PayloadMismatch {
|
||||
/// Declared rows.
|
||||
rows: usize,
|
||||
/// Declared cols.
|
||||
cols: usize,
|
||||
/// Bytes the shape implies.
|
||||
expect: usize,
|
||||
/// Bytes actually present.
|
||||
found: usize,
|
||||
},
|
||||
/// Trailing bytes after the declared payload.
|
||||
#[error("{0} trailing bytes after payload")]
|
||||
TrailingBytes(usize),
|
||||
/// A reserved region that must be all-zero held nonzero bytes. Accepting
|
||||
/// them would let two distinct byte strings decode to the same frame
|
||||
/// (re-encoding could not reproduce the original — forged bytes would be
|
||||
/// indistinguishable after a replay round-trip).
|
||||
#[error("reserved bytes for {field} must be zero")]
|
||||
ReservedNotZero {
|
||||
/// Which field's reserved region was nonzero.
|
||||
field: &'static str,
|
||||
},
|
||||
}
|
||||
|
||||
/// Byte cursor for the canonical layout.
|
||||
struct Cursor<'a> {
|
||||
b: &'a [u8],
|
||||
at: usize,
|
||||
}
|
||||
|
||||
impl<'a> Cursor<'a> {
|
||||
fn take(&mut self, n: usize) -> Result<&'a [u8], CanonicalDecodeError> {
|
||||
if self.b.len() - self.at < n {
|
||||
return Err(CanonicalDecodeError::Truncated {
|
||||
at: self.at,
|
||||
need: n - (self.b.len() - self.at),
|
||||
});
|
||||
}
|
||||
let s = &self.b[self.at..self.at + n];
|
||||
self.at += n;
|
||||
Ok(s)
|
||||
}
|
||||
fn u8(&mut self) -> Result<u8, CanonicalDecodeError> {
|
||||
Ok(self.take(1)?[0])
|
||||
}
|
||||
fn u16(&mut self) -> Result<u16, CanonicalDecodeError> {
|
||||
Ok(u16::from_le_bytes(self.take(2)?.try_into().unwrap()))
|
||||
}
|
||||
fn u32(&mut self) -> Result<u32, CanonicalDecodeError> {
|
||||
Ok(u32::from_le_bytes(self.take(4)?.try_into().unwrap()))
|
||||
}
|
||||
fn i64(&mut self) -> Result<i64, CanonicalDecodeError> {
|
||||
Ok(i64::from_le_bytes(self.take(8)?.try_into().unwrap()))
|
||||
}
|
||||
fn f32(&mut self) -> Result<f32, CanonicalDecodeError> {
|
||||
Ok(f32::from_le_bytes(self.take(4)?.try_into().unwrap()))
|
||||
}
|
||||
fn i8(&mut self) -> Result<i8, CanonicalDecodeError> {
|
||||
Ok(self.take(1)?[0] as i8)
|
||||
}
|
||||
fn uuid(&mut self) -> Result<Uuid, CanonicalDecodeError> {
|
||||
Ok(Uuid::from_bytes(self.take(16)?.try_into().unwrap()))
|
||||
}
|
||||
}
|
||||
|
||||
impl CsiFrame {
|
||||
/// Reconstruct a frame from its [`to_canonical_bytes`] encoding — the
|
||||
/// replay half of the ADR-136 contract. Round-trip law (tested):
|
||||
/// `from_canonical_bytes(f.to_canonical_bytes())` yields a frame with the
|
||||
/// **same id, metadata, payload, and witness hash** as `f`.
|
||||
///
|
||||
/// Amplitude/phase are recomputed from the complex payload (they are
|
||||
/// projections, not independent state).
|
||||
///
|
||||
/// [`to_canonical_bytes`]: crate::traits::CanonicalFrame::to_canonical_bytes
|
||||
///
|
||||
/// # Errors
|
||||
/// [`CanonicalDecodeError`] on truncation, bad discriminants, non-UTF-8
|
||||
/// device id, nonzero reserved bytes, shape/payload disagreement, or
|
||||
/// trailing bytes — every malformed input fails closed. Strictness
|
||||
/// guarantees injectivity on the accepted domain: any accepted byte
|
||||
/// string re-encodes to exactly itself.
|
||||
pub fn from_canonical_bytes(bytes: &[u8]) -> Result<Self, CanonicalDecodeError> {
|
||||
let mut c = Cursor { b: bytes, at: 0 };
|
||||
|
||||
let id = FrameId::from_uuid(c.uuid()?);
|
||||
|
||||
let seconds = c.i64()?;
|
||||
let nanos = c.u32()?;
|
||||
let dev_len = c.u32()? as usize;
|
||||
let device_id = core::str::from_utf8(c.take(dev_len)?)
|
||||
.map_err(|_| CanonicalDecodeError::BadDeviceId)?
|
||||
.to_string();
|
||||
let frequency_band = match c.u8()? {
|
||||
0 => FrequencyBand::Band2_4GHz,
|
||||
1 => FrequencyBand::Band5GHz,
|
||||
2 => FrequencyBand::Band6GHz,
|
||||
v => {
|
||||
return Err(CanonicalDecodeError::BadDiscriminant {
|
||||
field: "frequency_band",
|
||||
value: v,
|
||||
})
|
||||
}
|
||||
};
|
||||
let channel = c.u8()?;
|
||||
let bandwidth_mhz = c.u16()?;
|
||||
let tx_antennas = c.u8()?;
|
||||
let rx_antennas = c.u8()?;
|
||||
let spacing_mm = match c.u8()? {
|
||||
1 => Some(c.f32()?),
|
||||
0 => {
|
||||
// Reserved padding must be zero (decoder strictness =
|
||||
// injectivity on the accepted domain): otherwise forged
|
||||
// nonzero padding would decode to the same frame as the
|
||||
// canonical encoding and re-encode differently.
|
||||
if c.take(4)? != [0u8; 4] {
|
||||
return Err(CanonicalDecodeError::ReservedNotZero { field: "spacing_mm" });
|
||||
}
|
||||
None
|
||||
}
|
||||
v => {
|
||||
return Err(CanonicalDecodeError::BadDiscriminant {
|
||||
field: "spacing_mm",
|
||||
value: v,
|
||||
})
|
||||
}
|
||||
};
|
||||
let rssi_dbm = c.i8()?;
|
||||
let noise_floor_dbm = c.i8()?;
|
||||
let sequence_number = c.u32()?;
|
||||
let cal = c.uuid()?;
|
||||
let calibration_id = if cal == Uuid::nil() { None } else { Some(cal) };
|
||||
let model_id = c.u16()?;
|
||||
let model_version = c.u16()?;
|
||||
|
||||
let rows = c.u32()? as usize;
|
||||
let cols = c.u32()? as usize;
|
||||
let expect = rows.saturating_mul(cols).saturating_mul(16);
|
||||
let found = bytes.len() - c.at;
|
||||
if found < expect {
|
||||
return Err(CanonicalDecodeError::PayloadMismatch { rows, cols, expect, found });
|
||||
}
|
||||
let mut samples = Vec::with_capacity(rows * cols);
|
||||
for _ in 0..rows * cols {
|
||||
let raw: [u8; 16] = c.take(16)?.try_into().unwrap();
|
||||
samples.push(ComplexSample::from_le_bytes(raw).0);
|
||||
}
|
||||
if c.at != bytes.len() {
|
||||
return Err(CanonicalDecodeError::TrailingBytes(bytes.len() - c.at));
|
||||
}
|
||||
let data = Array2::from_shape_vec((rows, cols), samples).map_err(|_| {
|
||||
CanonicalDecodeError::PayloadMismatch { rows, cols, expect, found }
|
||||
})?;
|
||||
|
||||
let metadata = CsiMetadata {
|
||||
timestamp: Timestamp { seconds, nanos },
|
||||
device_id: DeviceId::new(device_id),
|
||||
frequency_band,
|
||||
channel,
|
||||
bandwidth_mhz,
|
||||
antenna_config: AntennaConfig { tx_antennas, rx_antennas, spacing_mm },
|
||||
rssi_dbm,
|
||||
noise_floor_dbm,
|
||||
sequence_number,
|
||||
calibration_id,
|
||||
model_id,
|
||||
model_version,
|
||||
};
|
||||
|
||||
let amplitude = data.mapv(num_complex::Complex::norm);
|
||||
let phase = data.mapv(num_complex::Complex::arg);
|
||||
Ok(Self { id, metadata, data, amplitude, phase })
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Signal Types
|
||||
// =============================================================================
|
||||
@@ -1307,6 +1522,133 @@ mod tests {
|
||||
assert_ne!(frame.witness_hash(), frame2.witness_hash());
|
||||
}
|
||||
|
||||
/// AC7 — replay: `from_canonical_bytes` is the exact inverse of
|
||||
/// `to_canonical_bytes` — same id, metadata, payload, and witness hash.
|
||||
/// This is the capture-to-claim law: a stored canonical capture replays to
|
||||
/// a frame the pipeline cannot distinguish from the original.
|
||||
#[test]
|
||||
fn ac7_canonical_round_trip_replays_identically() {
|
||||
use ndarray::Array2;
|
||||
let mut meta = CsiMetadata::new(DeviceId::new("node-α"), FrequencyBand::Band6GHz, 37);
|
||||
meta.set_calibration(uuid::Uuid::new_v4());
|
||||
meta.set_model(9, 0x0203);
|
||||
meta.antenna_config.spacing_mm = Some(62.5);
|
||||
meta.rssi_dbm = -41;
|
||||
meta.sequence_number = 123_456;
|
||||
let data = Array2::from_shape_fn((2, 56), |(r, c)| {
|
||||
Complex64::new((r as f64 + 1.0) * (c as f64).cos(), (c as f64 * 0.1).tan())
|
||||
});
|
||||
let frame = CsiFrame::new(meta, data);
|
||||
|
||||
let bytes = frame.to_canonical_bytes();
|
||||
let replayed = CsiFrame::from_canonical_bytes(&bytes).expect("decodes");
|
||||
|
||||
assert_eq!(replayed.id, frame.id);
|
||||
// Field-wise metadata equality (CsiMetadata has no PartialEq; the
|
||||
// byte-identical re-encoding below covers every field regardless).
|
||||
assert_eq!(replayed.metadata.device_id, frame.metadata.device_id);
|
||||
assert_eq!(replayed.metadata.calibration_id, frame.metadata.calibration_id);
|
||||
assert_eq!(replayed.metadata.model_version, frame.metadata.model_version);
|
||||
assert_eq!(replayed.metadata.antenna_config.spacing_mm, Some(62.5));
|
||||
assert_eq!(replayed.data, frame.data);
|
||||
// Witness equality — the strongest statement of equivalence.
|
||||
assert_eq!(replayed.witness_hash(), frame.witness_hash());
|
||||
// Re-encoding is byte-identical.
|
||||
assert_eq!(replayed.to_canonical_bytes(), bytes);
|
||||
// Projections recomputed consistently.
|
||||
assert_eq!(replayed.amplitude, frame.amplitude);
|
||||
}
|
||||
|
||||
/// AC8 — the decoder fails closed on every malformed-input class.
|
||||
#[test]
|
||||
fn ac8_canonical_decode_fails_closed() {
|
||||
use ndarray::Array2;
|
||||
let meta = CsiMetadata::new(DeviceId::new("n"), FrequencyBand::Band2_4GHz, 1);
|
||||
let data = Array2::from_shape_fn((1, 4), |(_, c)| Complex64::new(c as f64, 0.0));
|
||||
let frame = CsiFrame::new(meta, data);
|
||||
let bytes = frame.to_canonical_bytes();
|
||||
|
||||
// Truncation anywhere fails: in the payload it is caught by the
|
||||
// shape-vs-length check (PayloadMismatch); in the header by Truncated.
|
||||
assert!(matches!(
|
||||
CsiFrame::from_canonical_bytes(&bytes[..bytes.len() - 1]),
|
||||
Err(CanonicalDecodeError::PayloadMismatch { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
CsiFrame::from_canonical_bytes(&bytes[..10]),
|
||||
Err(CanonicalDecodeError::Truncated { .. })
|
||||
));
|
||||
|
||||
// Trailing junk fails.
|
||||
let mut padded = bytes.clone();
|
||||
padded.extend_from_slice(&[0u8; 3]);
|
||||
assert!(matches!(
|
||||
CsiFrame::from_canonical_bytes(&padded),
|
||||
Err(CanonicalDecodeError::TrailingBytes(3))
|
||||
));
|
||||
|
||||
// Bad frequency-band discriminant fails. Band byte sits right after
|
||||
// id(16) + seconds(8) + nanos(4) + dev_len(4) + dev("n" = 1).
|
||||
let mut bad = bytes.clone();
|
||||
bad[16 + 8 + 4 + 4 + 1] = 9;
|
||||
assert!(matches!(
|
||||
CsiFrame::from_canonical_bytes(&bad),
|
||||
Err(CanonicalDecodeError::BadDiscriminant { field: "frequency_band", value: 9 })
|
||||
));
|
||||
|
||||
// A nil calibration uuid decodes as None (the documented encoding).
|
||||
let replayed = CsiFrame::from_canonical_bytes(&bytes).unwrap();
|
||||
assert_eq!(replayed.metadata.calibration_id, None);
|
||||
}
|
||||
|
||||
/// AC8b (review finding 7) — decoder strictness = injectivity on the
|
||||
/// accepted domain: forged nonzero bytes in the `spacing_mm` reserved
|
||||
/// region are rejected, so for accepted inputs `re-encode != original`
|
||||
/// is impossible.
|
||||
#[test]
|
||||
fn ac8b_forged_reserved_spacing_bytes_rejected() {
|
||||
use ndarray::Array2;
|
||||
let meta = CsiMetadata::new(DeviceId::new("n"), FrequencyBand::Band2_4GHz, 1);
|
||||
let data = Array2::from_shape_fn((1, 4), |(_, c)| Complex64::new(c as f64, 0.0));
|
||||
let frame = CsiFrame::new(meta, data);
|
||||
let bytes = frame.to_canonical_bytes();
|
||||
|
||||
// Spacing tag sits after id(16)+secs(8)+nanos(4)+dev_len(4)+dev("n"=1)
|
||||
// + band(1)+channel(1)+bw(2)+tx(1)+rx(1); the 4 reserved bytes follow.
|
||||
let tag_off = 16 + 8 + 4 + 4 + 1 + 1 + 1 + 2 + 1 + 1;
|
||||
assert_eq!(bytes[tag_off], 0, "fixture must encode spacing_mm = None");
|
||||
assert_eq!(&bytes[tag_off + 1..tag_off + 5], &[0u8; 4]);
|
||||
|
||||
// Sanity: the canonical bytes decode and re-encode byte-identically.
|
||||
let ok = CsiFrame::from_canonical_bytes(&bytes).unwrap();
|
||||
assert_eq!(ok.to_canonical_bytes(), bytes);
|
||||
|
||||
// Forge each reserved byte: the decoder must fail closed (before the
|
||||
// fix it decoded to the same frame, whose re-encoding differed from
|
||||
// the forged original — a witness-replay ambiguity).
|
||||
for i in 1..=4 {
|
||||
let mut forged = bytes.clone();
|
||||
forged[tag_off + i] = 0xAB;
|
||||
assert!(matches!(
|
||||
CsiFrame::from_canonical_bytes(&forged),
|
||||
Err(CanonicalDecodeError::ReservedNotZero { field: "spacing_mm" })
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// AC8c (review finding 7) — `Some(Uuid::nil())` calibration is an
|
||||
/// encoding error: nil is the wire sentinel for `None`, so encoding it
|
||||
/// would alias two distinct frames to one byte string (and one witness).
|
||||
#[test]
|
||||
#[should_panic(expected = "nil is the None sentinel")]
|
||||
fn ac8c_nil_calibration_id_is_an_encoding_error() {
|
||||
use ndarray::Array2;
|
||||
let mut meta = CsiMetadata::new(DeviceId::new("n"), FrequencyBand::Band2_4GHz, 1);
|
||||
meta.calibration_id = Some(uuid::Uuid::nil());
|
||||
let data = Array2::from_shape_fn((1, 2), |(_, c)| Complex64::new(c as f64, 0.0));
|
||||
let _ = CsiFrame::new(meta, data).to_canonical_bytes();
|
||||
}
|
||||
|
||||
/// AC3 — `serde(default)` forward-read of pre-ADR-136 metadata JSON.
|
||||
#[cfg(feature = "serde")]
|
||||
#[test]
|
||||
|
||||
@@ -19,6 +19,9 @@ wifi-densepose-worldgraph = { version = "0.3.0", path = "../wifi-densepose-world
|
||||
wifi-densepose-geo = { version = "0.1.0", path = "../wifi-densepose-geo" }
|
||||
# Deterministic witness over the trust decision (ADR-137 §2.7 / ADR-028).
|
||||
blake3 = { version = "1.5", default-features = false }
|
||||
# Dynamic min-cut over the live mesh coupling graph (mesh_guard.rs):
|
||||
# incremental partition-risk monitoring + structural recalibration trigger.
|
||||
ruvector-mincut = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
criterion = { version = "0.5", features = ["html_reports"] }
|
||||
|
||||
@@ -48,5 +48,41 @@ fn bench_cycle(c: &mut Criterion) {
|
||||
});
|
||||
}
|
||||
|
||||
criterion_group!(benches, bench_cycle);
|
||||
/// Mesh guard in isolation: cold build (node set appears) vs steady state
|
||||
/// (identical weights next cycle → change-gated, zero graph updates) for a
|
||||
/// 12-node mesh — the full ADR-029 deployment size.
|
||||
fn bench_mesh_guard(c: &mut Criterion) {
|
||||
use wifi_densepose_engine::MeshGuard;
|
||||
let nodes: Vec<u8> = (0..12).collect();
|
||||
let w = |i: usize, j: usize| 0.4 + 0.01 * ((i + j) % 7) as f64;
|
||||
|
||||
c.bench_function("mesh_guard_cold_build_12n", |b| {
|
||||
b.iter_batched(
|
||||
MeshGuard::default,
|
||||
|mut g| g.update(&nodes, w),
|
||||
BatchSize::SmallInput,
|
||||
);
|
||||
});
|
||||
|
||||
c.bench_function("mesh_guard_steady_state_12n", |b| {
|
||||
let mut g = MeshGuard::default();
|
||||
g.update(&nodes, w); // warm
|
||||
b.iter(|| g.update(&nodes, w));
|
||||
});
|
||||
|
||||
c.bench_function("mesh_guard_one_edge_change_12n", |b| {
|
||||
let mut g = MeshGuard::default();
|
||||
g.update(&nodes, w);
|
||||
let mut flip = false;
|
||||
b.iter(|| {
|
||||
flip = !flip;
|
||||
let delta = if flip { 0.2 } else { 0.0 };
|
||||
g.update(&nodes, |i, j| {
|
||||
if (i.min(j), i.max(j)) == (0, 1) { 0.4 + delta } else { w(i, j) }
|
||||
})
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
criterion_group!(benches, bench_cycle, bench_mesh_guard);
|
||||
criterion_main!(benches);
|
||||
|
||||
@@ -46,6 +46,9 @@ use wifi_densepose_worldgraph::{
|
||||
WorldId, WorldNode, ZoneBoundsEnu,
|
||||
};
|
||||
|
||||
pub mod mesh_guard;
|
||||
pub use mesh_guard::{MeshGuard, MeshPartitionReport};
|
||||
|
||||
/// Errors from an engine cycle.
|
||||
#[derive(Debug)]
|
||||
pub enum EngineError {
|
||||
@@ -97,6 +100,15 @@ pub struct TrustedOutput {
|
||||
/// BLAKE3 witness over the trust decision (provenance ‖ class ‖ calibration)
|
||||
/// — a deterministic, signed-belief fingerprint (ADR-137 §2.7 / ADR-028).
|
||||
pub witness: [u8; 32],
|
||||
/// Whether the drift→recalibration advisor recommends re-running the
|
||||
/// ADR-135 baseline / refitting the per-room adapter (ADR-150 §3.4):
|
||||
/// sustained low coherence or an ADR-142 change-point this cycle.
|
||||
pub recalibration_recommended: bool,
|
||||
/// Dynamic min-cut partition report over the live mesh coupling graph
|
||||
/// (None for meshes of fewer than two nodes). `at_risk` counts as a
|
||||
/// structural event for the recalibration advisor and names the nodes
|
||||
/// (`weak_side`) closest to splitting off — failure/jamming triage.
|
||||
pub mesh: Option<MeshPartitionReport>,
|
||||
}
|
||||
|
||||
/// Composition root for the RuView streaming engine.
|
||||
@@ -116,6 +128,74 @@ pub struct StreamingEngine {
|
||||
slam: RfSlam,
|
||||
// ADR-139 live loop: stable track_id -> PersonTrack WorldId.
|
||||
person_tracks: BTreeMap<u64, WorldId>,
|
||||
// WorldGraph belief retention: max live SemanticState nodes. The live loop
|
||||
// appends one belief per cycle (1.7M/day at 20 Hz); durable history is the
|
||||
// recorder's job, so old beliefs are evicted deterministically past this cap.
|
||||
semantic_retention: usize,
|
||||
// Per-room calibration adapter (ADR-150 §3.4: ~11 KB LoRA on a frozen
|
||||
// base). Identity is part of the trust chain: when set, the adapter id is
|
||||
// appended to the provenance model_version, so swapping adapters changes
|
||||
// the witness. None = shared base model.
|
||||
adapter: Option<AdapterInfo>,
|
||||
// Drift→recalibration advisor (ADR-135 trigger for ADR-150 §3.4 refit).
|
||||
recal: RecalibrationAdvisor,
|
||||
// Dynamic min-cut mesh partition guard (incremental, change-gated).
|
||||
mesh: MeshGuard,
|
||||
}
|
||||
|
||||
/// Identity of an active per-room calibration adapter (ADR-150 §3.4). The id
|
||||
/// must be content-derived (e.g. a hash prefix of the adapter file) so the
|
||||
/// provenance/witness chain pins the exact weights that shaped inference.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AdapterInfo {
|
||||
/// Content-derived adapter identity (e.g. first 16 hex of its SHA-256).
|
||||
pub adapter_id: String,
|
||||
/// Number of in-room samples the adapter was fitted on (0 if unknown).
|
||||
pub trained_samples: u32,
|
||||
}
|
||||
|
||||
/// Recommends re-running calibration / adapter refit when the live signal
|
||||
/// degrades persistently (ADR-135 drift → ADR-150 §3.4 few-shot recalibration).
|
||||
///
|
||||
/// Two triggers, both cheap and deterministic:
|
||||
/// - `low_coherence_streak`: N consecutive cycles whose base coherence fell
|
||||
/// below the floor (sustained degradation, not a single bad frame);
|
||||
/// - any ADR-142 change-point this cycle (the environment itself changed).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RecalibrationAdvisor {
|
||||
/// Coherence below this counts toward the streak.
|
||||
pub coherence_floor: f32,
|
||||
/// Consecutive low-coherence cycles required to recommend recalibration.
|
||||
pub streak_threshold: u32,
|
||||
streak: u32,
|
||||
}
|
||||
|
||||
impl Default for RecalibrationAdvisor {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
coherence_floor: 0.5,
|
||||
streak_threshold: 60, // ~3 s at 20 Hz of sustained degradation
|
||||
streak: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RecalibrationAdvisor {
|
||||
/// Feed one cycle's evidence; returns whether recalibration is recommended.
|
||||
fn observe(&mut self, base_coherence: f32, change_point: bool) -> bool {
|
||||
if base_coherence < self.coherence_floor {
|
||||
self.streak = self.streak.saturating_add(1);
|
||||
} else {
|
||||
self.streak = 0;
|
||||
}
|
||||
change_point || self.streak >= self.streak_threshold
|
||||
}
|
||||
|
||||
/// Current consecutive low-coherence cycle count.
|
||||
#[must_use]
|
||||
pub fn streak(&self) -> u32 {
|
||||
self.streak
|
||||
}
|
||||
}
|
||||
|
||||
impl StreamingEngine {
|
||||
@@ -135,9 +215,53 @@ impl StreamingEngine {
|
||||
evolution: None,
|
||||
slam: RfSlam::with_discovery(0.5, 5, 0.6),
|
||||
person_tracks: BTreeMap::new(),
|
||||
semantic_retention: Self::DEFAULT_SEMANTIC_RETENTION,
|
||||
adapter: None,
|
||||
recal: RecalibrationAdvisor::default(),
|
||||
mesh: MeshGuard::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Activate a per-room calibration adapter (ADR-150 §3.4). From the next
|
||||
/// cycle on, the adapter id is part of provenance `model_version` — and
|
||||
/// therefore of the witness — so the exact weights shaping inference are
|
||||
/// pinned in the trust chain. Pass the result of hashing the adapter file.
|
||||
pub fn set_room_adapter(&mut self, info: AdapterInfo) {
|
||||
self.adapter = Some(info);
|
||||
}
|
||||
|
||||
/// Deactivate the adapter (revert to the shared base model).
|
||||
pub fn clear_room_adapter(&mut self) {
|
||||
self.adapter = None;
|
||||
}
|
||||
|
||||
/// The active adapter, if any.
|
||||
#[must_use]
|
||||
pub fn room_adapter(&self) -> Option<&AdapterInfo> {
|
||||
self.adapter.as_ref()
|
||||
}
|
||||
|
||||
/// Tune the drift→recalibration advisor (floor + streak threshold).
|
||||
pub fn set_recalibration_advisor(&mut self, advisor: RecalibrationAdvisor) {
|
||||
self.recal = advisor;
|
||||
}
|
||||
|
||||
/// Mutable access to the mesh partition guard (risk threshold, quantum,
|
||||
/// min-node count). Operators tune the partition-risk sensitivity here.
|
||||
pub fn mesh_guard_mut(&mut self) -> &mut MeshGuard {
|
||||
&mut self.mesh
|
||||
}
|
||||
|
||||
/// Default cap on live `SemanticState` beliefs in the WorldGraph
|
||||
/// (~6 minutes of full-rate history at 20 Hz; older beliefs are evicted —
|
||||
/// durable history belongs to the recorder).
|
||||
pub const DEFAULT_SEMANTIC_RETENTION: usize = 7_200;
|
||||
|
||||
/// Override the `SemanticState` retention cap (minimum 1).
|
||||
pub fn set_semantic_retention(&mut self, max_states: usize) {
|
||||
self.semantic_retention = max_states.max(1);
|
||||
}
|
||||
|
||||
/// ADR-139 live loop: create or update a `PersonTrack` node by stable
|
||||
/// `track_id`, locate it in `room`, and wire an `Observes` edge from
|
||||
/// `sensor` (so the privacy rollup can suppress it under identity-strict
|
||||
@@ -321,21 +445,47 @@ impl StreamingEngine {
|
||||
// 4. Evolution change-point (ADR-142) over per-node mean amplitude.
|
||||
let change_point = self.track_evolution(node_frames, now_ms, room);
|
||||
|
||||
// 5. Privacy control plane (ADR-141): demote on a fusion-level OR an
|
||||
// array-level contradiction (monotonic — information only removed).
|
||||
// 5. Mesh partition guard (ADR-032): dynamic min-cut over the coupling
|
||||
// graph. Coupling between nodes i and j is the product of their
|
||||
// fusion attention weights scaled by the node count, so a node the
|
||||
// fuser down-weights is exactly a node weakly coupled in the graph.
|
||||
// (Change-gated incremental updates: steady state touches 0 edges.)
|
||||
let node_ids: Vec<u8> = node_frames.iter().map(|f| f.node_id).collect();
|
||||
let weights = &quality.per_node_weights;
|
||||
let n = weights.len() as f64;
|
||||
let mesh = self.mesh.update(&node_ids, |i, j| {
|
||||
let wi = weights.get(i).copied().unwrap_or(0.0) as f64;
|
||||
let wj = weights.get(j).copied().unwrap_or(0.0) as f64;
|
||||
wi * wj * n
|
||||
});
|
||||
let mesh_at_risk = mesh.as_ref().is_some_and(|m| m.at_risk);
|
||||
|
||||
// 6. Privacy control plane (ADR-141): demote on a fusion-level OR an
|
||||
// array-level contradiction OR a mesh close to partitioning. The
|
||||
// last is a security/reliability signal (ADR-032): a fragmenting
|
||||
// array makes the fused belief less trustworthy, so we emit at a
|
||||
// more restricted class. Monotonic — information is only ever
|
||||
// removed — and the demotion is part of the witness.
|
||||
let base_class = self.privacy.active_class();
|
||||
let demoted = quality.forces_privacy_demotion() || array_contradiction;
|
||||
let demoted = quality.forces_privacy_demotion() || array_contradiction || mesh_at_risk;
|
||||
let effective_class = if demoted { demote_one(base_class) } else { base_class };
|
||||
|
||||
// 6. Semantic state with mandatory provenance (ADR-139/140). The
|
||||
// 7. Semantic state with mandatory provenance (ADR-139/140). The
|
||||
// calibration version comes from the *agreed* epoch (None on mismatch).
|
||||
// When a per-room adapter is active (ADR-150 §3.4) its content-derived
|
||||
// id is part of model_version — and therefore of the witness — so the
|
||||
// exact weights shaping inference are pinned in the trust chain.
|
||||
let calibration_version = match quality.calibration_id {
|
||||
Some(c) => format!("cal:{:016x}", c.0),
|
||||
None => "cal:none".to_string(),
|
||||
};
|
||||
let model_version = match &self.adapter {
|
||||
Some(a) => format!("rfenc-v{}+adapter:{}", self.model_version, a.adapter_id),
|
||||
None => format!("rfenc-v{}", self.model_version),
|
||||
};
|
||||
let provenance = SemanticProvenance {
|
||||
evidence: quality.evidence_refs.iter().map(|e| format!("{e:?}")).collect(),
|
||||
model_version: format!("rfenc-v{}", self.model_version),
|
||||
model_version,
|
||||
calibration_version,
|
||||
privacy_decision: format!("{:?}/{:?}", self.privacy.active_mode(), effective_class),
|
||||
};
|
||||
@@ -350,10 +500,23 @@ impl StreamingEngine {
|
||||
provenance.clone(),
|
||||
&[room],
|
||||
);
|
||||
// Retention: bound the live belief set (one node is appended per cycle;
|
||||
// without this the graph grows ~1.7M nodes/day at 20 Hz). Deterministic
|
||||
// eviction; the just-added belief is always newest and survives.
|
||||
self.world.prune_semantic_states(self.semantic_retention);
|
||||
|
||||
// 7. Deterministic witness over the trust decision (ADR-137 §2.7).
|
||||
// 8. Deterministic witness over the trust decision (ADR-137 §2.7).
|
||||
// `effective_class` already reflects any mesh-risk demotion, so a
|
||||
// fragmenting array shifts the witness — partition risk is auditable.
|
||||
let witness = witness_of(&provenance, effective_class);
|
||||
|
||||
// 9. Drift→recalibration advisor (ADR-135 → ADR-150 §3.4): sustained
|
||||
// low coherence, an environment change-point, or a mesh close to
|
||||
// partitioning recommends refit.
|
||||
let recalibration_recommended = self
|
||||
.recal
|
||||
.observe(quality.base_coherence, change_point.is_some() || mesh_at_risk);
|
||||
|
||||
self.cycle += 1;
|
||||
Ok(TrustedOutput {
|
||||
semantic_id,
|
||||
@@ -364,6 +527,8 @@ impl StreamingEngine {
|
||||
directional,
|
||||
change_point,
|
||||
witness,
|
||||
recalibration_recommended,
|
||||
mesh,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -547,6 +712,205 @@ mod tests {
|
||||
assert_eq!(o1.quality.per_node_weights, o2.quality.per_node_weights);
|
||||
}
|
||||
|
||||
/// ADR-150 §3.4 adapter provenance: activating a per-room adapter changes
|
||||
/// the provenance model_version AND the witness — the exact weights shaping
|
||||
/// inference are pinned in the trust chain, so an adapter can never swap
|
||||
/// silently. Clearing it restores the base identity (and base witness).
|
||||
#[test]
|
||||
fn adapter_identity_is_witnessed() {
|
||||
let cal = CalibrationId(9);
|
||||
let frames = [node_frame(0, 1000, 56), node_frame(1, 1001, 56)];
|
||||
|
||||
let (mut e, room) = engine();
|
||||
let base = e.process_cycle(&frames, cal, room, 1_000).unwrap();
|
||||
assert_eq!(base.provenance.model_version, "rfenc-v1");
|
||||
|
||||
e.set_room_adapter(AdapterInfo {
|
||||
adapter_id: "a1b2c3d4e5f60718".into(),
|
||||
trained_samples: 150,
|
||||
});
|
||||
let adapted = e.process_cycle(&frames, cal, room, 2_000).unwrap();
|
||||
assert_eq!(
|
||||
adapted.provenance.model_version,
|
||||
"rfenc-v1+adapter:a1b2c3d4e5f60718"
|
||||
);
|
||||
assert_ne!(adapted.witness, base.witness, "adapter must shift the witness");
|
||||
|
||||
// A different adapter id yields a different witness again.
|
||||
e.set_room_adapter(AdapterInfo {
|
||||
adapter_id: "ffffffffffffffff".into(),
|
||||
trained_samples: 150,
|
||||
});
|
||||
let other = e.process_cycle(&frames, cal, room, 3_000).unwrap();
|
||||
assert_ne!(other.witness, adapted.witness);
|
||||
|
||||
// Clearing restores the base identity and the base witness.
|
||||
e.clear_room_adapter();
|
||||
let back = e.process_cycle(&frames, cal, room, 4_000).unwrap();
|
||||
assert_eq!(back.provenance.model_version, "rfenc-v1");
|
||||
assert_eq!(back.witness, base.witness);
|
||||
}
|
||||
|
||||
/// Drift→recalibration advisor logic: a sustained low-coherence streak
|
||||
/// recommends refit; a single healthy cycle resets the streak; a
|
||||
/// change-point recommends immediately regardless of streak.
|
||||
#[test]
|
||||
fn recalibration_advisor_streak_and_change_point() {
|
||||
let mut adv = RecalibrationAdvisor {
|
||||
coherence_floor: 0.5,
|
||||
streak_threshold: 3,
|
||||
..Default::default()
|
||||
};
|
||||
// Healthy cycles never recommend and keep the streak at zero.
|
||||
for _ in 0..5 {
|
||||
assert!(!adv.observe(0.9, false));
|
||||
}
|
||||
assert_eq!(adv.streak(), 0);
|
||||
// Two low cycles: not yet.
|
||||
assert!(!adv.observe(0.2, false));
|
||||
assert!(!adv.observe(0.2, false));
|
||||
// Third consecutive low cycle: fire.
|
||||
assert!(adv.observe(0.2, false));
|
||||
// Recovery resets the streak.
|
||||
assert!(!adv.observe(0.9, false));
|
||||
assert_eq!(adv.streak(), 0);
|
||||
// A change-point recommends immediately, even at full coherence.
|
||||
assert!(adv.observe(0.9, true));
|
||||
}
|
||||
|
||||
/// Engine-level: clean coherent cycles never recommend recalibration (the
|
||||
/// advisor is wired into process_cycle and stays quiet on healthy input).
|
||||
#[test]
|
||||
fn healthy_cycles_do_not_recommend_recalibration() {
|
||||
let (mut e, room) = engine();
|
||||
e.set_recalibration_advisor(RecalibrationAdvisor {
|
||||
coherence_floor: 0.5,
|
||||
streak_threshold: 3,
|
||||
..Default::default()
|
||||
});
|
||||
let cal = CalibrationId(2);
|
||||
for i in 0..5u64 {
|
||||
let frames = [
|
||||
node_frame(0, 1_000 + i * 50_000, 56),
|
||||
node_frame(1, 1_001 + i * 50_000, 56),
|
||||
];
|
||||
let out = e.process_cycle(&frames, cal, room, i as i64).unwrap();
|
||||
assert!(!out.recalibration_recommended);
|
||||
}
|
||||
}
|
||||
|
||||
/// Maximum total coupling mass of an n-node mesh whose attention weights
|
||||
/// sum to 1 (coupling = wᵢ·wⱼ·n): Σ_{i<j} wᵢwⱼ·n = n(1−Σwᵢ²)/2 ≤ (n−1)/2.
|
||||
/// Any cut is a subset of the edges, so every achievable cut value is
|
||||
/// bounded by this mass — a risk threshold at or above it is *guaranteed*
|
||||
/// to be crossed (deterministic fixture, review finding 4).
|
||||
fn max_coupling_mass(n_nodes: usize) -> f64 {
|
||||
(n_nodes as f64 - 1.0) / 2.0
|
||||
}
|
||||
|
||||
/// Mesh guard wiring: a balanced 2-node cycle reports a mesh (cut exists)
|
||||
/// but never flags risk (min_nodes=3); a 3-node mesh whose cut value
|
||||
/// *deterministically* falls at or below the configured risk threshold
|
||||
/// (threshold = the provable upper bound on any achievable cut) is flagged
|
||||
/// at_risk, and the structural event feeds the recalibration advisor
|
||||
/// immediately — no conditional assertions (review finding 4).
|
||||
#[test]
|
||||
fn mesh_partition_risk_feeds_recalibration() {
|
||||
let (mut e, room) = engine();
|
||||
let cal = CalibrationId(3);
|
||||
|
||||
// Balanced 2-node mesh: report present, no risk.
|
||||
let out = e
|
||||
.process_cycle(&[node_frame(0, 1000, 56), node_frame(1, 1001, 56)], cal, room, 1)
|
||||
.unwrap();
|
||||
let mesh = out.mesh.expect("2-node mesh reports");
|
||||
assert!(!mesh.at_risk);
|
||||
assert!(!out.recalibration_recommended);
|
||||
|
||||
// 3-node mesh with the operator risk threshold set to the provable
|
||||
// cut upper bound: the crossing is deterministic regardless of the
|
||||
// fuser's exact weighting.
|
||||
e.mesh_guard_mut().risk_threshold = max_coupling_mass(3);
|
||||
let frames = [
|
||||
node_frame(0, 10_000_000, 56),
|
||||
node_frame(1, 10_000_001, 56),
|
||||
node_frame(2, 10_000_002, 56),
|
||||
];
|
||||
let out3 = e.process_cycle(&frames, cal, room, 2).unwrap();
|
||||
let m3 = out3.mesh.expect("3-node mesh reports");
|
||||
assert!(m3.at_risk, "cut ≤ threshold must flag partition risk");
|
||||
assert!(
|
||||
out3.recalibration_recommended,
|
||||
"mesh risk is a structural event — the advisor must fire immediately, no streak"
|
||||
);
|
||||
assert!(m3.cut_value.is_finite() && m3.cut_value >= 0.0);
|
||||
}
|
||||
|
||||
/// Mesh partition risk demotes the privacy class and shifts the witness —
|
||||
/// a fragmenting array makes the fused belief less trustworthy, so it is
|
||||
/// emitted at a more restricted class, and that demotion is auditable.
|
||||
/// Both cycles use the *same 3-node topology and frames*; the engines
|
||||
/// differ only in the forced mesh risk, so the witness delta is
|
||||
/// attributable to the risk demotion alone (review finding 4).
|
||||
#[test]
|
||||
fn mesh_risk_demotes_privacy_and_shifts_witness() {
|
||||
let cal = CalibrationId(8);
|
||||
let frames3 = [
|
||||
node_frame(0, 1000, 56),
|
||||
node_frame(1, 1001, 56),
|
||||
node_frame(2, 1002, 56),
|
||||
];
|
||||
|
||||
// Baseline: same topology, default risk threshold — clean cycle, not
|
||||
// demoted (PrivateHome → Anonymous), mesh healthy.
|
||||
let (mut e1, r1) = engine();
|
||||
let base = e1.process_cycle(&frames3, cal, r1, 5_000).unwrap();
|
||||
assert!(!base.mesh.as_ref().unwrap().at_risk);
|
||||
assert!(!base.demoted);
|
||||
assert_eq!(base.effective_class, PrivacyClass::Anonymous);
|
||||
|
||||
// Forced risk: identical frames/topology, threshold at the provable
|
||||
// cut upper bound so the crossing is deterministic.
|
||||
let (mut e2, r2) = engine();
|
||||
e2.mesh_guard_mut().risk_threshold = max_coupling_mass(3);
|
||||
let risky = e2.process_cycle(&frames3, cal, r2, 5_000).unwrap();
|
||||
assert!(risky.mesh.as_ref().unwrap().at_risk);
|
||||
assert!(risky.demoted, "mesh risk must demote");
|
||||
// PrivateHome base Anonymous(2) → demoted to Restricted(3).
|
||||
assert_eq!(risky.effective_class, PrivacyClass::Restricted);
|
||||
assert!(risky.provenance.privacy_decision.contains("Restricted"));
|
||||
assert_ne!(
|
||||
risky.witness, base.witness,
|
||||
"same topology, risk-only delta must shift the witness"
|
||||
);
|
||||
}
|
||||
|
||||
/// WorldGraph belief retention: the live loop appends one SemanticState per
|
||||
/// cycle; past the cap the oldest beliefs are evicted so graph memory is
|
||||
/// bounded, while structural nodes and the newest belief always survive.
|
||||
#[test]
|
||||
fn semantic_state_growth_is_bounded() {
|
||||
let (mut e, room) = engine();
|
||||
e.set_semantic_retention(5);
|
||||
let cal = CalibrationId(1);
|
||||
let mut last_id = None;
|
||||
let baseline_nodes = 2; // room + sensor
|
||||
for i in 0..20u64 {
|
||||
let frames = [
|
||||
node_frame(0, 1000 + i * 50_000, 56),
|
||||
node_frame(1, 1001 + i * 50_000, 56),
|
||||
];
|
||||
let out = e.process_cycle(&frames, cal, room, 5_000 + i as i64).unwrap();
|
||||
last_id = Some(out.semantic_id);
|
||||
assert!(e.world().node_count() <= baseline_nodes + 5);
|
||||
}
|
||||
// 20 cycles ran, only 5 beliefs remain, newest is still present.
|
||||
assert_eq!(e.world().node_count(), baseline_nodes + 5);
|
||||
assert!(e.world().node(last_id.unwrap()).is_some());
|
||||
// Structural nodes survive eviction.
|
||||
assert!(e.world().node(room).is_some());
|
||||
}
|
||||
|
||||
fn node_frame_scaled(node_id: u8, ts_us: u64, n_sub: usize, scale: f32) -> MultiBandCsiFrame {
|
||||
MultiBandCsiFrame {
|
||||
node_id,
|
||||
|
||||
@@ -0,0 +1,364 @@
|
||||
//! Mesh partition guard: dynamic min-cut over the live multistatic node graph.
|
||||
//!
|
||||
//! The fusion mesh (nodes = sensing nodes, edge weights = fusion coupling
|
||||
//! derived from per-node attention weights) changes *incrementally* at cycle
|
||||
//! rate — one node's coupling drifts, a node joins or drops. This module
|
||||
//! maintains a [`ruvector_mincut::DynamicMinCut`] over that graph and exposes,
|
||||
//! per cycle:
|
||||
//!
|
||||
//! - the **min-cut value** — the cheapest set of couplings whose loss splits
|
||||
//! the mesh in two: a principled, global "how close is the array to
|
||||
//! partitioning" number (vs per-node heuristics that miss multi-node
|
||||
//! structure);
|
||||
//! - the **weak side** — which specific nodes are about to partition (feeds
|
||||
//! failure/jamming triage, ADR-032 posture);
|
||||
//! - an **at-risk flag** consumed by the engine: it counts as a structural
|
||||
//! event for the drift→recalibration advisor.
|
||||
//!
|
||||
//! ## Cost model (the optimization)
|
||||
//!
|
||||
//! Weights are quantized (default 1/64; a *nonzero* coupling below one quantum
|
||||
//! saturates to quantum 1 so a live coupling is never erased — see
|
||||
//! [`MeshGuard::weight_quantum`]) and updates are **change-gated**: an
|
||||
//! edge is touched only when its quantized weight actually moves, so the
|
||||
//! steady-state cycle applies *zero* graph updates and reuses the cached cut —
|
||||
//! O(active-changes) per cycle, not O(n²) rebuilds. The exact (deterministic)
|
||||
//! algorithm is used; mesh sizes are ≤ tens of nodes, far inside its budget.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use ruvector_mincut::{DynamicMinCut, MinCutBuilder};
|
||||
|
||||
/// Per-cycle report from the mesh guard.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct MeshPartitionReport {
|
||||
/// Current min-cut value over the coupling graph (higher = more robust).
|
||||
pub cut_value: f64,
|
||||
/// True when the mesh has ≥ `min_nodes` nodes and the cut value fell to or
|
||||
/// below the risk threshold — the array is close to splitting.
|
||||
pub at_risk: bool,
|
||||
/// The smaller side of the min-cut partition (node ids): the nodes that
|
||||
/// would be isolated if the weak couplings failed.
|
||||
pub weak_side: Vec<u8>,
|
||||
/// Incremental edge updates applied this cycle (0 in steady state).
|
||||
pub updates_applied: usize,
|
||||
}
|
||||
|
||||
/// Dynamic min-cut guard over the live mesh.
|
||||
pub struct MeshGuard {
|
||||
mincut: Option<DynamicMinCut>,
|
||||
/// Node set the structure was built over (sorted). A change forces rebuild.
|
||||
nodes: Vec<u8>,
|
||||
/// Quantized edge weights currently installed, keyed `(u, v)` with `u < v`.
|
||||
edges: BTreeMap<(u8, u8), i64>,
|
||||
/// Weight quantum: weights are snapped to multiples of this before
|
||||
/// comparison/installation, gating out sub-quantum jitter.
|
||||
///
|
||||
/// Policy: a **nonzero** coupling below one quantum saturates to quantum 1
|
||||
/// instead of quantizing to 0 — quantization never erases a live coupling.
|
||||
/// (Without the floor, a balanced mesh of ≥ 65 nodes — attention weights
|
||||
/// ~1/n ⇒ couplings ~1/n < 1/64 — had every edge erased and was reported
|
||||
/// permanently "already partitioned"/at-risk.) Exact zero stays zero: a
|
||||
/// truly absent coupling *is* a partition. Relative weakness below one
|
||||
/// quantum is not resolved; lower this quantum if that resolution matters.
|
||||
pub weight_quantum: f64,
|
||||
/// Cut value at or below which the mesh counts as at partition risk.
|
||||
pub risk_threshold: f64,
|
||||
/// Minimum node count for risk to be meaningful (a 2-node mesh always has
|
||||
/// a trivial cut; default 3).
|
||||
pub min_nodes: usize,
|
||||
}
|
||||
|
||||
impl Default for MeshGuard {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
mincut: None,
|
||||
nodes: Vec::new(),
|
||||
edges: BTreeMap::new(),
|
||||
weight_quantum: 1.0 / 64.0,
|
||||
risk_threshold: 0.25,
|
||||
min_nodes: 3,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MeshGuard {
|
||||
/// Quantize a raw weight to the guard's grid (floor; weights are ≥ 0).
|
||||
/// Nonzero sub-quantum weights saturate to quantum 1 — see the
|
||||
/// [`Self::weight_quantum`] policy (review finding: sub-quantum couplings
|
||||
/// must not produce a false "already partitioned").
|
||||
fn quantize(&self, w: f64) -> i64 {
|
||||
let w = w.max(0.0);
|
||||
let q = (w / self.weight_quantum).floor() as i64;
|
||||
if q == 0 && w > 0.0 {
|
||||
1
|
||||
} else {
|
||||
q
|
||||
}
|
||||
}
|
||||
|
||||
/// Update the guard with this cycle's mesh: `nodes` are the contributing
|
||||
/// node ids and `coupling(i, j)` returns the fusion coupling between
|
||||
/// `nodes[i]` and `nodes[j]` (symmetric, ≥ 0).
|
||||
///
|
||||
/// Returns `None` for meshes of fewer than 2 nodes (no cut exists).
|
||||
pub fn update(
|
||||
&mut self,
|
||||
nodes: &[u8],
|
||||
coupling: impl Fn(usize, usize) -> f64,
|
||||
) -> Option<MeshPartitionReport> {
|
||||
if nodes.len() < 2 {
|
||||
// Mesh degenerated: drop state so a later rebuild starts clean.
|
||||
self.mincut = None;
|
||||
self.nodes.clear();
|
||||
self.edges.clear();
|
||||
return None;
|
||||
}
|
||||
let mut sorted: Vec<u8> = nodes.to_vec();
|
||||
sorted.sort_unstable();
|
||||
sorted.dedup();
|
||||
|
||||
// Desired quantized edge set for this cycle.
|
||||
let mut desired: BTreeMap<(u8, u8), i64> = BTreeMap::new();
|
||||
for i in 0..nodes.len() {
|
||||
for j in (i + 1)..nodes.len() {
|
||||
let (a, b) = if nodes[i] < nodes[j] {
|
||||
(nodes[i], nodes[j])
|
||||
} else {
|
||||
(nodes[j], nodes[i])
|
||||
};
|
||||
if a == b {
|
||||
continue;
|
||||
}
|
||||
let q = self.quantize(coupling(i, j));
|
||||
desired.insert((a, b), q);
|
||||
}
|
||||
}
|
||||
|
||||
// Change detection: count quantized-weight moves vs the installed set.
|
||||
let changed = if self.mincut.is_none() || self.nodes != sorted {
|
||||
usize::MAX // node set changed / first cycle: rebuild unconditionally
|
||||
} else {
|
||||
desired
|
||||
.iter()
|
||||
.filter(|(k, &q)| self.edges.get(k).copied().unwrap_or(0) != q)
|
||||
.count()
|
||||
};
|
||||
|
||||
let mut updates = 0usize;
|
||||
if changed > 0 {
|
||||
// Measured policy (criterion, 12-node mesh): a full exact rebuild
|
||||
// is ~170 µs while ONE DynamicMinCut delete+insert is ~240 µs —
|
||||
// the incremental machinery's overheads target much larger graphs.
|
||||
// At mesh scale the optimum is: change-gate aggressively (the
|
||||
// steady state below is ~7 µs and covers almost every cycle) and
|
||||
// rebuild whenever anything actually moved.
|
||||
let edges: Vec<(u64, u64, f64)> = desired
|
||||
.iter()
|
||||
.filter(|(_, &q)| q > 0)
|
||||
.map(|(&(a, b), &q)| {
|
||||
(u64::from(a), u64::from(b), q as f64 * self.weight_quantum)
|
||||
})
|
||||
.collect();
|
||||
updates = if changed == usize::MAX { edges.len() } else { changed };
|
||||
self.mincut = MinCutBuilder::new().exact().with_edges(edges).build().ok();
|
||||
self.nodes = sorted;
|
||||
self.edges = desired;
|
||||
}
|
||||
// changed == 0: steady state — zero graph work, cached cut reused.
|
||||
|
||||
// Nodes with no positive coupling never enter the cut structure (zero
|
||||
// edges are not installed) — they are already partitioned. Report them
|
||||
// as the degenerate cut before consulting the structure.
|
||||
let mut isolated: Vec<u8> = self
|
||||
.nodes
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|&v| {
|
||||
!self
|
||||
.edges
|
||||
.iter()
|
||||
.any(|(&(a, b), &q)| q > 0 && (a == v || b == v))
|
||||
})
|
||||
.collect();
|
||||
if !isolated.is_empty() {
|
||||
isolated.sort_unstable();
|
||||
return Some(MeshPartitionReport {
|
||||
cut_value: 0.0,
|
||||
at_risk: self.nodes.len() >= self.min_nodes,
|
||||
weak_side: isolated,
|
||||
updates_applied: updates,
|
||||
});
|
||||
}
|
||||
|
||||
let mc = self.mincut.as_ref()?;
|
||||
// A disconnected coupling graph is the degenerate cut: value 0.
|
||||
let cut_value = if mc.is_connected() { mc.min_cut_value() } else { 0.0 };
|
||||
let (side_a, side_b) = mc.partition();
|
||||
let weak_raw = if side_a.len() <= side_b.len() { side_a } else { side_b };
|
||||
let mut weak_side: Vec<u8> = weak_raw.into_iter().map(|v| v as u8).collect();
|
||||
weak_side.sort_unstable();
|
||||
let at_risk = self.nodes.len() >= self.min_nodes && cut_value <= self.risk_threshold;
|
||||
|
||||
Some(MeshPartitionReport { cut_value, at_risk, weak_side, updates_applied: updates })
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Triangle with one weakly-attached node: the cut isolates that node and
|
||||
/// the cut value equals its total coupling.
|
||||
#[test]
|
||||
fn weakly_attached_node_is_the_weak_side() {
|
||||
let mut g = MeshGuard::default();
|
||||
let nodes = [0u8, 1, 2];
|
||||
// 0–1 strongly coupled; node 2 hangs on by 0.05 + 0.05.
|
||||
let w = |i: usize, j: usize| match (i.min(j), i.max(j)) {
|
||||
(0, 1) => 1.0,
|
||||
_ => 0.05,
|
||||
};
|
||||
let r = g.update(&nodes, w).expect("3-node mesh");
|
||||
assert!(r.cut_value <= 0.13, "cut {} should be ~0.10", r.cut_value);
|
||||
assert_eq!(r.weak_side, vec![2]);
|
||||
assert!(r.at_risk, "weak coupling must flag partition risk");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strong_mesh_is_not_at_risk() {
|
||||
let mut g = MeshGuard::default();
|
||||
let r = g.update(&[0, 1, 2, 3], |_, _| 0.9).expect("mesh");
|
||||
assert!(r.cut_value > g.risk_threshold);
|
||||
assert!(!r.at_risk);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_node_mesh_reports_but_never_risks() {
|
||||
let mut g = MeshGuard::default();
|
||||
let r = g.update(&[0, 1], |_, _| 0.01).expect("2-node mesh");
|
||||
// Trivial cut exists but min_nodes=3 keeps the flag off.
|
||||
assert!(!r.at_risk);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fewer_than_two_nodes_yields_none() {
|
||||
let mut g = MeshGuard::default();
|
||||
assert!(g.update(&[7], |_, _| 1.0).is_none());
|
||||
assert!(g.update(&[], |_, _| 1.0).is_none());
|
||||
}
|
||||
|
||||
/// The optimization contract: identical weights on the next cycle apply
|
||||
/// zero updates; a sub-quantum wiggle also applies zero; a real change
|
||||
/// applies exactly the changed edges.
|
||||
#[test]
|
||||
fn steady_state_applies_zero_updates() {
|
||||
let mut g = MeshGuard::default();
|
||||
let nodes = [0u8, 1, 2, 3];
|
||||
let first = g.update(&nodes, |_, _| 0.5).unwrap();
|
||||
assert_eq!(first.updates_applied, 6); // cold build installs all edges
|
||||
|
||||
let second = g.update(&nodes, |_, _| 0.5).unwrap();
|
||||
assert_eq!(second.updates_applied, 0);
|
||||
|
||||
// Sub-quantum jitter (quantum is 1/64 ≈ 0.0156) is gated out.
|
||||
let third = g.update(&nodes, |_, _| 0.5 + 0.004).unwrap();
|
||||
assert_eq!(third.updates_applied, 0);
|
||||
|
||||
// One genuinely changed edge touches exactly one edge.
|
||||
let fourth = g
|
||||
.update(&nodes, |i, j| if (i.min(j), i.max(j)) == (0, 1) { 0.1 } else { 0.5 })
|
||||
.unwrap();
|
||||
assert_eq!(fourth.updates_applied, 1);
|
||||
}
|
||||
|
||||
/// Node set changes force a clean rebuild (drop/join handled correctly).
|
||||
#[test]
|
||||
fn node_join_and_drop_rebuild() {
|
||||
let mut g = MeshGuard::default();
|
||||
g.update(&[0, 1, 2], |_, _| 0.8).unwrap();
|
||||
// Node 3 joins.
|
||||
let joined = g.update(&[0, 1, 2, 3], |_, _| 0.8).unwrap();
|
||||
assert_eq!(joined.updates_applied, 6); // rebuild over 4 nodes
|
||||
// Node 0 drops.
|
||||
let dropped = g.update(&[1, 2, 3], |_, _| 0.8).unwrap();
|
||||
assert_eq!(dropped.updates_applied, 3);
|
||||
assert!(!dropped.at_risk);
|
||||
}
|
||||
|
||||
/// Determinism: same inputs, same report (cut value + weak side).
|
||||
#[test]
|
||||
fn reports_are_deterministic() {
|
||||
let run = || {
|
||||
let mut g = MeshGuard::default();
|
||||
let w = |i: usize, j: usize| match (i.min(j), i.max(j)) {
|
||||
(0, 1) => 0.9,
|
||||
(1, 2) => 0.6,
|
||||
_ => 0.07,
|
||||
};
|
||||
g.update(&[0, 1, 2], w).unwrap()
|
||||
};
|
||||
let a = run();
|
||||
let b = run();
|
||||
assert_eq!(a.cut_value.to_bits(), b.cut_value.to_bits());
|
||||
assert_eq!(a.weak_side, b.weak_side);
|
||||
}
|
||||
|
||||
/// Regression (review finding 3): a balanced mesh of ≥ 65 nodes has every
|
||||
/// pairwise coupling at ~1/n < quantum (1/64). The old floor-to-zero
|
||||
/// quantization erased all edges and reported the mesh permanently
|
||||
/// "already partitioned" (cut 0, at_risk). Nonzero sub-quantum couplings
|
||||
/// now saturate to one quantum, so the mesh reports a healthy cut.
|
||||
#[test]
|
||||
fn large_balanced_mesh_is_not_at_risk() {
|
||||
let mut g = MeshGuard::default();
|
||||
let nodes: Vec<u8> = (0..70u8).collect();
|
||||
// Attention-weight product coupling: (1/n)·(1/n)·n = 1/n ≈ 0.0143 < 1/64.
|
||||
let n = nodes.len() as f64;
|
||||
let r = g.update(&nodes, |_, _| 1.0 / n).expect("70-node mesh");
|
||||
assert!(
|
||||
r.cut_value > 0.0,
|
||||
"live couplings must not quantize to zero"
|
||||
);
|
||||
// Min cut isolates one node: 69 edges × one quantum (1/64) ≈ 1.08,
|
||||
// well above the 0.25 default risk threshold.
|
||||
assert!(r.cut_value > g.risk_threshold);
|
||||
assert!(
|
||||
!r.at_risk,
|
||||
"balanced large mesh must not be at partition risk"
|
||||
);
|
||||
assert!(r.weak_side.len() < nodes.len(), "no false full partition");
|
||||
}
|
||||
|
||||
/// Sub-quantum couplings saturate to one quantum but exact zero is still a
|
||||
/// real partition (the floor must not invent couplings).
|
||||
#[test]
|
||||
fn sub_quantum_saturates_but_zero_stays_zero() {
|
||||
let mut g = MeshGuard::default();
|
||||
// 0.001 < 1/64 everywhere: connected, tiny cut, flagged at risk
|
||||
// (cut = 2 × 1/64 ≈ 0.031 ≤ 0.25) — but NOT "already partitioned".
|
||||
let r = g.update(&[0, 1, 2], |_, _| 0.001).expect("mesh");
|
||||
assert!(r.cut_value > 0.0);
|
||||
assert!(r.at_risk);
|
||||
// Exact zero to node 2: degenerate cut 0, node 2 isolated.
|
||||
let mut g2 = MeshGuard::default();
|
||||
let r2 = g2
|
||||
.update(&[0, 1, 2], |i, j| if i == 2 || j == 2 { 0.0 } else { 0.5 })
|
||||
.expect("mesh");
|
||||
assert_eq!(r2.cut_value, 0.0);
|
||||
assert_eq!(r2.weak_side, vec![2]);
|
||||
}
|
||||
|
||||
/// A fully partitioned mesh (zero coupling to one node) reports cut 0.
|
||||
#[test]
|
||||
fn disconnected_mesh_is_cut_zero() {
|
||||
let mut g = MeshGuard::default();
|
||||
let w = |i: usize, j: usize| {
|
||||
if i == 2 || j == 2 { 0.0 } else { 0.9 }
|
||||
};
|
||||
let r = g.update(&[0, 1, 2], w).unwrap();
|
||||
assert_eq!(r.cut_value, 0.0);
|
||||
assert!(r.at_risk);
|
||||
assert_eq!(r.weak_side, vec![2]);
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,8 @@
|
||||
//! 12 4 Sequence number (LE u32)
|
||||
//! 16 1 RSSI (i8)
|
||||
//! 17 1 Noise floor (i8)
|
||||
//! 18 2 Reserved
|
||||
//! 18 1 PPDU type (ADR-110: 0=HT/legacy, 1=HE-SU, 2=HE-MU, 3=HE-TB)
|
||||
//! 19 1 Flags (ADR-110: bit0 bw40, bit2 STBC, bit3 LDPC, bit4 15.4-sync)
|
||||
//! 20 N*2 I/Q pairs (n_antennas * n_subcarriers * 2 bytes)
|
||||
//! ```
|
||||
//!
|
||||
@@ -240,12 +241,31 @@ impl Esp32CsiParser {
|
||||
}
|
||||
}
|
||||
|
||||
// Determine bandwidth from subcarrier count
|
||||
let bandwidth = match n_subcarriers {
|
||||
0..=56 => Bandwidth::Bw20,
|
||||
57..=114 => Bandwidth::Bw40,
|
||||
115..=242 => Bandwidth::Bw80,
|
||||
_ => Bandwidth::Bw160,
|
||||
// Determine bandwidth from PPDU type + subcarrier count (ADR-110).
|
||||
//
|
||||
// HE-LTF uses a 4x denser tone grid than HT-LTF on the same channel
|
||||
// width: HE20 = 256-FFT (242 active tones), HE40 = 512-FFT (484
|
||||
// active). So a 256-bin frame on an HE PPDU is *20 MHz*, not 160.
|
||||
// For HE frames the firmware also writes the bandwidth into byte 19
|
||||
// bit 0 (see Adr018Flags::bw40) — prefer that when set.
|
||||
//
|
||||
// HT/legacy keeps the count heuristic, with 64 included in the 20 MHz
|
||||
// bucket: ESP32 HT20 CSI delivers the full 64-bin FFT grid (live
|
||||
// capture evidence: 148-byte frames = 64 subcarriers on a 20 MHz
|
||||
// channel, issue #1005).
|
||||
let bandwidth = if ppdu_type.is_he() {
|
||||
if adr018_flags.bw40 || n_subcarriers > 256 {
|
||||
Bandwidth::Bw40
|
||||
} else {
|
||||
Bandwidth::Bw20
|
||||
}
|
||||
} else {
|
||||
match n_subcarriers {
|
||||
0..=64 => Bandwidth::Bw20,
|
||||
65..=128 => Bandwidth::Bw40,
|
||||
129..=242 => Bandwidth::Bw80,
|
||||
_ => Bandwidth::Bw160,
|
||||
}
|
||||
};
|
||||
|
||||
let frame = CsiFrame {
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
//! Session FSM I/O types for the 802.11bf sensing model: events in
|
||||
//! ([`SessionEvent`]), actions out ([`Action`]), close reasons, static
|
||||
//! configuration, and the state enum.
|
||||
//!
|
||||
//! Split from [`super::session`] to keep each file under the ADR-153
|
||||
//! 500-line maintainability cap; the canonical public path re-exports
|
||||
//! these from [`super::session`].
|
||||
|
||||
use super::messages::{
|
||||
CsiReportPayload, SbpRequest, SbpResponse, SbpStatus, SensingMeasurementInstance,
|
||||
SensingMeasurementReport, SensingMeasurementSetupRequest, SensingMeasurementSetupResponse,
|
||||
SensingSessionTermination, TerminationReason,
|
||||
};
|
||||
use super::types::{MeasurementInstanceId, SensingCapabilities, 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 (the
|
||||
/// measurement-driving endpoint — initiator or SBP proxy — 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`/`RelaySbpReport`
|
||||
/// 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),
|
||||
/// SBP proxy mode: forward a report received from the sensing responder
|
||||
/// to the SBP client. The transport maps this to a frame toward the
|
||||
/// client (`SensingFrame::SbpReport`), distinct from `SendReport`,
|
||||
/// which travels toward the sensing initiator.
|
||||
RelaySbpReport(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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
//! 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.
|
||||
///
|
||||
/// Mirrors [`SetupStatus`] 1:1 (see the `From<SetupStatus>` impl): an SBP
|
||||
/// request is validated through the same chain as a direct setup, so every
|
||||
/// rejection class must survive the proxy translation.
|
||||
/// `RejectedNotSupported` additionally covers a proxy that lacks the SBP
|
||||
/// capability itself.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum SbpStatus {
|
||||
Accepted,
|
||||
RejectedNotSupported,
|
||||
RejectedUnsupportedParams,
|
||||
RejectedSetupIdCollision,
|
||||
RejectedIncompatibleProfile,
|
||||
RejectedByPolicy,
|
||||
RejectedCapacity,
|
||||
}
|
||||
|
||||
impl From<SetupStatus> for SbpStatus {
|
||||
/// 1:1 mapping from the direct-setup status space, keeping the SBP path
|
||||
/// on the single `evaluate_setup` validation chain (no SBP-only policy
|
||||
/// drift or bypass).
|
||||
fn from(status: SetupStatus) -> Self {
|
||||
match status {
|
||||
SetupStatus::Accepted => SbpStatus::Accepted,
|
||||
SetupStatus::RejectedNotSupported => SbpStatus::RejectedNotSupported,
|
||||
SetupStatus::RejectedUnsupportedParams => SbpStatus::RejectedUnsupportedParams,
|
||||
SetupStatus::RejectedSetupIdCollision => SbpStatus::RejectedSetupIdCollision,
|
||||
SetupStatus::RejectedIncompatibleProfile => SbpStatus::RejectedIncompatibleProfile,
|
||||
SetupStatus::RejectedByPolicy => SbpStatus::RejectedByPolicy,
|
||||
SetupStatus::RejectedCapacity => SbpStatus::RejectedCapacity,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,78 @@
|
||||
//! 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, timeout handling, single-role enforcement, and the
|
||||
//! first-class SBP proxy mode. No async, no clocks.
|
||||
//! - [`events`] — the FSM I/O types ([`events::SessionEvent`],
|
||||
//! [`events::Action`], close reasons, configuration), re-exported via
|
||||
//! [`session`].
|
||||
//! - [`table`] — responder-side setup registry (setup-ID collision and
|
||||
//! capacity rejection paths, for direct setups and SBP alike).
|
||||
//! - [`transport`] — the [`transport::SensingTransport`] seam, the
|
||||
//! [`transport::SimTransport`] test double, and the ESP32 bridge.
|
||||
|
||||
pub mod events;
|
||||
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 tests_sbp;
|
||||
#[cfg(test)]
|
||||
mod testutil;
|
||||
@@ -0,0 +1,499 @@
|
||||
//! 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).
|
||||
//!
|
||||
//! **Single-role design:** a session is constructed as initiator or responder
|
||||
//! and keeps that role for its whole lifetime. An initiator-role session
|
||||
//! receiving a peer's setup or SBP request answers `RejectedNotSupported`
|
||||
//! instead of accepting — a peer must never be able to hijack a session out
|
||||
//! of its configured role. Endpoints that play both roles run one session per
|
||||
//! role (or a [`super::table::SessionTable`] for the responder side).
|
||||
//!
|
||||
//! **SBP proxy mode:** a responder session that accepts an SBP request
|
||||
//! becomes a first-class proxy ([`SensingSession::is_sbp_proxy`]): it drives
|
||||
//! the standard initiator path toward the actual sensing responder —
|
||||
//! including re-triggering measurement instances on
|
||||
//! [`SessionEvent::InstanceElapsed`] — and relays every received report to
|
||||
//! the SBP client via [`Action::RelaySbpReport`], in addition to local
|
||||
//! [`Action::DeliverReport`] delivery.
|
||||
//!
|
||||
//! Local `Start*` commands issued outside Idle are caller bugs and surface
|
||||
//! as typed [`BfError::InvalidStateForCommand`]; genuinely ignorable stray
|
||||
//! frames/ticks remain silent no-ops. The FSM I/O types live in
|
||||
//! [`super::events`] and are re-exported here.
|
||||
|
||||
use super::messages::{
|
||||
SbpRequest, SbpResponse, SbpStatus, SensingMeasurementInstance, SensingMeasurementReport,
|
||||
SensingMeasurementSetupRequest, SensingMeasurementSetupResponse, SensingSessionTermination,
|
||||
TerminationReason,
|
||||
};
|
||||
use super::types::{
|
||||
BfError, MeasurementInstanceId, MeasurementSetupId, MeasurementSetupParams, ReportingConfig,
|
||||
SensingRole, SetupStatus,
|
||||
};
|
||||
|
||||
pub use super::events::{Action, CloseReason, SessionConfig, SessionEvent, SessionState};
|
||||
|
||||
/// 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,
|
||||
/// True when this responder-role session proxies sensing for an SBP
|
||||
/// client: it drives the initiator path toward the sensing responder
|
||||
/// and relays received reports back to the client.
|
||||
sbp_proxy: 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,
|
||||
sbp_proxy: 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
|
||||
}
|
||||
|
||||
/// True when this session is acting as an SBP proxy (accepted via
|
||||
/// [`SessionEvent::SbpRequestReceived`]); cleared on reset to Idle.
|
||||
pub fn is_sbp_proxy(&self) -> bool {
|
||||
self.sbp_proxy
|
||||
}
|
||||
|
||||
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,
|
||||
/// out-of-state local commands, 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,
|
||||
})
|
||||
};
|
||||
// Single-role design (module docs): an initiator-role
|
||||
// session never accepts a peer's setup request — accepting
|
||||
// here would let a peer hijack the session into the
|
||||
// responder path.
|
||||
if self.role != SensingRole::Responder {
|
||||
return Ok(vec![response(SetupStatus::RejectedNotSupported)]);
|
||||
}
|
||||
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) => {
|
||||
// Single-role design: only responder-role sessions proxy.
|
||||
if self.role != SensingRole::Responder {
|
||||
return Ok(vec![Action::SendSbpResponse(SbpResponse {
|
||||
proxy_setup_id: sbp.proxy_setup_id,
|
||||
status: SbpStatus::RejectedNotSupported,
|
||||
})]);
|
||||
}
|
||||
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.
|
||||
///
|
||||
/// Validation is the single [`Self::evaluate_setup`] chain: the proxied
|
||||
/// setup request is built first and evaluated exactly as a direct setup
|
||||
/// would be, with the resulting [`SetupStatus`] mapped 1:1 onto
|
||||
/// [`SbpStatus`] — no SBP-only re-implementation that could drift from
|
||||
/// (or bypass) the setup policy.
|
||||
fn handle_sbp_request(&mut self, sbp: SbpRequest) -> Vec<Action> {
|
||||
let respond = |status| {
|
||||
Action::SendSbpResponse(SbpResponse {
|
||||
proxy_setup_id: sbp.proxy_setup_id,
|
||||
status,
|
||||
})
|
||||
};
|
||||
// SBP-specific capability gate; everything else is the setup chain.
|
||||
if !self.config.capabilities.sensing_by_proxy {
|
||||
return vec![respond(SbpStatus::RejectedNotSupported)];
|
||||
}
|
||||
let req = SensingMeasurementSetupRequest {
|
||||
profile: sbp.profile.clone(),
|
||||
setup_id: sbp.proxy_setup_id,
|
||||
params: sbp.params.clone(),
|
||||
};
|
||||
match self.evaluate_setup(&req) {
|
||||
SetupStatus::Accepted => {}
|
||||
status => return vec![respond(SbpStatus::from(status))],
|
||||
}
|
||||
self.setup = Some((req.setup_id, req.params.clone()));
|
||||
self.pending_request = Some(req.clone());
|
||||
self.sbp_proxy = true;
|
||||
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,
|
||||
))])
|
||||
}
|
||||
// Local Start* outside Idle is a caller bug — typed error.
|
||||
SessionEvent::StartSetup(_) | SessionEvent::StartSbp(_) => {
|
||||
Err(BfError::InvalidStateForCommand {
|
||||
state: "SetupNegotiating",
|
||||
})
|
||||
}
|
||||
// Genuinely ignorable stray frames/ticks are no-ops.
|
||||
_ => Ok(vec![]),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_active(&mut self, event: SessionEvent) -> Result<Vec<Action>, BfError> {
|
||||
match event {
|
||||
SessionEvent::InstanceElapsed => {
|
||||
// The measurement-driving endpoint re-triggers here: the
|
||||
// initiator, or an SBP proxy running the initiator path
|
||||
// toward the sensing responder. SBP *clients* only consume
|
||||
// proxied reports and never trigger instances.
|
||||
let drives_instances =
|
||||
(self.role == SensingRole::Initiator || self.sbp_proxy) && !self.sbp_client;
|
||||
if drives_instances {
|
||||
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![]),
|
||||
};
|
||||
// A successful capture means this instance was not missed —
|
||||
// the missed-instance budget counts *consecutive* misses,
|
||||
// so it resets here even when threshold-based reporting
|
||||
// suppresses the report below.
|
||||
self.missed_instances = 0;
|
||||
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;
|
||||
if self.sbp_proxy {
|
||||
// Proxy mode: deliver to the local consumer *and* relay
|
||||
// toward the SBP client on the transport.
|
||||
Ok(vec![
|
||||
Action::DeliverReport(report.clone()),
|
||||
Action::RelaySbpReport(report),
|
||||
])
|
||||
} else {
|
||||
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,
|
||||
))])
|
||||
}
|
||||
// Local Start* outside Idle is a caller bug — typed error.
|
||||
SessionEvent::StartSetup(_) | SessionEvent::StartSbp(_) => {
|
||||
Err(BfError::InvalidStateForCommand { state: "Active" })
|
||||
}
|
||||
// Genuinely ignorable stray frames (duplicate setup/SBP traffic)
|
||||
// are no-ops.
|
||||
_ => 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)])
|
||||
}
|
||||
// Local Start* outside Idle is a caller bug — typed error.
|
||||
SessionEvent::StartSetup(_) | SessionEvent::StartSbp(_) => {
|
||||
Err(BfError::InvalidStateForCommand {
|
||||
state: "Terminating",
|
||||
})
|
||||
}
|
||||
_ => 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.sbp_proxy = false;
|
||||
self.setup_timeouts = 0;
|
||||
self.missed_instances = 0;
|
||||
self.instance_counter = 0;
|
||||
self.last_reported_mean = None;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
//! 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).
|
||||
//! Both entry points — direct setups ([`SessionTable::handle_setup_request`])
|
||||
//! and sensing-by-proxy ([`SessionTable::handle_sbp_request`]) — share the
|
||||
//! same guards and the same per-setup session storage.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use super::messages::{
|
||||
SbpRequest, SbpResponse, SbpStatus, 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>,
|
||||
/// Events dropped because no session owned the setup ID (see
|
||||
/// [`Self::handle_for`]).
|
||||
unknown_setup_drops: u64,
|
||||
}
|
||||
|
||||
impl SessionTable {
|
||||
pub fn new(config: SessionConfig) -> Self {
|
||||
Self {
|
||||
config,
|
||||
sessions: BTreeMap::new(),
|
||||
unknown_setup_drops: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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())
|
||||
}
|
||||
|
||||
/// Count of events dropped by [`Self::handle_for`] because the setup ID
|
||||
/// was unknown — lets an AP spot peers addressing setups it never
|
||||
/// accepted without turning stray frames into errors.
|
||||
pub fn unknown_setup_drops(&self) -> u64 {
|
||||
self.unknown_setup_drops
|
||||
}
|
||||
|
||||
/// 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 self.is_collision(req.setup_id) {
|
||||
return reject(req.setup_id, SetupStatus::RejectedSetupIdCollision);
|
||||
}
|
||||
if self.at_capacity() {
|
||||
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 an inbound SBP request, rejecting proxy-setup-ID collisions and
|
||||
/// capacity overruns before delegating to a (new) proxy session — the
|
||||
/// SBP mirror of [`Self::handle_setup_request`], so a table-driven AP
|
||||
/// accepts SBP end-to-end instead of silently dropping it.
|
||||
pub fn handle_sbp_request(&mut self, sbp: SbpRequest) -> Result<Vec<Action>, BfError> {
|
||||
let reject = |proxy_setup_id, status| {
|
||||
Ok(vec![Action::SendSbpResponse(SbpResponse {
|
||||
proxy_setup_id,
|
||||
status,
|
||||
})])
|
||||
};
|
||||
if self.is_collision(sbp.proxy_setup_id) {
|
||||
return reject(sbp.proxy_setup_id, SbpStatus::RejectedSetupIdCollision);
|
||||
}
|
||||
if self.at_capacity() {
|
||||
return reject(sbp.proxy_setup_id, SbpStatus::RejectedCapacity);
|
||||
}
|
||||
let key = sbp.proxy_setup_id.value();
|
||||
let mut session = SensingSession::new_responder(self.config.clone());
|
||||
let actions = session.handle(SessionEvent::SbpRequestReceived(sbp))?;
|
||||
self.sessions.insert(key, session);
|
||||
Ok(actions)
|
||||
}
|
||||
|
||||
/// Route any other event to the session owning `setup_id`.
|
||||
///
|
||||
/// Frames addressing an unknown setup are dropped *by design* (stray
|
||||
/// frames are ignored, not errors), but the drop is observable through
|
||||
/// [`Self::unknown_setup_drops`].
|
||||
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 => {
|
||||
self.unknown_setup_drops = self.unknown_setup_drops.saturating_add(1);
|
||||
Ok(vec![])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A non-Idle session already owns this setup ID.
|
||||
fn is_collision(&self, setup_id: MeasurementSetupId) -> bool {
|
||||
self.sessions
|
||||
.get(&setup_id.value())
|
||||
.is_some_and(|existing| existing.state() != SessionState::Idle)
|
||||
}
|
||||
|
||||
/// The active-setup budget is exhausted.
|
||||
fn at_capacity(&self) -> bool {
|
||||
self.active_setups() >= self.config.capabilities.max_active_setups as usize
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
//! 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());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serde_rejects_out_of_range_threshold_params() {
|
||||
assert!(serde_json::from_str::<ThresholdParams>(r#"{"delta_percent":255}"#).is_err());
|
||||
let ok = serde_json::from_str::<ThresholdParams>(r#"{"delta_percent":100}"#).unwrap();
|
||||
assert_eq!(ok.delta_percent(), 100);
|
||||
}
|
||||
|
||||
// ---------- 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,489 @@
|
||||
//! ADR-153 acceptance tests — session FSM full cycle, rejection paths,
|
||||
//! timeout handling, threshold-based reporting, single-role enforcement,
|
||||
//! and adversarial no-panic coverage. SBP flows live in [`super::tests_sbp`];
|
||||
//! type/serde/transport/bridge tests 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());
|
||||
}
|
||||
|
||||
// ---------- consecutive missed-instance semantics ----------
|
||||
|
||||
#[test]
|
||||
fn missed_instance_budget_is_consecutive_not_cumulative() {
|
||||
// Review finding 2: a successful measurement must reset the
|
||||
// missed-instance counter — `max_missed_instances` bounds *consecutive*
|
||||
// misses (as documented on SessionConfig), not cumulative ones.
|
||||
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);
|
||||
let capture = || SessionEvent::MeasurementCaptured {
|
||||
instance_id: MeasurementInstanceId::new(0),
|
||||
payload: payload(10.0),
|
||||
};
|
||||
|
||||
// Miss 4, then succeed once...
|
||||
for _ in 0..4 {
|
||||
assert!(responder.handle(SessionEvent::Timeout).unwrap().is_empty());
|
||||
}
|
||||
let actions = responder.handle(capture()).unwrap();
|
||||
assert!(matches!(actions[..], [Action::SendReport(_)]));
|
||||
|
||||
// ...so 4 more misses still leave the session alive.
|
||||
for _ in 0..4 {
|
||||
assert!(responder.handle(SessionEvent::Timeout).unwrap().is_empty());
|
||||
assert_eq!(responder.state(), SessionState::Active);
|
||||
}
|
||||
// The 5th consecutive miss terminates.
|
||||
let actions = responder.handle(SessionEvent::Timeout).unwrap();
|
||||
assert!(matches!(
|
||||
actions[..],
|
||||
[Action::SendTermination(SensingSessionTermination {
|
||||
reason: TerminationReason::Timeout,
|
||||
..
|
||||
})]
|
||||
));
|
||||
assert_eq!(responder.state(), SessionState::Terminating);
|
||||
}
|
||||
|
||||
// ---------- single-role enforcement & out-of-state commands ----------
|
||||
|
||||
#[test]
|
||||
fn initiator_role_session_rejects_inbound_setup_and_sbp_requests() {
|
||||
// Review finding 4a: single-role design — a peer must not be able to
|
||||
// hijack an initiator-role session into the responder path.
|
||||
let mut initiator = SensingSession::new_initiator(SessionConfig::default());
|
||||
let actions = initiator
|
||||
.handle(SessionEvent::SetupRequestReceived(setup_request(3)))
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
actions[..],
|
||||
[Action::SendSetupResponse(SensingMeasurementSetupResponse {
|
||||
status: SetupStatus::RejectedNotSupported,
|
||||
..
|
||||
})]
|
||||
));
|
||||
assert_eq!(initiator.state(), SessionState::Idle);
|
||||
|
||||
let sbp = SbpRequest {
|
||||
profile: SpecProfile::Ieee80211Bf2025,
|
||||
proxy_setup_id: MeasurementSetupId::new(4).unwrap(),
|
||||
params: params(),
|
||||
};
|
||||
let actions = initiator
|
||||
.handle(SessionEvent::SbpRequestReceived(sbp))
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
actions[..],
|
||||
[Action::SendSbpResponse(SbpResponse {
|
||||
status: SbpStatus::RejectedNotSupported,
|
||||
..
|
||||
})]
|
||||
));
|
||||
assert_eq!(initiator.state(), SessionState::Idle);
|
||||
assert!(!initiator.is_sbp_proxy());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_start_commands_error_outside_idle() {
|
||||
// Review finding 4b: StartSetup/StartSbp outside Idle are caller bugs
|
||||
// and must surface as typed errors, not silent no-ops.
|
||||
let sbp = SbpRequest {
|
||||
profile: SpecProfile::Ieee80211Bf2025,
|
||||
proxy_setup_id: MeasurementSetupId::new(13).unwrap(),
|
||||
params: params(),
|
||||
};
|
||||
let start_err = |s: &mut SensingSession, expected: SessionState| {
|
||||
assert!(matches!(
|
||||
s.handle(SessionEvent::StartSetup(setup_request(8))),
|
||||
Err(BfError::InvalidStateForCommand { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
s.handle(SessionEvent::StartSbp(sbp.clone())),
|
||||
Err(BfError::InvalidStateForCommand { .. })
|
||||
));
|
||||
// The rejected commands must not disturb the session.
|
||||
assert_eq!(s.state(), expected);
|
||||
};
|
||||
|
||||
let mut s = SensingSession::new_initiator(SessionConfig::default());
|
||||
s.handle(SessionEvent::StartSetup(setup_request(7)))
|
||||
.unwrap();
|
||||
start_err(&mut s, SessionState::SetupNegotiating);
|
||||
|
||||
s.handle(SessionEvent::SetupResponseReceived(
|
||||
SensingMeasurementSetupResponse {
|
||||
setup_id: MeasurementSetupId::new(7).unwrap(),
|
||||
status: SetupStatus::Accepted,
|
||||
},
|
||||
))
|
||||
.unwrap();
|
||||
start_err(&mut s, SessionState::Active);
|
||||
// Genuinely ignorable stray frames remain no-ops in Active.
|
||||
assert!(s
|
||||
.handle(SessionEvent::SbpResponseReceived(SbpResponse {
|
||||
proxy_setup_id: MeasurementSetupId::new(7).unwrap(),
|
||||
status: SbpStatus::Accepted,
|
||||
}))
|
||||
.unwrap()
|
||||
.is_empty());
|
||||
|
||||
s.handle(SessionEvent::Terminate(
|
||||
TerminationReason::InitiatorRequested,
|
||||
))
|
||||
.unwrap();
|
||||
start_err(&mut s, SessionState::Terminating);
|
||||
}
|
||||
|
||||
// ---------- 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,338 @@
|
||||
//! ADR-153 sensing-by-proxy (SBP) acceptance tests — proxy lifecycle
|
||||
//! (re-triggering + report relay), client flow, table-driven AP entry
|
||||
//! point, and the single-validation-path status mapping. Other FSM tests
|
||||
//! live in [`super::tests_fsm`]; type/serde/transport/bridge tests 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::{params, payload};
|
||||
use super::transport::{action_to_frame, frame_to_event, SensingFrame};
|
||||
use super::types::*;
|
||||
use crate::csi_frame::Bandwidth;
|
||||
|
||||
fn sbp_request(id: u8) -> SbpRequest {
|
||||
SbpRequest {
|
||||
profile: SpecProfile::Ieee80211Bf2025,
|
||||
proxy_setup_id: MeasurementSetupId::new(id).unwrap(),
|
||||
params: params(),
|
||||
}
|
||||
}
|
||||
|
||||
#[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 actions = proxy
|
||||
.handle(SessionEvent::SbpRequestReceived(sbp_request(11)))
|
||||
.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 = sbp_request(12);
|
||||
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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sbp_proxy_full_lifecycle_retriggers_and_relays() {
|
||||
// Review finding 1: the SBP proxy is a first-class mode — after the
|
||||
// proxied setup is accepted it keeps driving measurement instances on
|
||||
// InstanceElapsed (like an initiator) and relays every received report
|
||||
// to the SBP client in addition to local delivery.
|
||||
let mut proxy = SensingSession::new_responder(SessionConfig::default());
|
||||
|
||||
// Accept: SBP response to the client + proxied setup to the responder.
|
||||
let actions = proxy
|
||||
.handle(SessionEvent::SbpRequestReceived(sbp_request(21)))
|
||||
.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!(proxy.is_sbp_proxy());
|
||||
|
||||
// Responder accepts → proxy Active, instance 0 triggered.
|
||||
let actions = proxy
|
||||
.handle(SessionEvent::SetupResponseReceived(
|
||||
SensingMeasurementSetupResponse {
|
||||
setup_id: forwarded.setup_id,
|
||||
status: SetupStatus::Accepted,
|
||||
},
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(proxy.state(), SessionState::Active);
|
||||
match &actions[..] {
|
||||
[Action::TriggerInstance(i)] => assert_eq!(i.instance_id.value(), 0),
|
||||
other => panic!("expected instance 0 trigger, got {other:?}"),
|
||||
}
|
||||
|
||||
// InstanceElapsed re-triggers instance 1+ (proxy drives the schedule).
|
||||
let actions = proxy.handle(SessionEvent::InstanceElapsed).unwrap();
|
||||
match &actions[..] {
|
||||
[Action::TriggerInstance(i)] => assert_eq!(i.instance_id.value(), 1),
|
||||
other => panic!("expected instance 1 trigger, got {other:?}"),
|
||||
}
|
||||
|
||||
// A report from the sensing responder is delivered locally AND relayed.
|
||||
let report = SensingMeasurementReport {
|
||||
setup_id: forwarded.setup_id,
|
||||
instance_id: MeasurementInstanceId::new(1),
|
||||
payload: payload(5.0),
|
||||
};
|
||||
let actions = proxy
|
||||
.handle(SessionEvent::ReportReceived(report.clone()))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
actions,
|
||||
vec![
|
||||
Action::DeliverReport(report.clone()),
|
||||
Action::RelaySbpReport(report.clone()),
|
||||
]
|
||||
);
|
||||
// The relay action maps to a frame toward the SBP client, which
|
||||
// consumes it through the standard report path.
|
||||
let frame = action_to_frame(&Action::RelaySbpReport(report.clone())).unwrap();
|
||||
assert_eq!(frame, SensingFrame::SbpReport(report.clone()));
|
||||
assert_eq!(
|
||||
frame_to_event(frame),
|
||||
Some(SessionEvent::ReportReceived(report))
|
||||
);
|
||||
|
||||
// Terminate cleanly: notify the responder, quiesce back to Idle.
|
||||
let actions = proxy
|
||||
.handle(SessionEvent::Terminate(
|
||||
TerminationReason::InitiatorRequested,
|
||||
))
|
||||
.unwrap();
|
||||
assert!(matches!(actions[..], [Action::SendTermination(_)]));
|
||||
assert_eq!(proxy.state(), SessionState::Terminating);
|
||||
let actions = proxy.handle(SessionEvent::Timeout).unwrap();
|
||||
assert!(matches!(
|
||||
actions[..],
|
||||
[Action::SessionClosed(CloseReason::Completed)]
|
||||
));
|
||||
assert_eq!(proxy.state(), SessionState::Idle);
|
||||
assert!(!proxy.is_sbp_proxy());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_table_routes_sbp_end_to_end() {
|
||||
// Review finding 3: the table has a first-class SBP entry point with
|
||||
// the same collision/capacity guards as direct setups — a table-driven
|
||||
// AP accepts SBP instead of silently dropping it.
|
||||
let mut table = SessionTable::new(SessionConfig::default());
|
||||
let actions = table.handle_sbp_request(sbp_request(31)).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:?}"),
|
||||
};
|
||||
let setup_id = forwarded.setup_id;
|
||||
assert_eq!(table.active_setups(), 1);
|
||||
assert!(table.session(setup_id).unwrap().is_sbp_proxy());
|
||||
|
||||
// Proxy-setup-ID collision while the first proxy is live.
|
||||
let actions = table.handle_sbp_request(sbp_request(31)).unwrap();
|
||||
assert!(matches!(
|
||||
actions[..],
|
||||
[Action::SendSbpResponse(SbpResponse {
|
||||
status: SbpStatus::RejectedSetupIdCollision,
|
||||
..
|
||||
})]
|
||||
));
|
||||
|
||||
// Drive the proxied negotiation to Active through the table.
|
||||
let actions = table
|
||||
.handle_for(
|
||||
setup_id,
|
||||
SessionEvent::SetupResponseReceived(SensingMeasurementSetupResponse {
|
||||
setup_id,
|
||||
status: SetupStatus::Accepted,
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(matches!(actions[..], [Action::TriggerInstance(_)]));
|
||||
assert_eq!(
|
||||
table.session(setup_id).unwrap().state(),
|
||||
SessionState::Active
|
||||
);
|
||||
|
||||
// Reports relay to the SBP client through the table-owned proxy.
|
||||
let report = SensingMeasurementReport {
|
||||
setup_id,
|
||||
instance_id: MeasurementInstanceId::new(0),
|
||||
payload: payload(2.0),
|
||||
};
|
||||
let actions = table
|
||||
.handle_for(setup_id, SessionEvent::ReportReceived(report.clone()))
|
||||
.unwrap();
|
||||
assert!(actions.contains(&Action::RelaySbpReport(report)));
|
||||
|
||||
// Capacity guard mirrors the direct-setup path.
|
||||
let mut cfg = SessionConfig::default();
|
||||
cfg.capabilities.max_active_setups = 1;
|
||||
let mut small = SessionTable::new(cfg);
|
||||
small.handle_sbp_request(sbp_request(1)).unwrap();
|
||||
let actions = small.handle_sbp_request(sbp_request(2)).unwrap();
|
||||
assert!(matches!(
|
||||
actions[..],
|
||||
[Action::SendSbpResponse(SbpResponse {
|
||||
status: SbpStatus::RejectedCapacity,
|
||||
..
|
||||
})]
|
||||
));
|
||||
|
||||
// Unknown-setup drops are observable, not silent (finding 3).
|
||||
assert_eq!(table.unknown_setup_drops(), 0);
|
||||
let actions = table
|
||||
.handle_for(MeasurementSetupId::new(99).unwrap(), SessionEvent::Timeout)
|
||||
.unwrap();
|
||||
assert!(actions.is_empty());
|
||||
assert_eq!(table.unknown_setup_drops(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sbp_validation_shares_setup_chain_with_one_to_one_status_mapping() {
|
||||
// Review finding 5: SBP requests are validated by building the proxied
|
||||
// setup request first and running it through the single evaluate_setup
|
||||
// chain — statuses map 1:1, so no rejection class is folded away and no
|
||||
// setup policy can be bypassed via SBP.
|
||||
|
||||
// Incompatible profile now surfaces as its own status (the old
|
||||
// duplicated SBP chain folded it into RejectedUnsupportedParams).
|
||||
let mut cfg = SessionConfig::default();
|
||||
cfg.profile = SpecProfile::VendorExtension("acme".into());
|
||||
let mut proxy = SensingSession::new_responder(cfg);
|
||||
let actions = proxy
|
||||
.handle(SessionEvent::SbpRequestReceived(sbp_request(41)))
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
actions[..],
|
||||
[Action::SendSbpResponse(SbpResponse {
|
||||
status: SbpStatus::RejectedIncompatibleProfile,
|
||||
..
|
||||
})]
|
||||
));
|
||||
|
||||
// Consent policy rejection passes through unchanged.
|
||||
let mut proxy = SensingSession::new_responder(SessionConfig::default());
|
||||
let mut sbp = sbp_request(42);
|
||||
sbp.params.consent = ConsentMode::Disabled;
|
||||
let actions = proxy.handle(SessionEvent::SbpRequestReceived(sbp)).unwrap();
|
||||
assert!(matches!(
|
||||
actions[..],
|
||||
[Action::SendSbpResponse(SbpResponse {
|
||||
status: SbpStatus::RejectedByPolicy,
|
||||
..
|
||||
})]
|
||||
));
|
||||
|
||||
// Capability rejection (bandwidth beyond the advertised maximum).
|
||||
let mut cfg = SessionConfig::default();
|
||||
cfg.capabilities.max_bandwidth_mhz = 40;
|
||||
let mut proxy = SensingSession::new_responder(cfg);
|
||||
let mut sbp = sbp_request(43);
|
||||
sbp.params.bandwidth = Bandwidth::Bw80;
|
||||
let actions = proxy.handle(SessionEvent::SbpRequestReceived(sbp)).unwrap();
|
||||
assert!(matches!(
|
||||
actions[..],
|
||||
[Action::SendSbpResponse(SbpResponse {
|
||||
status: SbpStatus::RejectedUnsupportedParams,
|
||||
..
|
||||
})]
|
||||
));
|
||||
|
||||
// The status translation itself is exhaustive and 1:1.
|
||||
let pairs = [
|
||||
(SetupStatus::Accepted, SbpStatus::Accepted),
|
||||
(
|
||||
SetupStatus::RejectedNotSupported,
|
||||
SbpStatus::RejectedNotSupported,
|
||||
),
|
||||
(
|
||||
SetupStatus::RejectedUnsupportedParams,
|
||||
SbpStatus::RejectedUnsupportedParams,
|
||||
),
|
||||
(
|
||||
SetupStatus::RejectedSetupIdCollision,
|
||||
SbpStatus::RejectedSetupIdCollision,
|
||||
),
|
||||
(
|
||||
SetupStatus::RejectedIncompatibleProfile,
|
||||
SbpStatus::RejectedIncompatibleProfile,
|
||||
),
|
||||
(SetupStatus::RejectedByPolicy, SbpStatus::RejectedByPolicy),
|
||||
(SetupStatus::RejectedCapacity, SbpStatus::RejectedCapacity),
|
||||
];
|
||||
for (setup, sbp) in pairs {
|
||||
assert_eq!(SbpStatus::from(setup), sbp);
|
||||
}
|
||||
}
|
||||
@@ -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,318 @@
|
||||
//! 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),
|
||||
/// Proxied measurement report forwarded by an SBP proxy toward its SBP
|
||||
/// client ([`Action::RelaySbpReport`]) — distinct from [`Self::Report`],
|
||||
/// which travels toward the sensing initiator.
|
||||
SbpReport(SensingMeasurementReport),
|
||||
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::RelaySbpReport(report) => Some(SensingFrame::SbpReport(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)),
|
||||
// The SBP client consumes proxied reports through the standard
|
||||
// report path (its session is in sbp_client mode).
|
||||
SensingFrame::SbpReport(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,398 @@
|
||||
//! 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).
|
||||
///
|
||||
/// Deserialization validates through [`ThresholdParams::new`] so the
|
||||
/// `delta_percent <= 100` invariant holds on every construction path,
|
||||
/// including untrusted wire/persisted payloads (same convention as
|
||||
/// [`MeasurementSetupId`]).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(try_from = "RawThresholdParams")]
|
||||
pub struct ThresholdParams {
|
||||
delta_percent: u8,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RawThresholdParams {
|
||||
delta_percent: u8,
|
||||
}
|
||||
|
||||
impl TryFrom<RawThresholdParams> for ThresholdParams {
|
||||
type Error = BfError;
|
||||
|
||||
fn try_from(raw: RawThresholdParams) -> Result<Self, Self::Error> {
|
||||
Self::new(raw.delta_percent)
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
/// The receiving endpoint does not act as a sensing responder for this
|
||||
/// request — e.g. an initiator-role session received a setup request
|
||||
/// (single-role design, see [`crate::ieee80211bf::session`]).
|
||||
RejectedNotSupported,
|
||||
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
|
||||
@@ -49,7 +55,9 @@ pub mod sync_packet;
|
||||
pub mod radio_ops;
|
||||
|
||||
pub use bridge::CsiData;
|
||||
pub use csi_frame::{AntennaConfig, Bandwidth, CsiFrame, CsiMetadata, SubcarrierData};
|
||||
pub use csi_frame::{
|
||||
Adr018Flags, AntennaConfig, Bandwidth, CsiFrame, CsiMetadata, PpduType, SubcarrierData,
|
||||
};
|
||||
pub use error::ParseError;
|
||||
pub use esp32_parser::{
|
||||
ruview_sibling_packet_name, Esp32CsiParser, ESP32_CSI_MAGIC, RUVIEW_COMPRESSED_CSI_MAGIC,
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
//! ADR-110 / issue #1005: real ESP32-C6 HE-LTF CSI frames captured live.
|
||||
//!
|
||||
//! Both fixtures below are verbatim UDP payloads captured on 2026-06-11 from
|
||||
//! an ESP32-C6 (node_id 12, IDF v5.5 build) streaming to UDP :5005 — the
|
||||
//! same node, same link, seconds apart. The 532-byte frame is an HE-SU
|
||||
//! capture (256 subcarrier bins = 242 active HE20 tones); the 148-byte frame
|
||||
//! is the HT fallback grid (64 bins) the same firmware emits for non-HE
|
||||
//! traffic. They are the canonical regression fixtures for the non-fixed
|
||||
//! subcarrier count introduced by HE-LTF.
|
||||
|
||||
use wifi_densepose_hardware::{Bandwidth, Esp32CsiParser, PpduType};
|
||||
|
||||
/// 532-byte HE-SU frame: header + 256 subcarrier I/Q pairs.
|
||||
/// magic=0xC5110001 node=12 ant=1 nsub=256 freq=2432 seq=11610
|
||||
/// rssi=-40 noise=-87 byte18=0x01 (HE-SU) byte19=0x10 (15.4-sync valid)
|
||||
const HE_FRAME_HEX: &str = "010011c50c010001800900005a2d0000d8a9011000000000000000000000f70ef70ef50cf30bf209f108f006ef03ee02ee00eefdeffbeff8f0f7f1f4f2f3f4f1f5f0f7eef8edfaecfdecffeb01ea03ea05e908ea0aeb0deb0fec11ee13f015f216f318f519f71afa1bfd1bff1c021c051b071b0a1a0c190f1811161315161218101a0e1b0c1c091d071e041f0120ff20fc20f91ff71ff41ef11def1cec1be919e717e615e413e311e10edf0cde09dd06dc04dc01dcffdcfbdcf9ddf6def3dff0e0ede2eae4e8e6e6e8e4eae2ebe0eedef1dcf4dbf7dafad9fdd900d903d806d909d90cda0fdc12dc14dd17df1ae11ce31ee520e722e924ed25f127f328f629f929fd2900290329062809270c260e26122516061a00001c201c1f1a211722142411250e260c27082804280129fe29fb28f927f627f426f125ef23ec22ea20e81eea20e81e891b53a82951565d4ffafbfebe9abddb10222aa47b3b371fd2c0860cd4d86ea2f35faccd46b0b66f6ff0050f2da27d1c92f7f8e1017cb545afd3e3fe60db6f478dc85a33b3454cf6df9061194a0a0fc3e0eedf76f1d292cb25c8f541dfcc4109f9f1a34955520ad8ffa3694ac395cbf6c19073a4aefb1ebf47c76730458431805d9f18ff2e81955e8752b29757f66e289f72f8e35309a737547c040444cbda1a81d221d950037ec38fd9d1dd0f56c3dc707a7bbfe66ca5a97ab7cc17d68d38ba43a1806f91f5911a5967e2c9f7f07186";
|
||||
|
||||
/// 148-byte HT frame from the same node: header + 64 subcarrier I/Q pairs.
|
||||
/// magic=0xC5110001 node=12 ant=1 nsub=64 freq=2432 seq=11622
|
||||
/// rssi=-79 noise=-87 byte18=0x00 (HT/legacy) byte19=0x10
|
||||
const HT_FRAME_HEX: &str = "010011c50c01400080090000662d0000b1a900100000000000000000fcfaf909f013f112f213f212f311f410f511f510f610f510f411f410f411f312f213f214f214f212f313f513f512f611f610f80ef90df90c0000010eff11fe13ff11fe1300000000ff01000001010002000200020204000301040103000400040002ff03ff03fe02fe02fe01fd00edfc03fa000000000000";
|
||||
|
||||
fn unhex(s: &str) -> Vec<u8> {
|
||||
(0..s.len())
|
||||
.step_by(2)
|
||||
.map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_he_su_frame_532_bytes_parses_with_256_subcarriers() {
|
||||
let data = unhex(HE_FRAME_HEX);
|
||||
assert_eq!(data.len(), 532);
|
||||
|
||||
let (frame, consumed) = Esp32CsiParser::parse_frame(&data).expect("HE frame must parse");
|
||||
assert_eq!(consumed, 532);
|
||||
assert_eq!(frame.metadata.node_id, 12);
|
||||
assert_eq!(frame.metadata.n_antennas, 1);
|
||||
assert_eq!(frame.metadata.n_subcarriers, 256);
|
||||
assert_eq!(frame.subcarrier_count(), 256);
|
||||
assert_eq!(frame.metadata.channel_freq_mhz, 2432);
|
||||
assert_eq!(frame.metadata.sequence, 11610);
|
||||
assert_eq!(frame.metadata.rssi_dbm, -40);
|
||||
assert_eq!(frame.metadata.noise_floor_dbm, -87);
|
||||
// ADR-110 byte 18: HE-SU PPDU. Byte 19 bit 4: ESP-NOW time-sync valid.
|
||||
assert_eq!(frame.metadata.ppdu_type, PpduType::HeSu);
|
||||
assert!(frame.metadata.ppdu_type.is_he());
|
||||
assert!(frame.metadata.adr018_flags.ieee802154_sync_valid);
|
||||
assert!(!frame.metadata.adr018_flags.bw40);
|
||||
// 256-FFT HE-LTF on a 20 MHz channel — NOT 160 MHz.
|
||||
assert_eq!(frame.metadata.bandwidth, Bandwidth::Bw20);
|
||||
assert!(frame.is_valid());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_ht_frame_148_bytes_parses_with_64_subcarriers() {
|
||||
let data = unhex(HT_FRAME_HEX);
|
||||
assert_eq!(data.len(), 148);
|
||||
|
||||
let (frame, consumed) = Esp32CsiParser::parse_frame(&data).expect("HT frame must parse");
|
||||
assert_eq!(consumed, 148);
|
||||
assert_eq!(frame.metadata.node_id, 12);
|
||||
assert_eq!(frame.metadata.n_subcarriers, 64);
|
||||
assert_eq!(frame.metadata.channel_freq_mhz, 2432);
|
||||
assert_eq!(frame.metadata.sequence, 11622);
|
||||
assert_eq!(frame.metadata.rssi_dbm, -79);
|
||||
assert_eq!(frame.metadata.noise_floor_dbm, -87);
|
||||
assert_eq!(frame.metadata.ppdu_type, PpduType::HtLegacy);
|
||||
assert!(!frame.metadata.ppdu_type.is_he());
|
||||
// 64-bin full HT20 FFT grid on a 20 MHz channel — NOT 40 MHz.
|
||||
assert_eq!(frame.metadata.bandwidth, Bandwidth::Bw20);
|
||||
assert!(frame.is_valid());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_interleaved_stream_parses_both_grids() {
|
||||
// The live node interleaves HE (84%) and HT (16%) frames on one socket.
|
||||
let mut stream = unhex(HE_FRAME_HEX);
|
||||
stream.extend_from_slice(&unhex(HT_FRAME_HEX));
|
||||
stream.extend_from_slice(&unhex(HE_FRAME_HEX));
|
||||
|
||||
let (frames, consumed) = Esp32CsiParser::parse_stream(&stream);
|
||||
assert_eq!(frames.len(), 3);
|
||||
assert_eq!(consumed, 532 + 148 + 532);
|
||||
assert_eq!(frames[0].metadata.n_subcarriers, 256);
|
||||
assert_eq!(frames[1].metadata.n_subcarriers, 64);
|
||||
assert_eq!(frames[2].metadata.n_subcarriers, 256);
|
||||
assert_eq!(frames[0].metadata.ppdu_type, PpduType::HeSu);
|
||||
assert_eq!(frames[1].metadata.ppdu_type, PpduType::HtLegacy);
|
||||
}
|
||||
@@ -15,12 +15,17 @@ readme = "README.md"
|
||||
default = ["std", "api", "ruvector"]
|
||||
ruvector = ["dep:ruvector-solver", "dep:ruvector-temporal-tensor"]
|
||||
std = []
|
||||
api = ["chrono/serde", "geo/use-serde"]
|
||||
# REST/WebSocket surface. Pulls the web stack (axum, futures-util) only when
|
||||
# enabled, and enables the `serde` FEATURE (not just `dep:serde`) so the
|
||||
# `cfg_attr(feature = "serde", ...)` derives on domain types are actually
|
||||
# active when the API is on (review finding 5: `api = ["dep:serde"]` enabled
|
||||
# the dependency but left every `feature = "serde"` cfg dead).
|
||||
api = ["serde", "dep:axum", "dep:futures-util"]
|
||||
portable = ["low-power"]
|
||||
low-power = []
|
||||
distributed = ["tokio/sync"]
|
||||
drone = ["distributed"]
|
||||
serde = ["chrono/serde", "geo/use-serde"]
|
||||
serde = ["dep:serde", "chrono/serde", "geo/use-serde"]
|
||||
|
||||
[dependencies]
|
||||
# Workspace dependencies
|
||||
@@ -30,20 +35,22 @@ wifi-densepose-nn = { version = "0.3.0", path = "../wifi-densepose-nn" }
|
||||
ruvector-solver = { workspace = true, optional = true }
|
||||
ruvector-temporal-tensor = { workspace = true, optional = true }
|
||||
|
||||
# Async runtime
|
||||
# Async runtime — required by the core integration layer (UDP CSI receiver,
|
||||
# hardware adapter, scan loop in `DisasterResponse::start_scanning`), not just
|
||||
# the REST API, so it is deliberately NOT gated behind `api`.
|
||||
tokio = { version = "1.35", features = ["rt", "sync", "time"] }
|
||||
async-trait = "0.1"
|
||||
|
||||
# Web framework (REST API)
|
||||
axum = { version = "0.7", features = ["ws"] }
|
||||
futures-util = "0.3"
|
||||
# Web framework (REST API) — only compiled with the `api` feature.
|
||||
axum = { version = "0.7", features = ["ws"], optional = true }
|
||||
futures-util = { version = "0.3", optional = true }
|
||||
|
||||
# Error handling
|
||||
thiserror = "2.0"
|
||||
anyhow = "1.0"
|
||||
|
||||
# Serialization
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde = { version = "1.0", features = ["derive"], optional = true }
|
||||
serde_json = "1.0"
|
||||
|
||||
# Time handling
|
||||
|
||||
@@ -78,6 +78,10 @@
|
||||
#![warn(rustdoc::missing_crate_level_docs)]
|
||||
|
||||
pub mod alerting;
|
||||
/// REST API surface (Axum). Requires the `api` feature — its DTOs derive
|
||||
/// serde, which is an optional dependency gated behind that feature.
|
||||
#[cfg(feature = "api")]
|
||||
#[cfg_attr(docsrs, doc(cfg(feature = "api")))]
|
||||
pub mod api;
|
||||
pub mod detection;
|
||||
pub mod domain;
|
||||
@@ -122,6 +126,8 @@ pub use integration::{
|
||||
AdapterError, HardwareAdapter, IntegrationConfig, NeuralAdapter, SignalAdapter,
|
||||
};
|
||||
|
||||
#[cfg(feature = "api")]
|
||||
#[cfg_attr(docsrs, doc(cfg(feature = "api")))]
|
||||
pub use api::{create_router, AppState};
|
||||
|
||||
pub use ml::{
|
||||
|
||||
@@ -53,6 +53,16 @@ wifi-densepose-signal = { version = "0.3.1", path = "../wifi-densepose-signal",
|
||||
# Hardware crate — SyncPacket decoder for ADR-110 §A0.12 mesh-aligned timestamps.
|
||||
wifi-densepose-hardware = { version = "0.3.0", path = "../wifi-densepose-hardware" }
|
||||
|
||||
# Governed streaming engine (ADR-135..146): fusion + privacy demotion +
|
||||
# WorldGraph belief + deterministic witness. The live server data runs through
|
||||
# this as a governed path whose Restricted-class decision strips per-node raw
|
||||
# amplitudes from the live publish; full output gating is a tracked follow-up —
|
||||
# see engine_bridge.rs ("Honest scope of the live-path governance").
|
||||
wifi-densepose-engine = { version = "0.3.0", path = "../wifi-densepose-engine" }
|
||||
wifi-densepose-worldgraph = { version = "0.3.0", path = "../wifi-densepose-worldgraph" }
|
||||
wifi-densepose-bfld = { version = "0.3.1", path = "../wifi-densepose-bfld", default-features = false }
|
||||
wifi-densepose-geo = { version = "0.1.0", path = "../wifi-densepose-geo" }
|
||||
|
||||
# midstream — real-time introspection / low-latency tap (ADR-099 D1).
|
||||
# Two crates only, on purpose: scheduler / neural-solver / strange-loop are
|
||||
# explicitly out of scope of ADR-099 (D5).
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
use ruvector_mincut::{DynamicMinCut, MinCutBuilder};
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use wifi_densepose_hardware::PpduType;
|
||||
|
||||
use crate::adaptive_classifier;
|
||||
use crate::types::*;
|
||||
@@ -84,6 +85,18 @@ pub fn parse_wasm_output(buf: &[u8]) -> Option<WasmOutputPacket> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse an ADR-018 raw CSI frame (magic 0xC511_0001).
|
||||
///
|
||||
/// Header layout (authoritative: firmware `csi_collector.c` / ADR-018):
|
||||
/// magic u32 LE @0, node_id u8 @4, n_antennas u8 @5, n_subcarriers u16 LE
|
||||
/// @6-7, freq_mhz u32 LE @8-11, sequence u32 LE @12-15, rssi i8 @16,
|
||||
/// noise_floor i8 @17, PPDU type u8 @18 (ADR-110), flags u8 @19 (ADR-110),
|
||||
/// I/Q pairs from @20.
|
||||
///
|
||||
/// Until issue #1005 this function read `n_subcarriers` from byte 6 alone
|
||||
/// (an ESP32-C6 HE-SU frame's 256 = 0x0100 LE decoded as 0 — the frame
|
||||
/// parsed "successfully" with zero subcarriers) and read sequence/rssi/
|
||||
/// noise at stale offsets 10/14/15 (rssi landed on sequence bytes ⇒ 0).
|
||||
pub fn parse_esp32_frame(buf: &[u8]) -> Option<Esp32Frame> {
|
||||
if buf.len() < 20 {
|
||||
return None;
|
||||
@@ -95,16 +108,18 @@ pub fn parse_esp32_frame(buf: &[u8]) -> Option<Esp32Frame> {
|
||||
|
||||
let node_id = buf[4];
|
||||
let n_antennas = buf[5];
|
||||
let n_subcarriers = buf[6];
|
||||
let freq_mhz = u16::from_le_bytes([buf[8], buf[9]]);
|
||||
let sequence = u32::from_le_bytes([buf[10], buf[11], buf[12], buf[13]]);
|
||||
let rssi_raw = buf[14] as i8;
|
||||
let n_subcarriers = u16::from_le_bytes([buf[6], buf[7]]);
|
||||
let freq_mhz_u32 = u32::from_le_bytes([buf[8], buf[9], buf[10], buf[11]]);
|
||||
let freq_mhz = u16::try_from(freq_mhz_u32).unwrap_or(0);
|
||||
let sequence = u32::from_le_bytes([buf[12], buf[13], buf[14], buf[15]]);
|
||||
let rssi_raw = buf[16] as i8;
|
||||
let rssi = if rssi_raw > 0 {
|
||||
rssi_raw.saturating_neg()
|
||||
} else {
|
||||
rssi_raw
|
||||
};
|
||||
let noise_floor = buf[15] as i8;
|
||||
let noise_floor = buf[17] as i8;
|
||||
let ppdu_type = PpduType::from_byte(buf[18]);
|
||||
|
||||
let iq_start = 20;
|
||||
let n_pairs = n_antennas as usize * n_subcarriers as usize;
|
||||
@@ -131,6 +146,7 @@ pub fn parse_esp32_frame(buf: &[u8]) -> Option<Esp32Frame> {
|
||||
sequence,
|
||||
rssi,
|
||||
noise_floor,
|
||||
ppdu_type,
|
||||
amplitudes,
|
||||
phases,
|
||||
})
|
||||
@@ -964,11 +980,12 @@ pub fn generate_simulated_frame(tick: u64) -> Esp32Frame {
|
||||
magic: 0xC511_0001,
|
||||
node_id: 1,
|
||||
n_antennas: 1,
|
||||
n_subcarriers: n_sub as u8,
|
||||
n_subcarriers: n_sub as u16,
|
||||
freq_mhz: 2437,
|
||||
sequence: tick as u32,
|
||||
rssi: (-40.0 + 5.0 * (t * 0.2).sin()) as i8,
|
||||
noise_floor: -90,
|
||||
ppdu_type: PpduType::HtLegacy,
|
||||
amplitudes,
|
||||
phases,
|
||||
}
|
||||
@@ -981,3 +998,76 @@ pub fn chrono_timestamp() -> u64 {
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
// ── ADR-110 / issue #1005 tests: live ESP32-C6 HE-LTF frames ────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod adr110_tests {
|
||||
use super::*;
|
||||
use crate::types::NodeState;
|
||||
|
||||
/// Verbatim 532-byte HE-SU UDP payload captured live 2026-06-11 from an
|
||||
/// ESP32-C6 (node 12, IDF v5.5): 256 subcarrier bins, byte18=0x01.
|
||||
const HE_FRAME_HEX: &str = "010011c50c010001800900005a2d0000d8a9011000000000000000000000f70ef70ef50cf30bf209f108f006ef03ee02ee00eefdeffbeff8f0f7f1f4f2f3f4f1f5f0f7eef8edfaecfdecffeb01ea03ea05e908ea0aeb0deb0fec11ee13f015f216f318f519f71afa1bfd1bff1c021c051b071b0a1a0c190f1811161315161218101a0e1b0c1c091d071e041f0120ff20fc20f91ff71ff41ef11def1cec1be919e717e615e413e311e10edf0cde09dd06dc04dc01dcffdcfbdcf9ddf6def3dff0e0ede2eae4e8e6e6e8e4eae2ebe0eedef1dcf4dbf7dafad9fdd900d903d806d909d90cda0fdc12dc14dd17df1ae11ce31ee520e722e924ed25f127f328f629f929fd2900290329062809270c260e26122516061a00001c201c1f1a211722142411250e260c27082804280129fe29fb28f927f627f426f125ef23ec22ea20e81eea20e81e891b53a82951565d4ffafbfebe9abddb10222aa47b3b371fd2c0860cd4d86ea2f35faccd46b0b66f6ff0050f2da27d1c92f7f8e1017cb545afd3e3fe60db6f478dc85a33b3454cf6df9061194a0a0fc3e0eedf76f1d292cb25c8f541dfcc4109f9f1a34955520ad8ffa3694ac395cbf6c19073a4aefb1ebf47c76730458431805d9f18ff2e81955e8752b29757f66e289f72f8e35309a737547c040444cbda1a81d221d950037ec38fd9d1dd0f56c3dc707a7bbfe66ca5a97ab7cc17d68d38ba43a1806f91f5911a5967e2c9f7f07186";
|
||||
|
||||
/// Verbatim 148-byte HT payload from the same node seconds later:
|
||||
/// 64 bins, byte18=0x00.
|
||||
const HT_FRAME_HEX: &str = "010011c50c01400080090000662d0000b1a900100000000000000000fcfaf909f013f112f213f212f311f410f511f510f610f510f411f410f411f312f213f214f214f212f313f513f512f611f610f80ef90df90c0000010eff11fe13ff11fe1300000000ff01000001010002000200020204000301040103000400040002ff03ff03fe02fe02fe01fd00edfc03fa000000000000";
|
||||
|
||||
fn unhex(s: &str) -> Vec<u8> {
|
||||
(0..s.len())
|
||||
.step_by(2)
|
||||
.map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_he_su_frame_parses_with_256_subcarriers() {
|
||||
let buf = unhex(HE_FRAME_HEX);
|
||||
assert_eq!(buf.len(), 532);
|
||||
let f = parse_esp32_frame(&buf).expect("532-byte HE frame must parse");
|
||||
assert_eq!(f.node_id, 12);
|
||||
assert_eq!(f.n_subcarriers, 256);
|
||||
assert_eq!(f.amplitudes.len(), 256);
|
||||
assert_eq!(f.freq_mhz, 2432);
|
||||
assert_eq!(f.sequence, 11610);
|
||||
assert_eq!(f.rssi, -40);
|
||||
assert_eq!(f.noise_floor, -87);
|
||||
assert_eq!(f.ppdu_type, PpduType::HeSu);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_ht_frame_parses_with_64_subcarriers() {
|
||||
let buf = unhex(HT_FRAME_HEX);
|
||||
assert_eq!(buf.len(), 148);
|
||||
let f = parse_esp32_frame(&buf).expect("148-byte HT frame must parse");
|
||||
assert_eq!(f.node_id, 12);
|
||||
assert_eq!(f.n_subcarriers, 64);
|
||||
assert_eq!(f.amplitudes.len(), 64);
|
||||
assert_eq!(f.rssi, -79);
|
||||
assert_eq!(f.ppdu_type, PpduType::HtLegacy);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grid_gate_never_mixes_ht_and_he_windows() {
|
||||
let he = parse_esp32_frame(&unhex(HE_FRAME_HEX)).unwrap();
|
||||
let ht = parse_esp32_frame(&unhex(HT_FRAME_HEX)).unwrap();
|
||||
let mut ns = NodeState::new();
|
||||
|
||||
// First frame locks the grid.
|
||||
assert!(ns.accept_grid(ht.grid()));
|
||||
ns.frame_history.push_back(ht.amplitudes.clone());
|
||||
|
||||
// HE upgrade: accepted, denser grid wins, history re-keyed.
|
||||
assert!(ns.accept_grid(he.grid()));
|
||||
assert!(ns.frame_history.is_empty(), "upgrade must clear HT history");
|
||||
ns.frame_history.push_back(he.amplitudes.clone());
|
||||
|
||||
// Interleaved HT minority frames are rejected from the feature path.
|
||||
assert!(!ns.accept_grid(ht.grid()));
|
||||
assert_eq!(ns.frame_history.len(), 1, "HT frame must not touch window");
|
||||
|
||||
// Steady-state HE frames keep flowing.
|
||||
assert!(ns.accept_grid(he.grid()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,469 @@
|
||||
//! Live trust-path bridge: drive the governed [`StreamingEngine`] from the
|
||||
//! sensing-server's live `NodeState` map.
|
||||
//!
|
||||
//! `multistatic_bridge.rs` already converts `NodeState` → `MultiBandCsiFrame`
|
||||
//! and runs the *bare* `MultistaticFuser`. That path produces fused amplitudes
|
||||
//! but skips the trust control plane: privacy demotion on contradiction, the
|
||||
//! WorldGraph belief with mandatory provenance, and the deterministic witness
|
||||
//! (ADR-135..146). This bridge routes the same live frames through
|
||||
//! [`StreamingEngine::process_cycle`], so every governed belief carries
|
||||
//! evidence + model + calibration + privacy decision and a BLAKE3 witness
|
||||
//! (narrowing the gap called out in ADR-136 §8 and the beyond-SOTA system
|
||||
//! review).
|
||||
//!
|
||||
//! ## Honest scope of the live-path governance
|
||||
//!
|
||||
//! The engine runs *alongside* the bare fusion path that feeds the live
|
||||
//! `SensingUpdate`; it does not replace it. What the engine's decision **does**
|
||||
//! gate on the live wire today: when a cycle is emitted at
|
||||
//! [`PrivacyClass::Restricted`] (base mode or contradiction/mesh-risk
|
||||
//! demotion), [`EngineBridge::suppress_raw_outputs`] is true and `main.rs`
|
||||
//! strips the per-node raw amplitude vectors from the published update — the
|
||||
//! same field mapping `wifi-densepose-bfld`'s privacy gate applies at
|
||||
//! `Restricted` (drop amplitude/phase proxies). Trust state (latest witness,
|
||||
//! effective class, recalibration flag, engine-error count) is readable on
|
||||
//! `GET /api/v1/status`. Gating of the remaining *derived* outputs
|
||||
//! (person count, classification, signal field) by privacy class is tracked
|
||||
//! as a follow-up; until then those fields are published ungoverned.
|
||||
//!
|
||||
//! Determinism: this module reads server state and forwards explicit
|
||||
//! timestamps/calibration ids; it introduces no wall-clock reads of its own, so
|
||||
//! a given `(frames, calibration, now_ms)` always yields the same
|
||||
//! [`TrustedOutput`] witness.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use wifi_densepose_bfld::{PrivacyClass, PrivacyMode};
|
||||
use wifi_densepose_engine::{AdapterInfo, EngineError, StreamingEngine, TrustedOutput};
|
||||
use wifi_densepose_geo::types::GeoRegistration;
|
||||
use wifi_densepose_signal::ruvsense::fusion_quality::CalibrationId;
|
||||
use wifi_densepose_worldgraph::WorldId;
|
||||
|
||||
use super::multistatic_bridge::node_frames_from_states;
|
||||
use super::NodeState;
|
||||
|
||||
/// Minimum spacing between engine-error warn logs (errors are still counted
|
||||
/// every cycle; only the log line is rate-limited — a 20 Hz loop must not
|
||||
/// emit 20 warns/s).
|
||||
const ENGINE_ERROR_WARN_INTERVAL: Duration = Duration::from_secs(10);
|
||||
|
||||
/// Owns a [`StreamingEngine`] and the WorldGraph scope (one room + sensor) the
|
||||
/// live sensing loop publishes beliefs into.
|
||||
pub struct EngineBridge {
|
||||
engine: StreamingEngine,
|
||||
room: WorldId,
|
||||
/// Nodes already wired into the WorldGraph as sensors (by `node_id`).
|
||||
registered_nodes: HashMap<u8, WorldId>,
|
||||
/// Calibration epoch applied to live frames until the ADR-135 baseline
|
||||
/// stage supplies a real per-node id. Stable so witnesses are reproducible.
|
||||
calibration: CalibrationId,
|
||||
// ── Trust state observed from the most recent cycles (review finding 1:
|
||||
// previously write-only fields on AppState; now recorded here and
|
||||
// exposed via the status endpoint + output gating). ──────────────────
|
||||
/// BLAKE3 witness of the most recent successful governed cycle.
|
||||
last_witness: Option<[u8; 32]>,
|
||||
/// Latest drift→recalibration recommendation (ADR-135 → ADR-150 §3.4).
|
||||
recalibration_recommended: bool,
|
||||
/// Privacy class the most recent cycle was emitted under (post-demotion).
|
||||
effective_class: Option<PrivacyClass>,
|
||||
/// Whether the most recent cycle was demoted (contradiction / mesh risk).
|
||||
demoted: bool,
|
||||
/// Total engine cycles that returned an error (previously swallowed by
|
||||
/// `if let Some(Ok(..))` at the call sites).
|
||||
engine_error_count: u64,
|
||||
/// Last time an engine error was actually logged (rate limiter).
|
||||
last_error_warn_at: Option<Instant>,
|
||||
}
|
||||
|
||||
impl EngineBridge {
|
||||
/// Build a bridge for one installation. `room_area_id`/`room_name` name the
|
||||
/// observation scope; `mode` is the starting privacy mode.
|
||||
pub fn new(mode: PrivacyMode, model_version: u16, room_area_id: &str, room_name: &str) -> Self {
|
||||
let mut engine = StreamingEngine::new(mode, model_version, GeoRegistration::default());
|
||||
let room = engine.add_room(room_area_id, room_name);
|
||||
Self {
|
||||
engine,
|
||||
room,
|
||||
registered_nodes: HashMap::new(),
|
||||
calibration: CalibrationId(0x5256_0001), // "RV\0\x01" — placeholder epoch
|
||||
last_witness: None,
|
||||
recalibration_recommended: false,
|
||||
effective_class: None,
|
||||
demoted: false,
|
||||
engine_error_count: 0,
|
||||
last_error_warn_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Override the calibration epoch stamped onto live frames (ADR-135).
|
||||
pub fn set_calibration(&mut self, calibration: CalibrationId) {
|
||||
self.calibration = calibration;
|
||||
}
|
||||
|
||||
/// Override the WorldGraph belief-retention cap (bounds memory on the live
|
||||
/// loop; see `WorldGraph::prune_semantic_states`).
|
||||
pub fn set_semantic_retention(&mut self, max_states: usize) {
|
||||
self.engine.set_semantic_retention(max_states);
|
||||
}
|
||||
|
||||
/// Switch the active privacy mode (operator/control-plane action).
|
||||
pub fn set_privacy_mode(&mut self, mode: PrivacyMode) {
|
||||
self.engine.set_privacy_mode(mode);
|
||||
}
|
||||
|
||||
/// Activate a per-room calibration adapter (ADR-150 §3.4). The adapter's
|
||||
/// content-derived id becomes part of provenance/witness from the next
|
||||
/// cycle — weights can never swap silently on the live path.
|
||||
pub fn set_room_adapter(&mut self, info: AdapterInfo) {
|
||||
self.engine.set_room_adapter(info);
|
||||
}
|
||||
|
||||
/// Deactivate the per-room adapter (revert to the shared base model).
|
||||
pub fn clear_room_adapter(&mut self) {
|
||||
self.engine.clear_room_adapter();
|
||||
}
|
||||
|
||||
/// Borrow the engine (queries, WorldGraph snapshot, privacy audit).
|
||||
pub fn engine(&self) -> &StreamingEngine {
|
||||
&self.engine
|
||||
}
|
||||
|
||||
/// Number of sensor nodes wired into the WorldGraph so far.
|
||||
pub fn registered_node_count(&self) -> usize {
|
||||
self.registered_nodes.len()
|
||||
}
|
||||
|
||||
/// Run one governed trust cycle over the current live node states.
|
||||
///
|
||||
/// Returns `None` when no active node yields a frame (nothing to fuse —
|
||||
/// the engine is not invoked, so no spurious belief is published). On a
|
||||
/// real cycle it lazily wires any newly-seen node as a WorldGraph sensor,
|
||||
/// then returns the witnessed [`TrustedOutput`] (or a fusion error).
|
||||
///
|
||||
/// `now_ms` is supplied by the caller (the sensing loop's clock), keeping
|
||||
/// the bridge deterministic and replayable.
|
||||
pub fn process_cycle_from_states(
|
||||
&mut self,
|
||||
node_states: &HashMap<u8, NodeState>,
|
||||
now_ms: i64,
|
||||
) -> Option<Result<TrustedOutput, EngineError>> {
|
||||
let frames = node_frames_from_states(node_states);
|
||||
if frames.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// Lazily register each contributing node as a sensor observing the room,
|
||||
// so the privacy rollup can suppress it under identity-strict modes.
|
||||
for f in &frames {
|
||||
self.registered_nodes.entry(f.node_id).or_insert_with(|| {
|
||||
self.engine
|
||||
.add_sensor(&format!("node-{}", f.node_id), self.room)
|
||||
});
|
||||
}
|
||||
Some(
|
||||
self.engine
|
||||
.process_cycle(&frames, self.calibration, self.room, now_ms),
|
||||
)
|
||||
}
|
||||
|
||||
/// Run one governed cycle **and record the trust state** (review finding
|
||||
/// 1): on success the witness / effective class / demotion /
|
||||
/// recalibration flag are stored for the status endpoint and output
|
||||
/// gating; on error the error counter is incremented and a rate-limited
|
||||
/// warning is logged (never silently swallowed). Returns the trusted
|
||||
/// output on success, `None` when there was nothing to fuse or the cycle
|
||||
/// errored.
|
||||
pub fn observe_cycle(
|
||||
&mut self,
|
||||
node_states: &HashMap<u8, NodeState>,
|
||||
now_ms: i64,
|
||||
) -> Option<TrustedOutput> {
|
||||
match self.process_cycle_from_states(node_states, now_ms)? {
|
||||
Ok(trust) => {
|
||||
self.last_witness = Some(trust.witness);
|
||||
self.recalibration_recommended = trust.recalibration_recommended;
|
||||
self.effective_class = Some(trust.effective_class);
|
||||
self.demoted = trust.demoted;
|
||||
Some(trust)
|
||||
}
|
||||
Err(e) => {
|
||||
self.engine_error_count += 1;
|
||||
let now = Instant::now();
|
||||
let warn_due = self.last_error_warn_at.map_or(true, |t| {
|
||||
now.duration_since(t) >= ENGINE_ERROR_WARN_INTERVAL
|
||||
});
|
||||
if warn_due {
|
||||
self.last_error_warn_at = Some(now);
|
||||
tracing::warn!(
|
||||
total_engine_errors = self.engine_error_count,
|
||||
"governed trust cycle failed (warn rate-limited to one per {:?}): {e}",
|
||||
ENGINE_ERROR_WARN_INTERVAL
|
||||
);
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// BLAKE3 witness of the most recent successful governed cycle.
|
||||
pub fn last_trust_witness(&self) -> Option<[u8; 32]> {
|
||||
self.last_witness
|
||||
}
|
||||
|
||||
/// Latest drift→recalibration recommendation from the governed engine.
|
||||
pub fn recalibration_recommended(&self) -> bool {
|
||||
self.recalibration_recommended
|
||||
}
|
||||
|
||||
/// Privacy class the most recent cycle was emitted under (post-demotion);
|
||||
/// `None` until a governed cycle has run.
|
||||
pub fn effective_class(&self) -> Option<PrivacyClass> {
|
||||
self.effective_class
|
||||
}
|
||||
|
||||
/// Whether the most recent cycle was demoted (contradiction / mesh risk).
|
||||
pub fn demoted(&self) -> bool {
|
||||
self.demoted
|
||||
}
|
||||
|
||||
/// Engine cycles that returned an error since startup.
|
||||
pub fn engine_error_count(&self) -> u64 {
|
||||
self.engine_error_count
|
||||
}
|
||||
|
||||
/// ADR-141 output mapping for the live publish path (review finding 1c):
|
||||
/// at effective class [`PrivacyClass::Restricted`] the bfld privacy gate
|
||||
/// drops the amplitude + phase proxies; the live `SensingUpdate` applies
|
||||
/// the same field mapping by suppressing the per-node raw amplitude
|
||||
/// vectors when this returns true. Classes below `Restricted` leave the
|
||||
/// publish unchanged.
|
||||
pub fn suppress_raw_outputs(&self) -> bool {
|
||||
self.effective_class
|
||||
.is_some_and(|c| c.as_u8() >= PrivacyClass::Restricted.as_u8())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::VecDeque;
|
||||
use std::time::Instant;
|
||||
use wifi_densepose_bfld::PrivacyClass;
|
||||
|
||||
fn node_state_with_history(amp: f64, n_sub: usize) -> NodeState {
|
||||
let mut ns = NodeState::new();
|
||||
let frame: Vec<f64> = (0..n_sub).map(|i| amp + 0.1 * i as f64).collect();
|
||||
ns.frame_history = VecDeque::from(vec![frame]);
|
||||
ns.last_frame_time = Some(Instant::now());
|
||||
ns
|
||||
}
|
||||
|
||||
fn two_node_states() -> HashMap<u8, NodeState> {
|
||||
let mut m = HashMap::new();
|
||||
m.insert(0u8, node_state_with_history(1.0, 56));
|
||||
m.insert(1u8, node_state_with_history(1.05, 56));
|
||||
m
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_states_produce_no_belief() {
|
||||
let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "living_room", "Living Room");
|
||||
let out = bridge.process_cycle_from_states(&HashMap::new(), 1_000);
|
||||
assert!(out.is_none());
|
||||
// No belief published, no sensor wired.
|
||||
assert_eq!(bridge.registered_node_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_cycle_produces_witnessed_belief_with_provenance() {
|
||||
let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "living_room", "Living Room");
|
||||
let states = two_node_states();
|
||||
let out = bridge
|
||||
.process_cycle_from_states(&states, 10_000)
|
||||
.expect("frames present")
|
||||
.expect("fusion succeeds");
|
||||
|
||||
// Full provenance: evidence + model + calibration + privacy decision.
|
||||
assert!(!out.provenance.evidence.is_empty());
|
||||
assert_eq!(out.provenance.model_version, "rfenc-v1");
|
||||
assert!(out.provenance.calibration_version.starts_with("cal:"));
|
||||
assert!(out.provenance.privacy_decision.starts_with("PrivateHome/"));
|
||||
// A witness was produced and the belief is in the WorldGraph.
|
||||
assert_ne!(out.witness, [0u8; 32]);
|
||||
assert!(bridge.engine().world().node(out.semantic_id).is_some());
|
||||
// Both nodes are now wired as sensors.
|
||||
assert_eq!(bridge.registered_node_count(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_path_is_deterministic() {
|
||||
let states = two_node_states_fixed();
|
||||
let run = || {
|
||||
let mut b = EngineBridge::new(PrivacyMode::PrivateHome, 1, "r", "R");
|
||||
b.process_cycle_from_states(&states, 5_000).unwrap().unwrap()
|
||||
};
|
||||
let a = run();
|
||||
let b = run();
|
||||
assert_eq!(a.witness, b.witness);
|
||||
assert_eq!(a.provenance.calibration_version, b.provenance.calibration_version);
|
||||
assert_eq!(a.effective_class, b.effective_class);
|
||||
}
|
||||
|
||||
// Deterministic node states (no wall-clock in amplitude/history).
|
||||
fn two_node_states_fixed() -> HashMap<u8, NodeState> {
|
||||
let mut m = HashMap::new();
|
||||
for (id, amp) in [(0u8, 1.0_f64), (1u8, 1.05)] {
|
||||
let mut ns = NodeState::new();
|
||||
ns.frame_history = VecDeque::from(vec![(0..56)
|
||||
.map(|i| amp + 0.1 * i as f64)
|
||||
.collect::<Vec<f64>>()]);
|
||||
ns.last_frame_time = Some(Instant::now());
|
||||
m.insert(id, ns);
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nodes_registered_once_across_cycles() {
|
||||
let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "r", "R");
|
||||
let states = two_node_states();
|
||||
bridge.process_cycle_from_states(&states, 1_000);
|
||||
bridge.process_cycle_from_states(&states, 2_000);
|
||||
bridge.process_cycle_from_states(&states, 3_000);
|
||||
// Still exactly two sensors — idempotent registration.
|
||||
assert_eq!(bridge.registered_node_count(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retention_bounds_world_graph_growth() {
|
||||
let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "r", "R");
|
||||
bridge.set_semantic_retention(5);
|
||||
let states = two_node_states();
|
||||
for i in 0..20i64 {
|
||||
bridge.process_cycle_from_states(&states, 1_000 + i * 50);
|
||||
}
|
||||
// room + 2 sensors + at most 5 retained beliefs.
|
||||
assert!(bridge.engine().world().node_count() <= 3 + 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adapter_identity_flows_into_live_witness() {
|
||||
let states = two_node_states_fixed();
|
||||
let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "r", "R");
|
||||
let base = bridge
|
||||
.process_cycle_from_states(&states, 1_000)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
bridge.set_room_adapter(AdapterInfo {
|
||||
adapter_id: "deadbeefcafef00d".into(),
|
||||
trained_samples: 120,
|
||||
});
|
||||
let adapted = bridge
|
||||
.process_cycle_from_states(&states, 2_000)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(adapted
|
||||
.provenance
|
||||
.model_version
|
||||
.ends_with("+adapter:deadbeefcafef00d"));
|
||||
assert_ne!(adapted.witness, base.witness);
|
||||
// Clearing reverts to the base model identity.
|
||||
bridge.clear_room_adapter();
|
||||
let back = bridge
|
||||
.process_cycle_from_states(&states, 3_000)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(back.provenance.model_version, "rfenc-v1");
|
||||
}
|
||||
|
||||
/// Wiring (review finding 1): a live frame in → trust state recorded on
|
||||
/// the bridge (witness, effective class, recalibration flag), readable by
|
||||
/// the status endpoint, with a zero error count on the happy path.
|
||||
#[test]
|
||||
fn observe_cycle_records_trust_state() {
|
||||
let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "r", "R");
|
||||
assert!(bridge.last_trust_witness().is_none());
|
||||
assert_eq!(bridge.effective_class(), None);
|
||||
|
||||
let out = bridge
|
||||
.observe_cycle(&two_node_states(), 1_000)
|
||||
.expect("two fresh nodes → governed cycle runs");
|
||||
|
||||
assert_eq!(bridge.last_trust_witness(), Some(out.witness));
|
||||
assert_eq!(bridge.effective_class(), Some(out.effective_class));
|
||||
assert_eq!(
|
||||
bridge.recalibration_recommended(),
|
||||
out.recalibration_recommended
|
||||
);
|
||||
assert_eq!(bridge.demoted(), out.demoted);
|
||||
assert_eq!(bridge.engine_error_count(), 0);
|
||||
// PrivateHome clean cycle → Anonymous → raw outputs NOT suppressed.
|
||||
assert_eq!(bridge.effective_class(), Some(PrivacyClass::Anonymous));
|
||||
assert!(!bridge.suppress_raw_outputs());
|
||||
}
|
||||
|
||||
/// Error wiring (review finding 1a): two live nodes with mismatched
|
||||
/// subcarrier counts make fusion return a `DimensionMismatch` →
|
||||
/// `EngineError` — previously dropped by `if let Some(Ok(..))` at the
|
||||
/// call sites. The counter must increment and the last good trust state
|
||||
/// must survive a later failure.
|
||||
#[test]
|
||||
fn observe_cycle_counts_engine_errors() {
|
||||
let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "r", "R");
|
||||
let mut mismatched = HashMap::new();
|
||||
mismatched.insert(0u8, node_state_with_history(1.0, 56));
|
||||
mismatched.insert(1u8, node_state_with_history(1.05, 30)); // 30 ≠ 56 subcarriers
|
||||
|
||||
assert!(bridge.observe_cycle(&mismatched, 1_000).is_none());
|
||||
assert_eq!(bridge.engine_error_count(), 1);
|
||||
assert!(
|
||||
bridge.last_trust_witness().is_none(),
|
||||
"no witness from a failed cycle"
|
||||
);
|
||||
|
||||
assert!(bridge.observe_cycle(&mismatched, 2_000).is_none());
|
||||
assert_eq!(bridge.engine_error_count(), 2);
|
||||
|
||||
// A later good cycle records trust state; the audit count is kept.
|
||||
let out = bridge.observe_cycle(&two_node_states(), 3_000);
|
||||
assert!(out.is_some());
|
||||
assert!(bridge.last_trust_witness().is_some());
|
||||
assert_eq!(bridge.engine_error_count(), 2);
|
||||
|
||||
// And a subsequent failure keeps the last good witness readable.
|
||||
assert!(bridge.observe_cycle(&mismatched, 4_000).is_none());
|
||||
assert_eq!(bridge.engine_error_count(), 3);
|
||||
assert!(bridge.last_trust_witness().is_some());
|
||||
}
|
||||
|
||||
/// ADR-141 mapping (review finding 1c): a cycle emitted at class
|
||||
/// Restricted flips `suppress_raw_outputs`, which `main.rs` uses to strip
|
||||
/// per-node raw amplitude vectors from the live publish — the same field
|
||||
/// mapping bfld's privacy gate applies at `Restricted`.
|
||||
#[test]
|
||||
fn restricted_class_suppresses_raw_outputs() {
|
||||
let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "r", "R");
|
||||
bridge.set_privacy_mode(PrivacyMode::StrictNoIdentity); // base = Restricted
|
||||
bridge
|
||||
.observe_cycle(&two_node_states(), 1_000)
|
||||
.expect("cycle runs");
|
||||
assert_eq!(bridge.effective_class(), Some(PrivacyClass::Restricted));
|
||||
assert!(bridge.suppress_raw_outputs());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_strict_mode_is_carried_into_provenance() {
|
||||
let mut bridge = EngineBridge::new(PrivacyMode::PrivateHome, 1, "r", "R");
|
||||
bridge.set_privacy_mode(PrivacyMode::StrictNoIdentity);
|
||||
let out = bridge
|
||||
.process_cycle_from_states(&two_node_states(), 7_000)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(out.provenance.privacy_decision.starts_with("StrictNoIdentity/"));
|
||||
// Effective class is a valid privacy class (sanity).
|
||||
let _ = matches!(
|
||||
out.effective_class,
|
||||
PrivacyClass::Raw | PrivacyClass::Derived | PrivacyClass::Anonymous | PrivacyClass::Restricted
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@
|
||||
mod adaptive_classifier;
|
||||
pub mod cli;
|
||||
pub mod csi;
|
||||
mod engine_bridge;
|
||||
mod field_bridge;
|
||||
mod multistatic_bridge;
|
||||
pub mod pose;
|
||||
@@ -226,15 +227,28 @@ struct Esp32Frame {
|
||||
magic: u32,
|
||||
node_id: u8,
|
||||
n_antennas: u8,
|
||||
n_subcarriers: u8,
|
||||
/// u16 since ADR-110 / issue #1005: ESP32-C6 HE-SU frames carry 256
|
||||
/// subcarrier bins (242 active HE20 tones). HT frames stay ≤128.
|
||||
n_subcarriers: u16,
|
||||
freq_mhz: u16,
|
||||
sequence: u32,
|
||||
rssi: i8,
|
||||
noise_floor: i8,
|
||||
/// ADR-110 byte 18: PPDU type the CSI was sampled from. Pre-ADR-110
|
||||
/// firmware sends 0 ⇒ `PpduType::HtLegacy`.
|
||||
ppdu_type: wifi_densepose_hardware::PpduType,
|
||||
amplitudes: Vec<f64>,
|
||||
phases: Vec<f64>,
|
||||
}
|
||||
|
||||
impl Esp32Frame {
|
||||
/// The `(n_subcarriers, ppdu_type)` symbol-grid identity of this frame.
|
||||
/// HT-LTF and HE-LTF grids are not bin-comparable (ADR-110 / #1005).
|
||||
fn grid(&self) -> (u16, wifi_densepose_hardware::PpduType) {
|
||||
(self.n_subcarriers, self.ppdu_type)
|
||||
}
|
||||
}
|
||||
|
||||
/// Sensing update broadcast to WebSocket clients
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct SensingUpdate {
|
||||
@@ -442,6 +456,12 @@ struct NodeState {
|
||||
/// Most recent novelty score in [0.0, 1.0] (0 = exact-match in bank,
|
||||
/// 1 = no overlap). Consumed by the model-wake gate downstream.
|
||||
pub(crate) last_novelty_score: Option<f32>,
|
||||
/// ADR-110 / issue #1005: the `(n_subcarriers, ppdu_type)` grid this
|
||||
/// node's rolling windows were built on. ESP32-C6 nodes interleave
|
||||
/// HE-SU 256-bin frames with HT 64-bin frames on one socket; mixing
|
||||
/// the two symbol grids in `frame_history` corrupts variance/baseline
|
||||
/// statistics. See [`NodeState::accept_grid`].
|
||||
active_grid: Option<(u16, wifi_densepose_hardware::PpduType)>,
|
||||
}
|
||||
|
||||
/// Default EMA alpha for temporal keypoint smoothing (RuVector Phase 2).
|
||||
@@ -647,6 +667,35 @@ impl NodeState {
|
||||
),
|
||||
),
|
||||
last_novelty_score: None,
|
||||
active_grid: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// ADR-110 / issue #1005 grid gate: decide whether a frame on `grid`
|
||||
/// may enter this node's feature path, and update `active_grid`.
|
||||
///
|
||||
/// Returns `true` to accept. Policy: lock onto the densest grid seen.
|
||||
/// On a grid *upgrade* (more subcarriers — e.g. the first HE-SU 256-bin
|
||||
/// frame after HT 64-bin history) the rolling amplitude history and
|
||||
/// motion baseline are cleared so HT and HE symbol grids are never
|
||||
/// mixed in one window. Sparser-grid frames (the ~16% HT minority an
|
||||
/// ESP32-C6 keeps emitting alongside HE) are rejected from the feature
|
||||
/// path; the caller still records the arrival for fps/liveness.
|
||||
fn accept_grid(&mut self, grid: (u16, wifi_densepose_hardware::PpduType)) -> bool {
|
||||
match self.active_grid {
|
||||
None => {
|
||||
self.active_grid = Some(grid);
|
||||
true
|
||||
}
|
||||
Some(active) if active == grid => true,
|
||||
Some((active_n, _)) if grid.0 > active_n => {
|
||||
self.active_grid = Some(grid);
|
||||
self.frame_history.clear();
|
||||
self.baseline_motion = 0.0;
|
||||
self.baseline_frames = 0;
|
||||
true
|
||||
}
|
||||
Some(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -988,6 +1037,13 @@ struct AppStateInner {
|
||||
last_tracker_instant: Option<std::time::Instant>,
|
||||
/// Attention-weighted multi-node CSI fusion engine.
|
||||
multistatic_fuser: MultistaticFuser,
|
||||
/// Governed trust-path bridge (ADR-135..146): runs the same live frames
|
||||
/// through the privacy/provenance/witness control plane. Does not alter
|
||||
/// person-count behavior; its trust state (witness, effective class,
|
||||
/// recalibration flag, error count) is recorded on the bridge itself and
|
||||
/// exposed via `GET /api/v1/status`, and a Restricted-class cycle strips
|
||||
/// per-node raw amplitudes from the live publish (review finding 1).
|
||||
engine_bridge: engine_bridge::EngineBridge,
|
||||
/// SVD-based room field model for eigenvalue person counting (None until calibration).
|
||||
field_model: Option<FieldModel>,
|
||||
// ── ADR-044 §5.2: adaptive rolling-p95 normalization ─────────────────────
|
||||
@@ -1374,19 +1430,25 @@ fn parse_esp32_frame(buf: &[u8]) -> Option<Esp32Frame> {
|
||||
// [17] noise_floor (i8)
|
||||
// [18..19] reserved
|
||||
// [20..] I/Q data
|
||||
// Issue #1005: until 2026-06 this code read n_subcarriers from byte 6
|
||||
// alone (an ESP32-C6 HE-SU frame's 256 = 0x0100 LE decoded as 0 — the
|
||||
// frame parsed with zero subcarriers) and read sequence/rssi/noise at
|
||||
// stale offsets 10/14/15. Offsets below match the comment (and firmware).
|
||||
let node_id = buf[4];
|
||||
let n_antennas = buf[5];
|
||||
let n_subcarriers = buf[6];
|
||||
let freq_mhz = u16::from_le_bytes([buf[8], buf[9]]);
|
||||
let sequence = u32::from_le_bytes([buf[10], buf[11], buf[12], buf[13]]);
|
||||
let rssi_raw = buf[14] as i8;
|
||||
let n_subcarriers = u16::from_le_bytes([buf[6], buf[7]]);
|
||||
let freq_mhz =
|
||||
u16::try_from(u32::from_le_bytes([buf[8], buf[9], buf[10], buf[11]])).unwrap_or(0);
|
||||
let sequence = u32::from_le_bytes([buf[12], buf[13], buf[14], buf[15]]);
|
||||
let rssi_raw = buf[16] as i8;
|
||||
// Fix RSSI sign: ensure it's always negative (dBm convention).
|
||||
let rssi = if rssi_raw > 0 {
|
||||
rssi_raw.saturating_neg()
|
||||
} else {
|
||||
rssi_raw
|
||||
};
|
||||
let noise_floor = buf[15] as i8;
|
||||
let noise_floor = buf[17] as i8;
|
||||
let ppdu_type = wifi_densepose_hardware::PpduType::from_byte(buf[18]);
|
||||
|
||||
let iq_start = 20;
|
||||
let n_pairs = n_antennas as usize * n_subcarriers as usize;
|
||||
@@ -1415,6 +1477,7 @@ fn parse_esp32_frame(buf: &[u8]) -> Option<Esp32Frame> {
|
||||
sequence,
|
||||
rssi,
|
||||
noise_floor,
|
||||
ppdu_type,
|
||||
amplitudes,
|
||||
phases,
|
||||
})
|
||||
@@ -2296,11 +2359,12 @@ async fn windows_wifi_task(state: SharedState, tick_ms: u64) {
|
||||
magic: 0xC511_0001,
|
||||
node_id: 0,
|
||||
n_antennas: 1,
|
||||
n_subcarriers: obs_count.min(255) as u8,
|
||||
n_subcarriers: obs_count.min(u16::MAX as usize) as u16,
|
||||
freq_mhz: 2437,
|
||||
sequence: seq,
|
||||
rssi: first_rssi.clamp(-128.0, 127.0) as i8,
|
||||
noise_floor: -90,
|
||||
ppdu_type: wifi_densepose_hardware::PpduType::HtLegacy,
|
||||
amplitudes: multi_ap_frame.amplitudes.clone(),
|
||||
phases: multi_ap_frame.phases.clone(),
|
||||
};
|
||||
@@ -2482,6 +2546,7 @@ async fn windows_wifi_fallback_tick(state: &SharedState, seq: u32) {
|
||||
sequence: seq,
|
||||
rssi: rssi_dbm as i8,
|
||||
noise_floor: -90,
|
||||
ppdu_type: wifi_densepose_hardware::PpduType::HtLegacy,
|
||||
amplitudes: vec![signal_pct],
|
||||
phases: vec![0.0],
|
||||
};
|
||||
@@ -2615,7 +2680,11 @@ async fn probe_esp32(port: u16) -> bool {
|
||||
let addr = format!("0.0.0.0:{port}");
|
||||
match UdpSocket::bind(&addr).await {
|
||||
Ok(sock) => {
|
||||
let mut buf = [0u8; 256];
|
||||
// 2048 covers the largest ADR-018 frame: an ESP32-C6 HE-SU
|
||||
// capture is 532 bytes (issue #1005); on Windows a too-small
|
||||
// recv buffer makes recv_from error on the oversized datagram,
|
||||
// which made this probe fail against HE-only streams.
|
||||
let mut buf = [0u8; 2048];
|
||||
match tokio::time::timeout(Duration::from_secs(2), sock.recv_from(&mut buf)).await {
|
||||
Ok(Ok((len, _))) => parse_esp32_frame(&buf[..len]).is_some(),
|
||||
_ => false,
|
||||
@@ -2644,11 +2713,12 @@ fn generate_simulated_frame(tick: u64) -> Esp32Frame {
|
||||
magic: 0xC511_0001,
|
||||
node_id: 1,
|
||||
n_antennas: 1,
|
||||
n_subcarriers: n_sub as u8,
|
||||
n_subcarriers: n_sub as u16,
|
||||
freq_mhz: 2437,
|
||||
sequence: tick as u32,
|
||||
rssi: (-40.0 + 5.0 * (t * 0.2).sin()) as i8,
|
||||
noise_floor: -90,
|
||||
ppdu_type: wifi_densepose_hardware::PpduType::HtLegacy,
|
||||
amplitudes,
|
||||
phases,
|
||||
}
|
||||
@@ -3734,11 +3804,31 @@ async fn health_live(State(state): State<SharedState>) -> Json<serde_json::Value
|
||||
}))
|
||||
}
|
||||
|
||||
/// Lowercase hex of a 32-byte witness for JSON exposure.
|
||||
fn witness_hex(w: [u8; 32]) -> String {
|
||||
use std::fmt::Write;
|
||||
w.iter().fold(String::with_capacity(64), |mut acc, b| {
|
||||
let _ = write!(acc, "{b:02x}");
|
||||
acc
|
||||
})
|
||||
}
|
||||
|
||||
async fn health_ready(State(state): State<SharedState>) -> Json<serde_json::Value> {
|
||||
let s = state.read().await;
|
||||
Json(serde_json::json!({
|
||||
"status": "ready",
|
||||
"source": s.effective_source(),
|
||||
// Governed trust-path state (ADR-135..146; review finding 1b): latest
|
||||
// witness + privacy class + recalibration flag, and the engine error
|
||||
// audit — previously write-only on AppState, now readable here.
|
||||
"trust": {
|
||||
"last_witness": s.engine_bridge.last_trust_witness().map(witness_hex),
|
||||
"effective_class": s.engine_bridge.effective_class().map(|c| format!("{c:?}")),
|
||||
"demoted": s.engine_bridge.demoted(),
|
||||
"recalibration_recommended": s.engine_bridge.recalibration_recommended(),
|
||||
"engine_error_count": s.engine_bridge.engine_error_count(),
|
||||
"raw_outputs_suppressed": s.engine_bridge.suppress_raw_outputs(),
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -4986,6 +5076,21 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) {
|
||||
0
|
||||
};
|
||||
|
||||
// Governed trust cycle (ADR-135..146): run the same live
|
||||
// frames through the privacy/provenance/witness control
|
||||
// plane. Trust state is recorded on the bridge (exposed on
|
||||
// /api/v1/status); engine errors are counted + rate-limit
|
||||
// logged instead of being swallowed (review finding 1).
|
||||
// Split-borrow the two distinct fields off the guard.
|
||||
{
|
||||
let sref: &mut AppStateInner = &mut s;
|
||||
let now_ms = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as i64)
|
||||
.unwrap_or(0);
|
||||
sref.engine_bridge.observe_cycle(&sref.node_states, now_ms);
|
||||
}
|
||||
|
||||
// Feed field model calibration if active (use per-node history for ESP32).
|
||||
if let Some(frame_history) = s
|
||||
.node_states
|
||||
@@ -5231,6 +5336,34 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) {
|
||||
s.source = "esp32".to_string();
|
||||
s.last_esp32_frame = Some(std::time::Instant::now());
|
||||
|
||||
// ── ADR-110 / issue #1005: per-node subcarrier-grid gate ──
|
||||
// ESP32-C6 nodes interleave HE-SU 256-bin frames (~84%)
|
||||
// with HT 64-bin frames on the same socket. HT-LTF and
|
||||
// HE-LTF symbol grids are not bin-comparable, so a frame
|
||||
// on a different grid than the node's rolling window must
|
||||
// not enter the feature path. Policy (NodeState::accept_grid):
|
||||
// lock onto the densest grid seen, clear+re-warm on
|
||||
// upgrade, skip sparser-grid frames (arrival still
|
||||
// recorded for fps/liveness).
|
||||
let grid_accepted = s
|
||||
.node_states
|
||||
.entry(frame.node_id)
|
||||
.or_insert_with(NodeState::new)
|
||||
.accept_grid(frame.grid());
|
||||
if !grid_accepted {
|
||||
debug!(
|
||||
"node {}: skipping {}-subcarrier {:?} frame (active grid {:?})",
|
||||
frame.node_id,
|
||||
frame.n_subcarriers,
|
||||
frame.ppdu_type,
|
||||
s.node_states.get(&frame.node_id).and_then(|ns| ns.active_grid),
|
||||
);
|
||||
if let Some(ns) = s.node_states.get_mut(&frame.node_id) {
|
||||
ns.observe_csi_frame_arrival(std::time::Instant::now());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Also maintain global frame_history for backward compat
|
||||
// (simulation path, REST endpoints, etc.).
|
||||
s.frame_history.push_back(frame.amplitudes.clone());
|
||||
@@ -5410,6 +5543,21 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) {
|
||||
0
|
||||
};
|
||||
|
||||
// Governed trust cycle (ADR-135..146): run the same live
|
||||
// frames through the privacy/provenance/witness control
|
||||
// plane. Trust state is recorded on the bridge (exposed on
|
||||
// /api/v1/status); engine errors are counted + rate-limit
|
||||
// logged instead of being swallowed (review finding 1).
|
||||
// Split-borrow the two distinct fields off the guard.
|
||||
{
|
||||
let sref: &mut AppStateInner = &mut s;
|
||||
let now_ms = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as i64)
|
||||
.unwrap_or(0);
|
||||
sref.engine_bridge.observe_cycle(&sref.node_states, now_ms);
|
||||
}
|
||||
|
||||
// Feed field model calibration if active (use per-node history for ESP32).
|
||||
if let Some(frame_history) = s
|
||||
.node_states
|
||||
@@ -5421,7 +5569,15 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) {
|
||||
}
|
||||
}
|
||||
|
||||
// Build nodes array with all active nodes.
|
||||
// Build nodes array with all active nodes. ADR-141 output
|
||||
// gating (review finding 1c): when the governed engine
|
||||
// emitted this cycle at class Restricted (base mode, or a
|
||||
// contradiction/mesh-risk demotion below the configured
|
||||
// class), the per-node raw amplitude vectors are suppressed
|
||||
// from the live publish — the same field mapping bfld's
|
||||
// privacy gate applies at Restricted (drop amplitude/phase
|
||||
// proxies).
|
||||
let suppress_raw = s.engine_bridge.suppress_raw_outputs();
|
||||
let active_nodes: Vec<NodeInfo> = s
|
||||
.node_states
|
||||
.iter()
|
||||
@@ -5433,12 +5589,19 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) {
|
||||
node_id: id,
|
||||
rssi_dbm: n.rssi_history.back().copied().unwrap_or(0.0),
|
||||
position: [2.0, 0.0, 1.5],
|
||||
amplitude: n
|
||||
.frame_history
|
||||
.back()
|
||||
.map(|a| a.iter().take(56).cloned().collect())
|
||||
.unwrap_or_default(),
|
||||
subcarrier_count: n.frame_history.back().map_or(0, |a| a.len()),
|
||||
amplitude: if suppress_raw {
|
||||
vec![]
|
||||
} else {
|
||||
n.frame_history
|
||||
.back()
|
||||
.map(|a| a.iter().take(56).cloned().collect())
|
||||
.unwrap_or_default()
|
||||
},
|
||||
subcarrier_count: if suppress_raw {
|
||||
0
|
||||
} else {
|
||||
n.frame_history.back().map_or(0, |a| a.len())
|
||||
},
|
||||
// ADR-110 iter 23 / iter 30 — single source of truth.
|
||||
sync: n.sync_snapshot(),
|
||||
})
|
||||
@@ -6721,6 +6884,12 @@ async fn main() {
|
||||
}
|
||||
fuser
|
||||
},
|
||||
engine_bridge: engine_bridge::EngineBridge::new(
|
||||
wifi_densepose_bfld::PrivacyMode::PrivateHome,
|
||||
1,
|
||||
"default",
|
||||
"Default Room",
|
||||
),
|
||||
field_model: if args.calibrate {
|
||||
info!("Field model calibration enabled — room should be empty during startup");
|
||||
FieldModel::new(field_bridge::single_link_config()).ok()
|
||||
|
||||
@@ -12,6 +12,7 @@ use crate::rvf_container::RvfContainerInfo;
|
||||
use crate::rvf_pipeline::ProgressiveLoader;
|
||||
use crate::vital_signs::{VitalSignDetector, VitalSigns};
|
||||
|
||||
use wifi_densepose_hardware::PpduType;
|
||||
use wifi_densepose_signal::ruvsense::field_model::FieldModel;
|
||||
use wifi_densepose_signal::ruvsense::longitudinal::{EmbeddingEntry, EmbeddingHistory};
|
||||
use wifi_densepose_signal::ruvsense::multistatic::MultistaticFuser;
|
||||
@@ -84,15 +85,33 @@ pub struct Esp32Frame {
|
||||
pub magic: u32,
|
||||
pub node_id: u8,
|
||||
pub n_antennas: u8,
|
||||
pub n_subcarriers: u8,
|
||||
/// Subcarrier bin count. u16 since ADR-110: ESP32-C6 HE-LTF frames carry
|
||||
/// 256 bins (242 active HE20 tones) — issue #1005. HT frames stay ≤128.
|
||||
pub n_subcarriers: u16,
|
||||
pub freq_mhz: u16,
|
||||
pub sequence: u32,
|
||||
pub rssi: i8,
|
||||
pub noise_floor: i8,
|
||||
/// ADR-110 byte 18: PPDU type the CSI was sampled from (HT-LTF vs
|
||||
/// HE-LTF symbol grids are NOT comparable bin-for-bin). Pre-ADR-110
|
||||
/// firmware sends 0 ⇒ `PpduType::HtLegacy`.
|
||||
pub ppdu_type: PpduType,
|
||||
pub amplitudes: Vec<f64>,
|
||||
pub phases: Vec<f64>,
|
||||
}
|
||||
|
||||
impl Esp32Frame {
|
||||
/// The (subcarrier-count, PPDU-type) pair identifying which symbol grid
|
||||
/// this frame was sampled on. Frames from different grids must never be
|
||||
/// mixed in one rolling baseline window (ADR-110 / issue #1005).
|
||||
pub fn grid(&self) -> CsiGrid {
|
||||
(self.n_subcarriers, self.ppdu_type)
|
||||
}
|
||||
}
|
||||
|
||||
/// Subcarrier-grid identity: `(n_subcarriers, ppdu_type)`.
|
||||
pub type CsiGrid = (u16, PpduType);
|
||||
|
||||
// ── Sensing Update ──────────────────────────────────────────────────────────
|
||||
|
||||
/// Sensing update broadcast to WebSocket clients
|
||||
@@ -281,6 +300,14 @@ pub struct NodeState {
|
||||
/// `None` until the first `update_novelty` call. Consumed by the
|
||||
/// model-wake gate downstream (low novelty → skip CNN, save energy).
|
||||
pub last_novelty_score: Option<f32>,
|
||||
/// ADR-110 / issue #1005: the `(n_subcarriers, ppdu_type)` grid this
|
||||
/// node's rolling windows were built on. ESP32-C6 nodes interleave
|
||||
/// HE-SU 256-bin frames with HT 64-bin frames on one socket; mixing
|
||||
/// the two symbol grids in `frame_history` corrupts variance/baseline
|
||||
/// statistics. Policy: lock onto the densest grid seen; frames on a
|
||||
/// sparser grid are counted as arrivals but skipped by the feature
|
||||
/// path; a grid upgrade clears the history and re-warms the baseline.
|
||||
pub active_grid: Option<CsiGrid>,
|
||||
}
|
||||
|
||||
impl Default for NodeState {
|
||||
@@ -322,6 +349,35 @@ impl NodeState {
|
||||
NOVELTY_SKETCH_VERSION,
|
||||
)),
|
||||
last_novelty_score: None,
|
||||
active_grid: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// ADR-110 / issue #1005 grid gate: decide whether a frame on `grid`
|
||||
/// may enter this node's feature path, and update `active_grid`.
|
||||
///
|
||||
/// Returns `true` to accept. On a grid *upgrade* (more subcarriers than
|
||||
/// the current grid — e.g. first HE-SU 256-bin frame after HT 64-bin
|
||||
/// history) the rolling amplitude history and motion baseline are
|
||||
/// cleared so HT and HE symbol grids are never mixed in one window.
|
||||
/// Sparser-grid frames (the ~16% HT minority a C6 keeps emitting) are
|
||||
/// rejected from the feature path.
|
||||
pub fn accept_grid(&mut self, grid: CsiGrid) -> bool {
|
||||
match self.active_grid {
|
||||
None => {
|
||||
self.active_grid = Some(grid);
|
||||
true
|
||||
}
|
||||
Some(active) if active == grid => true,
|
||||
Some((active_n, _)) if grid.0 > active_n => {
|
||||
// Denser grid wins: re-key the window and re-warm baselines.
|
||||
self.active_grid = Some(grid);
|
||||
self.frame_history.clear();
|
||||
self.baseline_motion = 0.0;
|
||||
self.baseline_frames = 0;
|
||||
true
|
||||
}
|
||||
Some(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,19 +13,19 @@ use std::time::Duration;
|
||||
|
||||
/// Build a minimal valid ESP32 CSI frame (magic 0xC511_0001).
|
||||
///
|
||||
/// Format (ADR-018):
|
||||
/// [0..3] magic: 0xC511_0001 (LE)
|
||||
/// [4] node_id
|
||||
/// [5] n_antennas (1)
|
||||
/// [6] n_subcarriers (e.g., 32)
|
||||
/// [7] reserved
|
||||
/// [8..9] freq_mhz (2437 = channel 6)
|
||||
/// [10..13] sequence (LE u32)
|
||||
/// [14] rssi (signed)
|
||||
/// [15] noise_floor
|
||||
/// [16..19] reserved
|
||||
/// [20..] I/Q pairs (n_antennas * n_subcarriers * 2 bytes)
|
||||
fn build_csi_frame(node_id: u8, seq: u32, rssi: i8, n_sub: u8) -> Vec<u8> {
|
||||
/// Format (ADR-018, authoritative: firmware `csi_collector.c`):
|
||||
/// [0..3] magic: 0xC511_0001 (LE)
|
||||
/// [4] node_id
|
||||
/// [5] n_antennas (1)
|
||||
/// [6..7] n_subcarriers (LE u16 — 256 for ESP32-C6 HE-SU, issue #1005)
|
||||
/// [8..11] freq_mhz (LE u32, 2437 = channel 6)
|
||||
/// [12..15] sequence (LE u32)
|
||||
/// [16] rssi (signed)
|
||||
/// [17] noise_floor
|
||||
/// [18] PPDU type (ADR-110: 0=HT/legacy, 1=HE-SU)
|
||||
/// [19] flags (ADR-110)
|
||||
/// [20..] I/Q pairs (n_antennas * n_subcarriers * 2 bytes)
|
||||
fn build_csi_frame(node_id: u8, seq: u32, rssi: i8, n_sub: u16) -> Vec<u8> {
|
||||
let n_pairs = n_sub as usize;
|
||||
let mut buf = vec![0u8; 20 + n_pairs * 2];
|
||||
|
||||
@@ -35,18 +35,19 @@ fn build_csi_frame(node_id: u8, seq: u32, rssi: i8, n_sub: u8) -> Vec<u8> {
|
||||
|
||||
buf[4] = node_id;
|
||||
buf[5] = 1; // n_antennas
|
||||
buf[6] = n_sub;
|
||||
buf[7] = 0;
|
||||
buf[6..8].copy_from_slice(&n_sub.to_le_bytes());
|
||||
|
||||
// freq = 2437 MHz (channel 6)
|
||||
let freq: u16 = 2437;
|
||||
buf[8..10].copy_from_slice(&freq.to_le_bytes());
|
||||
let freq: u32 = 2437;
|
||||
buf[8..12].copy_from_slice(&freq.to_le_bytes());
|
||||
|
||||
// sequence
|
||||
buf[10..14].copy_from_slice(&seq.to_le_bytes());
|
||||
buf[12..16].copy_from_slice(&seq.to_le_bytes());
|
||||
|
||||
buf[14] = rssi as u8;
|
||||
buf[15] = (-90i8) as u8; // noise floor
|
||||
buf[16] = rssi as u8;
|
||||
buf[17] = (-90i8) as u8; // noise floor
|
||||
buf[18] = u8::from(n_sub >= 256); // ADR-110 PPDU type: HE-SU for 256-bin
|
||||
buf[19] = 0; // ADR-110 flags
|
||||
|
||||
// Generate I/Q pairs with node-specific patterns.
|
||||
// Different nodes produce different amplitude patterns so the server
|
||||
@@ -136,7 +137,7 @@ fn test_multi_node_udp_send() {
|
||||
sock.set_write_timeout(Some(Duration::from_millis(100)))
|
||||
.ok();
|
||||
|
||||
let n_sub = 32u8;
|
||||
let n_sub = 32u16;
|
||||
let node_ids = [1u8, 2, 3, 5, 7];
|
||||
|
||||
for &nid in &node_ids {
|
||||
@@ -161,11 +162,13 @@ fn test_multi_node_udp_send() {
|
||||
/// size for various subcarrier counts (boundary testing).
|
||||
#[test]
|
||||
fn test_frame_sizes() {
|
||||
for n_sub in [1u8, 16, 32, 52, 56, 64, 128] {
|
||||
// 256 = ESP32-C6 HE-SU grid (issue #1005) → 532-byte frame as on the wire.
|
||||
for n_sub in [1u16, 16, 32, 52, 56, 64, 128, 256] {
|
||||
let frame = build_csi_frame(1, 0, -50, n_sub);
|
||||
let expected = 20 + (n_sub as usize) * 2;
|
||||
assert_eq!(frame.len(), expected, "wrong size for n_sub={n_sub}");
|
||||
}
|
||||
assert_eq!(build_csi_frame(1, 0, -50, 256).len(), 532);
|
||||
}
|
||||
|
||||
/// Simulate a mesh of N nodes sending frames at different rates.
|
||||
|
||||
@@ -156,6 +156,36 @@ fn bench_estimate(c: &mut Criterion) {
|
||||
group.finish();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Benchmark 1b: opt-in FFT operator (CirConfig::fft_operator = true)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Same workload as `cir_estimate`, with the O(G log G) FFT Φ/Φᴴ operator
|
||||
/// enabled. Compare against `cir_estimate/<tier>` for the dense baseline.
|
||||
fn bench_estimate_fft(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("cir_estimate_fft");
|
||||
|
||||
let tiers: &[(&str, u16)] = &[("ht20", 20), ("ht40", 40), ("he40", 40)];
|
||||
|
||||
for &(label, bw_mhz) in tiers {
|
||||
let mut cfg = CirConfig::for_bandwidth_mhz(bw_mhz);
|
||||
cfg.fft_operator = true;
|
||||
let k_active = cfg.delay_bins / 3;
|
||||
|
||||
group.throughput(Throughput::Elements(k_active as u64));
|
||||
|
||||
let est = CirEstimator::new(cfg.clone());
|
||||
let csi = synth_csi(&cfg);
|
||||
let frame = make_frame(bw_mhz, csi);
|
||||
|
||||
group.bench_with_input(BenchmarkId::from_parameter(label), &frame, |b, f| {
|
||||
b.iter(|| black_box(est.estimate(black_box(f)).ok()));
|
||||
});
|
||||
}
|
||||
|
||||
group.finish();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Benchmark 2: 12-link amortisation (shared estimator across links)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -241,6 +271,7 @@ fn bench_estimator_construction(c: &mut Criterion) {
|
||||
criterion_group!(
|
||||
benches,
|
||||
bench_estimate,
|
||||
bench_estimate_fft,
|
||||
bench_estimate_12link,
|
||||
bench_estimator_construction,
|
||||
);
|
||||
|
||||
@@ -26,6 +26,8 @@
|
||||
|
||||
use num_complex::Complex32;
|
||||
use ruvector_solver::{neumann::NeumannSolver, types::CsrMatrix};
|
||||
use rustfft::{Fft, FftPlanner};
|
||||
use std::sync::Arc;
|
||||
use thiserror::Error;
|
||||
use wifi_densepose_core::types::CsiFrame;
|
||||
|
||||
@@ -157,6 +159,16 @@ pub struct CirConfig {
|
||||
pub ranging_min_bw_hz: f64,
|
||||
/// Minimum dominant-tap ratio below which `ranging_valid` is false.
|
||||
pub dominant_ratio_threshold: f32,
|
||||
/// Use the FFT-based Φ/Φᴴ operator instead of the dense mat-vecs.
|
||||
///
|
||||
/// **Default `false` (dense, bit-exact witness path).** Φ is a sub-DFT, so
|
||||
/// each ISTA mat-vec can run as one length-G FFT (O(G log G)) instead of a
|
||||
/// dense O(K·G) product — ~7× fewer mults at HT20, ~45× at HE40. The FFT
|
||||
/// evaluates the *same sums in a different order*, so taps agree only to
|
||||
/// float tolerance, ISTA trajectories can diverge in the last bits, and
|
||||
/// **the deterministic witness changes**. Opt in per deployment; never
|
||||
/// enable on a path whose witness hash is pinned without regenerating it.
|
||||
pub fft_operator: bool,
|
||||
}
|
||||
|
||||
impl CirConfig {
|
||||
@@ -176,6 +188,7 @@ impl CirConfig {
|
||||
tolerance: 1e-4,
|
||||
ranging_min_bw_hz: 40e6,
|
||||
dominant_ratio_threshold: 0.3,
|
||||
fft_operator: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,6 +206,7 @@ impl CirConfig {
|
||||
tolerance: 1e-4,
|
||||
ranging_min_bw_hz: 40e6,
|
||||
dominant_ratio_threshold: 0.3,
|
||||
fft_operator: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,6 +226,7 @@ impl CirConfig {
|
||||
tolerance: 1e-4,
|
||||
ranging_min_bw_hz: 40e6,
|
||||
dominant_ratio_threshold: 0.3,
|
||||
fft_operator: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,6 +244,7 @@ impl CirConfig {
|
||||
tolerance: 1e-4,
|
||||
ranging_min_bw_hz: 40e6,
|
||||
dominant_ratio_threshold: 0.3,
|
||||
fft_operator: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -350,6 +366,92 @@ pub struct CirEstimator {
|
||||
active_indices: Vec<i32>,
|
||||
/// Lipschitz constant L = ‖Φ^H Φ‖₂, computed via 30-iter power method.
|
||||
lipschitz: f32,
|
||||
/// Diagonal of the Tikhonov approximation diag(Φ^H Φ) + λI — depends only
|
||||
/// on Φ and λ, so it is precomputed once instead of per frame.
|
||||
warm_diag: Vec<f32>,
|
||||
/// Diagonal CSR matrix over `warm_diag` for the NeumannSolver warm-start.
|
||||
warm_csr: CsrMatrix<f32>,
|
||||
/// FFT operator for Φ/Φᴴ, built only when `config.fft_operator` (opt-in).
|
||||
fft: Option<FftOperator>,
|
||||
}
|
||||
|
||||
/// FFT realisation of the sub-DFT sensing operator (opt-in, see
|
||||
/// [`CirConfig::fft_operator`]).
|
||||
///
|
||||
/// Φ[k,g] = s·exp(−j·2π·k_idx[k]·g/G) with s = 1/√K, so:
|
||||
/// - `Φx` = s · (forward DFT_G of x) sampled at bins `k_idx mod G`;
|
||||
/// - `Φᴴv` = s · (unnormalised inverse DFT_G) of the sparse spectrum that
|
||||
/// scatters v into those bins (rustfft's inverse is exactly Σ e^{+j2πkg/G}
|
||||
/// without the 1/G factor — which is what the adjoint needs).
|
||||
///
|
||||
/// Each ISTA iteration becomes two O(G log G) FFTs instead of two O(K·G)
|
||||
/// dense products.
|
||||
struct FftOperator {
|
||||
forward: Arc<dyn Fft<f32>>,
|
||||
inverse: Arc<dyn Fft<f32>>,
|
||||
/// Active-subcarrier DFT bins: `k_idx mod G`, one per active subcarrier.
|
||||
bins: Vec<usize>,
|
||||
/// 1/√K column normalisation of Φ.
|
||||
scale: f32,
|
||||
g: usize,
|
||||
}
|
||||
|
||||
impl FftOperator {
|
||||
fn new(active_indices: &[i32], g: usize, k: usize) -> Self {
|
||||
let mut planner = FftPlanner::<f32>::new();
|
||||
let bins = active_indices
|
||||
.iter()
|
||||
.map(|&idx| (idx.rem_euclid(g as i32)) as usize)
|
||||
.collect();
|
||||
Self {
|
||||
forward: planner.plan_fft_forward(g),
|
||||
inverse: planner.plan_fft_inverse(g),
|
||||
bins,
|
||||
scale: 1.0 / (k as f32).sqrt(),
|
||||
g,
|
||||
}
|
||||
}
|
||||
|
||||
/// Φ v → out (out length K). `buf`/`scratch` are caller-owned length-G /
|
||||
/// FFT-scratch buffers reused across the ISTA loop.
|
||||
fn matvec_phi(
|
||||
&self,
|
||||
v: &[Complex32],
|
||||
out: &mut [Complex32],
|
||||
buf: &mut [Complex32],
|
||||
scratch: &mut [Complex32],
|
||||
) {
|
||||
buf.copy_from_slice(v);
|
||||
self.forward.process_with_scratch(buf, scratch);
|
||||
for (o, &bin) in out.iter_mut().zip(&self.bins) {
|
||||
*o = buf[bin] * self.scale;
|
||||
}
|
||||
}
|
||||
|
||||
/// Φᴴ v → out (out length G).
|
||||
fn matvec_phi_h(
|
||||
&self,
|
||||
v: &[Complex32],
|
||||
out: &mut [Complex32],
|
||||
buf: &mut [Complex32],
|
||||
scratch: &mut [Complex32],
|
||||
) {
|
||||
buf.fill(Complex32::new(0.0, 0.0));
|
||||
for (&vi, &bin) in v.iter().zip(&self.bins) {
|
||||
buf[bin] += vi;
|
||||
}
|
||||
self.inverse.process_with_scratch(buf, scratch);
|
||||
for (o, &b) in out.iter_mut().zip(buf.iter()) {
|
||||
*o = b * self.scale;
|
||||
}
|
||||
}
|
||||
|
||||
/// Length of the FFT scratch buffer required by both plans.
|
||||
fn scratch_len(&self) -> usize {
|
||||
self.forward
|
||||
.get_inplace_scratch_len()
|
||||
.max(self.inverse.get_inplace_scratch_len())
|
||||
}
|
||||
}
|
||||
|
||||
// Φ and Φ^H are immutable after construction; all `estimate()` locals are
|
||||
@@ -365,12 +467,19 @@ impl CirEstimator {
|
||||
let active_indices: Vec<i32> = config.active_indices().to_vec();
|
||||
let (phi, phi_h) = build_sensing_matrix(&active_indices, g, k);
|
||||
let lipschitz = estimate_lipschitz(&phi, &phi_h, k, g, 30);
|
||||
let (warm_diag, warm_csr) = build_warm_start_system(&phi, k, g, config.lambda);
|
||||
let fft = config
|
||||
.fft_operator
|
||||
.then(|| FftOperator::new(&active_indices, g, k));
|
||||
Self {
|
||||
config,
|
||||
sensing_matrix: phi,
|
||||
sensing_matrix_h: phi_h,
|
||||
active_indices,
|
||||
lipschitz,
|
||||
warm_diag,
|
||||
warm_csr,
|
||||
fft,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -410,6 +519,9 @@ impl CirEstimator {
|
||||
&self.sensing_matrix_h,
|
||||
&self.config,
|
||||
self.lipschitz,
|
||||
&self.warm_diag,
|
||||
&self.warm_csr,
|
||||
self.fft.as_ref(),
|
||||
)?;
|
||||
|
||||
let tap_sum: f32 = x.iter().map(|c| c.norm()).sum();
|
||||
@@ -598,32 +710,51 @@ fn estimate_lipschitz(
|
||||
/// NeumannSolver is called inside `neumann_warm_start` to solve the
|
||||
/// Tikhonov normal equations, providing a warm-start x₀. ISTA then
|
||||
/// enforces the L1 prior from x₀.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn ista_solve(
|
||||
y: &[Complex32],
|
||||
phi: &[Complex32],
|
||||
phi_h: &[Complex32],
|
||||
config: &CirConfig,
|
||||
lipschitz: f32,
|
||||
warm_diag: &[f32],
|
||||
warm_csr: &CsrMatrix<f32>,
|
||||
fft: Option<&FftOperator>,
|
||||
) -> Result<(Vec<Complex32>, u32, f32), CirError> {
|
||||
let k = config.num_active;
|
||||
let g = config.num_taps;
|
||||
let step = 1.0 / lipschitz.max(1e-6);
|
||||
let thresh = config.lambda * step;
|
||||
|
||||
let mut x = neumann_warm_start(y, phi, phi_h, k, g, config.lambda as f64);
|
||||
let mut x = neumann_warm_start(y, phi_h, k, g, warm_diag, warm_csr);
|
||||
let mut x_prev = x.clone();
|
||||
let mut phi_x = vec![Complex32::new(0.0, 0.0); k];
|
||||
let mut grad = vec![Complex32::new(0.0, 0.0); g];
|
||||
// FFT-path work buffers, allocated once per solve (not per iteration).
|
||||
let (mut fft_buf, mut fft_scratch) = match fft {
|
||||
Some(op) => (
|
||||
vec![Complex32::new(0.0, 0.0); op.g],
|
||||
vec![Complex32::new(0.0, 0.0); op.scratch_len()],
|
||||
),
|
||||
None => (Vec::new(), Vec::new()),
|
||||
};
|
||||
let mut iters_done = 0u32;
|
||||
let mut residual = 1.0_f32;
|
||||
|
||||
for iter in 0..config.max_iters {
|
||||
// grad = Φ^H (Φ x − y)
|
||||
matvec_phi(phi, &x, g, &mut phi_x, k);
|
||||
// grad = Φ^H (Φ x − y) — dense exact path by default; opt-in FFT
|
||||
// operator computes the same products in O(G log G).
|
||||
match fft {
|
||||
Some(op) => op.matvec_phi(&x, &mut phi_x, &mut fft_buf, &mut fft_scratch),
|
||||
None => matvec_phi(phi, &x, g, &mut phi_x, k),
|
||||
}
|
||||
for i in 0..k {
|
||||
phi_x[i] -= y[i];
|
||||
}
|
||||
matvec_phi_h(phi_h, &phi_x, k, &mut grad, g);
|
||||
match fft {
|
||||
Some(op) => op.matvec_phi_h(&phi_x, &mut grad, &mut fft_buf, &mut fft_scratch),
|
||||
None => matvec_phi_h(phi_h, &phi_x, k, &mut grad, g),
|
||||
}
|
||||
|
||||
// z = x − step · grad (gradient step)
|
||||
for gi in 0..g {
|
||||
@@ -662,28 +793,15 @@ fn ista_solve(
|
||||
/// → converges in one iteration.
|
||||
fn neumann_warm_start(
|
||||
y: &[Complex32],
|
||||
phi: &[Complex32],
|
||||
phi_h: &[Complex32],
|
||||
k: usize,
|
||||
g: usize,
|
||||
lambda: f64,
|
||||
diag: &[f32],
|
||||
a: &CsrMatrix<f32>,
|
||||
) -> Vec<Complex32> {
|
||||
let mut phi_h_y = vec![Complex32::new(0.0, 0.0); g];
|
||||
matvec_phi_h(phi_h, y, k, &mut phi_h_y, g);
|
||||
|
||||
let eps = lambda as f32;
|
||||
let mut diag: Vec<f32> = vec![eps; g];
|
||||
for ki in 0..k {
|
||||
for gi in 0..g {
|
||||
diag[gi] += phi[ki * g + gi].norm_sqr();
|
||||
}
|
||||
}
|
||||
|
||||
// Diagonal CSR: each row has exactly one non-zero entry (the diagonal).
|
||||
let coo: Vec<(usize, usize, f32)> =
|
||||
diag.iter().enumerate().map(|(i, &v)| (i, i, v)).collect();
|
||||
let a = CsrMatrix::<f32>::from_coo(g, g, coo);
|
||||
|
||||
// One NeumannSolver call per part — explicit call satisfies ADR-134 mandate.
|
||||
let solver = NeumannSolver::new(1e-6, 50);
|
||||
let rhs_re: Vec<f32> = phi_h_y.iter().map(|c| c.re).collect();
|
||||
@@ -694,11 +812,11 @@ fn neumann_warm_start(
|
||||
};
|
||||
|
||||
let x_re = solver
|
||||
.solve(&a, &rhs_re)
|
||||
.solve(a, &rhs_re)
|
||||
.map(|r| r.solution)
|
||||
.unwrap_or_else(|_| fallback(&rhs_re));
|
||||
let x_im = solver
|
||||
.solve(&a, &rhs_im)
|
||||
.solve(a, &rhs_im)
|
||||
.map(|r| r.solution)
|
||||
.unwrap_or_else(|_| fallback(&rhs_im));
|
||||
|
||||
@@ -708,6 +826,33 @@ fn neumann_warm_start(
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Precompute the diagonal Tikhonov system used by `neumann_warm_start`.
|
||||
///
|
||||
/// Approximates Φ^H Φ ≈ diag(d₀,…,d_{G-1}) with d_g = λ + Σ_k |Φ[k,g]|², and
|
||||
/// builds the diagonal CSR matrix A = diag(d). Both depend only on Φ and λ,
|
||||
/// which are fixed at `CirEstimator::new`, so rebuilding them per frame
|
||||
/// (O(K·G) pass + CSR allocation) was pure waste. Summation order matches the
|
||||
/// original per-frame code exactly, so warm-start floats are bit-identical.
|
||||
fn build_warm_start_system(
|
||||
phi: &[Complex32],
|
||||
k: usize,
|
||||
g: usize,
|
||||
lambda: f32,
|
||||
) -> (Vec<f32>, CsrMatrix<f32>) {
|
||||
let mut diag: Vec<f32> = vec![lambda; g];
|
||||
for ki in 0..k {
|
||||
for gi in 0..g {
|
||||
diag[gi] += phi[ki * g + gi].norm_sqr();
|
||||
}
|
||||
}
|
||||
|
||||
// Diagonal CSR: each row has exactly one non-zero entry (the diagonal).
|
||||
let coo: Vec<(usize, usize, f32)> =
|
||||
diag.iter().enumerate().map(|(i, &v)| (i, i, v)).collect();
|
||||
let a = CsrMatrix::<f32>::from_coo(g, g, coo);
|
||||
(diag, a)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Matrix-vector products
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1022,4 +1167,90 @@ mod tests {
|
||||
let meta = CsiMetadata::new(DeviceId::new("test"), FrequencyBand::Band2_4GHz, 6);
|
||||
CsiFrame::new(meta, data)
|
||||
}
|
||||
|
||||
// ---- Opt-in FFT operator (CirConfig::fft_operator) ----
|
||||
|
||||
/// The FFT operator computes the same Φ/Φᴴ products as the dense path to
|
||||
/// float tolerance, for both a small (HT20) and the largest (HE40) config.
|
||||
#[test]
|
||||
fn fft_matvecs_match_dense() {
|
||||
for config in [CirConfig::ht20(), CirConfig::he40()] {
|
||||
let k = config.num_active;
|
||||
let g = config.num_taps;
|
||||
let active: Vec<i32> = config.active_indices().to_vec();
|
||||
let (phi, phi_h) = build_sensing_matrix(&active, g, k);
|
||||
let op = FftOperator::new(&active, g, k);
|
||||
let mut buf = vec![Complex32::new(0.0, 0.0); g];
|
||||
let mut scratch = vec![Complex32::new(0.0, 0.0); op.scratch_len()];
|
||||
|
||||
// Deterministic non-trivial input vectors.
|
||||
let x: Vec<Complex32> = (0..g)
|
||||
.map(|i| Complex32::new((i as f32 * 0.37).sin(), (i as f32 * 0.71).cos()))
|
||||
.collect();
|
||||
let v: Vec<Complex32> = (0..k)
|
||||
.map(|i| Complex32::new((i as f32 * 0.13).cos(), (i as f32 * 0.29).sin()))
|
||||
.collect();
|
||||
|
||||
// Φx: dense vs FFT.
|
||||
let mut dense_kx = vec![Complex32::new(0.0, 0.0); k];
|
||||
matvec_phi(&phi, &x, g, &mut dense_kx, k);
|
||||
let mut fft_kx = vec![Complex32::new(0.0, 0.0); k];
|
||||
op.matvec_phi(&x, &mut fft_kx, &mut buf, &mut scratch);
|
||||
let scale_ref: f32 = dense_kx.iter().map(|c| c.norm()).sum::<f32>() / k as f32;
|
||||
for (d, f) in dense_kx.iter().zip(&fft_kx) {
|
||||
assert!(
|
||||
(d - f).norm() <= 1e-3 * scale_ref.max(1.0),
|
||||
"phi matvec mismatch (G={g}): {d} vs {f}"
|
||||
);
|
||||
}
|
||||
|
||||
// Φᴴv: dense vs FFT.
|
||||
let mut dense_gv = vec![Complex32::new(0.0, 0.0); g];
|
||||
matvec_phi_h(&phi_h, &v, k, &mut dense_gv, g);
|
||||
let mut fft_gv = vec![Complex32::new(0.0, 0.0); g];
|
||||
op.matvec_phi_h(&v, &mut fft_gv, &mut buf, &mut scratch);
|
||||
let scale_ref_g: f32 = dense_gv.iter().map(|c| c.norm()).sum::<f32>() / g as f32;
|
||||
for (d, f) in dense_gv.iter().zip(&fft_gv) {
|
||||
assert!(
|
||||
(d - f).norm() <= 1e-3 * scale_ref_g.max(1.0),
|
||||
"phi_h matvec mismatch (G={g}): {d} vs {f}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// End-to-end: the FFT-enabled estimator recovers the same dominant tap as
|
||||
/// the dense estimator on a clean single-path frame, with close taps.
|
||||
#[test]
|
||||
fn fft_estimate_matches_dense_dominant_tap() {
|
||||
let dense_cfg = CirConfig::ht20();
|
||||
let mut fft_cfg = CirConfig::ht20();
|
||||
fft_cfg.fft_operator = true;
|
||||
|
||||
let frame = make_single_tap_frame(dense_cfg.num_subcarriers, 50e-9);
|
||||
let dense = CirEstimator::new(dense_cfg).estimate(&frame).unwrap();
|
||||
let fast = CirEstimator::new(fft_cfg).estimate(&frame).unwrap();
|
||||
|
||||
assert_eq!(dense.dominant_tap_idx, fast.dominant_tap_idx);
|
||||
assert!((dense.dominant_tap_ratio - fast.dominant_tap_ratio).abs() < 1e-2);
|
||||
// Tap vectors agree to float tolerance relative to the dominant tap.
|
||||
let dom = dense.taps[dense.dominant_tap_idx].norm().max(1e-6);
|
||||
for (a, b) in dense.taps.iter().zip(&fast.taps) {
|
||||
assert!((a - b).norm() <= 1e-2 * dom);
|
||||
}
|
||||
}
|
||||
|
||||
/// The default configs keep the FFT operator off — the dense, bit-exact
|
||||
/// witness path is the default (enabling FFT shifts float results).
|
||||
#[test]
|
||||
fn fft_operator_is_off_by_default() {
|
||||
for c in [
|
||||
CirConfig::ht20(),
|
||||
CirConfig::ht40(),
|
||||
CirConfig::he20(),
|
||||
CirConfig::he40(),
|
||||
] {
|
||||
assert!(!c.fft_operator);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,6 +182,8 @@ pub struct RfTomographer {
|
||||
weight_matrix: Vec<Vec<(usize, f64)>>,
|
||||
/// Number of voxels.
|
||||
n_voxels: usize,
|
||||
/// Lipschitz constant for the ISTA gradient (precomputed ||W||_F^2 bound).
|
||||
lipschitz: f64,
|
||||
}
|
||||
|
||||
impl RfTomographer {
|
||||
@@ -222,10 +224,20 @@ impl RfTomographer {
|
||||
return Err(TomographyError::NoIntersections);
|
||||
}
|
||||
|
||||
// Lipschitz upper bound for the ISTA step size: ||W^T W|| <= ||W||_F^2.
|
||||
// Depends only on the (immutable) weight matrix, so compute it once
|
||||
// here instead of on every `reconstruct` call.
|
||||
let frobenius_sq: f64 = weight_matrix
|
||||
.iter()
|
||||
.flat_map(|ws| ws.iter().map(|&(_, w)| w * w))
|
||||
.sum();
|
||||
let lipschitz = frobenius_sq.max(1e-10);
|
||||
|
||||
Ok(Self {
|
||||
config,
|
||||
weight_matrix,
|
||||
n_voxels,
|
||||
lipschitz,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -246,24 +258,16 @@ impl RfTomographer {
|
||||
let mut x = vec![0.0_f64; self.n_voxels];
|
||||
let n_links = attenuations.len();
|
||||
|
||||
// Estimate step size: 1 / L where L is the Lipschitz constant of the
|
||||
// gradient of ||Wx - y||^2, i.e. the spectral norm of W^T W.
|
||||
// A safe upper bound is the Frobenius norm squared of W (sum of all
|
||||
// squared entries), since ||W^T W|| <= ||W||_F^2.
|
||||
let frobenius_sq: f64 = self
|
||||
.weight_matrix
|
||||
.iter()
|
||||
.flat_map(|ws| ws.iter().map(|&(_, w)| w * w))
|
||||
.sum();
|
||||
let lipschitz = frobenius_sq.max(1e-10);
|
||||
let step_size = 1.0 / lipschitz;
|
||||
// Step size 1 / L, with L precomputed in `new` (||W||_F^2 upper bound).
|
||||
let step_size = 1.0 / self.lipschitz;
|
||||
|
||||
let mut residual = 0.0_f64;
|
||||
let mut iterations = 0;
|
||||
let mut gradient = vec![0.0_f64; self.n_voxels];
|
||||
|
||||
for iter in 0..self.config.max_iterations {
|
||||
// Compute gradient: W^T (Wx - y)
|
||||
let mut gradient = vec![0.0_f64; self.n_voxels];
|
||||
gradient.fill(0.0);
|
||||
residual = 0.0;
|
||||
|
||||
for (link_idx, weights) in self.weight_matrix.iter().enumerate() {
|
||||
|
||||
@@ -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,85 @@ 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,
|
||||
},
|
||||
|
||||
/// The mask ratio is not a finite value strictly inside `(0, 1)` — the
|
||||
/// same rule as [`MaePretrainConfig::validate`]. A NaN ratio must never
|
||||
/// silently mask zero patches, and ratios ≤ 0 / ≥ 1 degenerate to
|
||||
/// all-visible / all-masked grids.
|
||||
///
|
||||
/// [`MaePretrainConfig::validate`]: crate::mae::MaePretrainConfig::validate
|
||||
#[error("Invalid mask ratio {ratio}: must be finite and strictly inside (0, 1)")]
|
||||
InvalidMaskRatio {
|
||||
/// The offending ratio.
|
||||
ratio: f64,
|
||||
},
|
||||
|
||||
/// A NaN or ±inf CSI value was found; corrupted input must be cleaned
|
||||
/// upstream, never masked over.
|
||||
#[error("Non-finite CSI value {value} at (t={row}, sc={col})")]
|
||||
NonFiniteValue {
|
||||
/// Time index of the offending value.
|
||||
row: usize,
|
||||
/// Subcarrier index of the offending value.
|
||||
col: usize,
|
||||
/// The non-finite value itself.
|
||||
value: f32,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -49,11 +49,13 @@ pub mod domain;
|
||||
pub mod error;
|
||||
pub mod eval;
|
||||
pub mod geometry;
|
||||
pub mod mae;
|
||||
pub mod rapid_adapt;
|
||||
pub mod ruview_metrics;
|
||||
pub mod signal_features;
|
||||
pub mod subcarrier;
|
||||
pub mod virtual_aug;
|
||||
pub mod wiflow_std;
|
||||
|
||||
// The following modules use `tch` (PyTorch Rust bindings) for GPU-accelerated
|
||||
// training and are only compiled when the `tch-backend` feature is enabled.
|
||||
@@ -70,6 +72,9 @@ pub mod proof;
|
||||
|
||||
/// ADR-145 — ablation evaluation harness (feature matrix + privacy/latency metrics).
|
||||
pub mod ablation;
|
||||
/// Falsifiable occupancy/presence benchmark (real-CSI gate: provenance,
|
||||
/// leak-free split, bootstrap-CI thresholds; refuses claims on synthetic/mock).
|
||||
pub mod occupancy_bench;
|
||||
#[cfg(feature = "tch-backend")]
|
||||
pub mod trainer;
|
||||
|
||||
@@ -78,7 +83,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 +91,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;
|
||||
|
||||
@@ -118,7 +118,7 @@ impl WiFiDensePoseLoss {
|
||||
// Normalise by number of visible joints in the batch.
|
||||
let n_visible = visibility.sum(Kind::Float);
|
||||
// Guard against division by zero (entire batch may have no labels).
|
||||
let safe_n = n_visible.clamp(1.0, f64::MAX);
|
||||
let safe_n = n_visible.clamp_min(1.0);
|
||||
|
||||
masked.sum(Kind::Float) / safe_n
|
||||
}
|
||||
@@ -165,7 +165,7 @@ impl WiFiDensePoseLoss {
|
||||
let masked_target_uv = target_uv * &fg_mask_f;
|
||||
|
||||
// Count foreground pixels × 48 channels to normalise.
|
||||
let n_fg = fg_mask_f.sum(Kind::Float).clamp(1.0, f64::MAX);
|
||||
let n_fg = fg_mask_f.sum(Kind::Float).clamp_min(1.0);
|
||||
|
||||
// Smooth-L1 with beta=1.0, reduction=Sum then divide by fg count.
|
||||
let uv_loss_sum = masked_pred_uv.smooth_l1_loss(&masked_target_uv, Reduction::Sum, 1.0);
|
||||
@@ -234,7 +234,7 @@ impl WiFiDensePoseLoss {
|
||||
// UV loss (foreground masked)
|
||||
let fg_mask = target_int.not_equal(0_i64);
|
||||
let fg_mask_f = fg_mask.unsqueeze(1).expand_as(pu).to_kind(Kind::Float);
|
||||
let n_fg = fg_mask_f.sum(Kind::Float).clamp(1.0, f64::MAX);
|
||||
let n_fg = fg_mask_f.sum(Kind::Float).clamp_min(1.0);
|
||||
let uv_loss =
|
||||
(pu * &fg_mask_f).smooth_l1_loss(&(tu * &fg_mask_f), Reduction::Sum, 1.0)
|
||||
/ n_fg;
|
||||
@@ -743,10 +743,11 @@ mod tests {
|
||||
}
|
||||
|
||||
// Visible batch (index 1) should have non-zero heatmaps.
|
||||
let heatmaps_ref = &heatmaps;
|
||||
let batch1_sum: f32 = (0..num_joints)
|
||||
.map(|j| {
|
||||
(0..size)
|
||||
.flat_map(|r| (0..size).map(move |c| heatmaps[[1, j, r, c]]))
|
||||
.flat_map(|r| (0..size).map(move |c| heatmaps_ref[[1, j, r, c]]))
|
||||
.sum::<f32>()
|
||||
})
|
||||
.sum();
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user