mirror of
https://github.com/ruvnet/RuView
synced 2026-07-27 18:11:43 +00:00
feat(ruview-unified): unified RF spatial world model P1 (ADR-273..278)
One shared representation instead of another isolated RF classifier: new v2 leaf crate ruview-unified implementing all five ADR-273 pillars, plus six ADRs with measured, grade-labeled results. - Canonical RfTensor + fail-closed hardware adapter registry (802.11 CSI via wifi-densepose-core::CsiFrame, FMCW radar cubes, UWB CIR, 5G SRS); shared layout/gain/phase normalization proven by tests (ADR-274). - Universal RF foundation encoder: CFO-aligned, median-scaled tokenizer; masked-reconstruction pretraining with hand-derived backprop verified against central finite differences (max rel err 1.31e-5 over all 12 parameter groups); fusion contract z = Enc ⊙ σ(AgeEnc) + GeomEnc; task adapters under the 1% budget (129/268/387/2 params vs 40,856 backbone), enforced by test. - RF-aware Gaussian spatial memory: anisotropic primitives with per-band reflectivity, confidence-weighted fusion, decay, spatial-hash/semantic queries, closed-form Beer-Lambert channel gain (exact Friis on empty map), inverse gain updates (unseen 6.1 dB wall learned to <0.5 dB in 20 observations), task-gated scene graph (ADR-275). - Physics-guided synthetic RF worlds: image-method multipath (order ≤2), complex-permittivity Fresnel materials, emergent Doppler proven against the analytic phase rate, seeded ChaCha20 randomization of physics and hardware nuisances; byte-deterministic per seed (ADR-276). - Edge sensing control plane: 802.11bf/ETSI-ISAC-aligned purposes/zones, fail-closed authorization, double-gated identity, retention bounds; BoundedEvent-only trust boundary makes raw RF export unrepresentable (ADR-277). Radar inverse rendering stays a gated research program (ADR-278, no code by design). Anti-leakage acceptance pipeline (strict splits by room/day/person/ chipset/firmware/layout with independent disjointness verification): presence F1 1.00 on held-out rooms and held-out chipset, degradation 0.0, ECE 0.012, p95 latency 2.0 ms debug / 105 µs release — ALL SYNTHETIC until P2 real-data validation. Benchmarks + optimization pass: channel_gain 139→27 µs (O(1) in map size via segment-corridor AABB sweep), observe_link 305→74 µs, DFT twiddle plan 4.9x; hash/linear crossover (~4k Gaussians) reported honestly. Tests: ruview-unified 66 unit + 3 acceptance, 0 failed; workspace 3,771 passed 0 failed (--exclude wifi-densepose-desktop: GTK headers unavailable in this container). Python proof: VERDICT PASS. Also gitignore sensing-server test-run artifacts (incl. generated session-secret). Co-Authored-By: claude-flow <ruv@ruv.net> Claude-Session: https://claude.ai/code/session_01Q1R5zhz6sSfXGRXpgBwpFX
This commit is contained in:
@@ -291,3 +291,7 @@ harness/**/ruvector.db
|
||||
# ruvector runtime/hook DB — never tracked (any depth)
|
||||
ruvector.db
|
||||
**/ruvector.db
|
||||
|
||||
# sensing-server runtime artifacts written by its test suite (trained model
|
||||
# snapshots + the generated session-secret) — never tracked
|
||||
v2/crates/wifi-densepose-sensing-server/data/
|
||||
|
||||
@@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- **`ruview-unified` — unified RF spatial world model, P1 (ADR-273..278).** New v2 workspace leaf crate implementing the five-pillar architecture: (1) canonical `RfTensor` (`links × 56 bins × 8 snapshots`, complex, validated at the boundary) plus a fail-closed hardware adapter registry with reference adapters for 802.11 CSI (consumes `wifi-densepose-core::CsiFrame`), FMCW radar cubes (fast-time DFT), UWB CIR, and 5G SRS (comb de-interleave); (2) a universal RF foundation encoder — window-median + CFO-aligned tokenizer, masked-reconstruction pretraining with a hand-derived backward pass verified against central finite differences (174 params sampled, max rel err 1.31e-5), the ADR-273 fusion contract `z = Enc(CSI) ⊙ σ(AgeEnc) + GeomEnc(pose)`, and ≤1% task adapters (presence 129 / activity 268 / localization 387 / anomaly 2 vs a 40,856-param backbone); (3) an RF-aware Gaussian spatial memory — anisotropic primitives with per-band×angle reflectivity, confidence-weighted fusion, exponential decay, spatial-hash + semantic queries, closed-form (erf) Beer–Lambert channel-gain queries that degrade to exact Friis on an empty map, inverse gain updates that learn an unseen 6 dB obstruction to <0.5 dB in 20 link observations, and a JITOMA-style task-gated scene graph; (4) a physics-guided synthetic RF world generator — Allen–Berkley image method (order ≤2), complex-permittivity Fresnel materials, bistatic person scattering with *emergent* Doppler (proven against the analytic phase rate), seeded ChaCha20 domain randomization of physics + hardware nuisances (gain/CFO/phase noise/packet loss/interference); (5) an edge sensing control plane — 802.11bf/ETSI-ISAC-aligned purposes and zones, fail-closed authorization, a double-gated identity purpose, retention bounds, and a `BoundedEvent`-only trust boundary that makes raw RF export unrepresentable. Anti-leakage evaluation (`StrictSplit` by room/day/person/chipset/firmware/layout with an independent disjointness verifier, ECE, selective risk, degradation) plus an end-to-end acceptance pipeline: presence F1 1.00 on held-out rooms *and* held-out chipset, degradation 0.0, ECE 0.012, p95 tokenize+encode 2.0 ms debug / 105 µs release — **all SYNTHETIC** (honest labeling propagates from `RfModality::Synthetic` through `Provenance.synthetic`). Criterion benches with an optimization pass: segment-corridor candidate search took `channel_gain` from 139 µs → 27 µs (O(1) in map size; hash/linear crossover at ~4k Gaussians reported honestly), `observe_link` 305 µs → 74 µs, precomputed DFT twiddles 4.9×. 66 unit + 3 acceptance tests, 0 failed.
|
||||
|
||||
### Changed
|
||||
- **`wifi-densepose` promoted to `2.0.0` stable; `ruview` `2.0.0` prepared for its first stable publish (ADR-184 P2).** Dropped the `a1` alpha suffix on both sibling packages (`python/pyproject.toml`, `python/ruview-meta/pyproject.toml`) and flipped their trove classifier `Development Status :: 3 - Alpha` → `5 - Production/Stable`; the `ruview` meta-package's `wifi-densepose==2.0.0a1` dependency pins (base + `[client]`) were repointed to `==2.0.0`. **Version-metadata prep only — nothing is published by this change**: the actual PyPI upload remains gated on the ADR-117 v2 witness hash. Justified as "stable": the default (no-extras) wheel builds at 279 KB (`maturin build --release --strip`) and the base non-SOTA suite is green — `pytest python/tests/` (excluding the `[aether]`/`[meridian]`/`[mat]` extra modules) = **185 passed, 0 failed** (smoke / keypoint / pose / vitals / bfld / security / WS+MQTT client).
|
||||
- **CI (ADR-184): `pip-release.yml` keeps token-based PyPI authentication until Trusted Publishing is registered.** An OIDC migration was attempted in `cc153e8b5` and reverted in `82d5c7339` so releases would not enter a half-configured state. Production currently uses `PYPI_API_TOKEN`; TestPyPI uses its independent `TESTPYPI_API_TOKEN`. The workflow now builds and publishes `wifi-densepose` and `ruview` together, verifies their versions and dependency pin match, and fails closed before production upload when `expected_features_v2.sha256` is absent.
|
||||
|
||||
@@ -25,6 +25,7 @@ Dual codebase: Python v1 (`v1/`) and Rust port (`v2/`).
|
||||
| `vendor/rufield` (submodule) | **RuField MFS** — the open spec for camera-free multimodal field sensing (ADR-260). A common `FieldEvent`/`FieldTensor`/`FusionGraph`/`PrivacyClass`/`ProvenanceReceipt` model *above* WiFi CSI/CIR/BFLD, UWB, BLE Channel Sounding, mmWave radar, ultrasound, subsonic, infrared, and quantum sensors. Lives in its own repo ([github.com/ruvnet/rufield](https://github.com/ruvnet/rufield)), vendored here under `vendor/rufield`. Not a `v2/` workspace member. v0.1 reference stack = 7 crates (`rufield-core`/`-provenance`/`-privacy`/`-adapters`/`-fusion`/`-bench`/`-viewer`), 72 tests/0 failed; `rufield-viewer` is an Axum + vanilla-JS read-only dashboard (`cargo run -p rufield-viewer`) completing ADR-260 §27.9. The WiFi-CSI modality is now **real-replay-backed** via `CsiReplayAdapter` (ingests real captured `.csi.jsonl` → fused presence/breathing inferences; replay-from-file, unlabeled CSI-variance proxy, not validated accuracy); mmWave/thermal + all synthetic-bench F1 numbers remain **SYNTHETIC** (no live hardware — live streaming + labeled accuracy are roadmap). |
|
||||
| `wifi-densepose-rufield` | ADR-262 P1 **anti-corruption bridge** — converts RuView WiFi-CSI sensing output (`SensingSnapshot` mirroring `SensingUpdate` + `TrustedOutput`, owned primitives, no dep on `wifi-densepose-sensing-server`) into **signed RuField `FieldEvent`s** (`Modality::WifiCsi`, real `timestamp_ns`, sha256 + ed25519 provenance, `synthetic=false`). The single coupling point between RuView and the standalone RuField MFS spec (§5.4); path-deps the `vendor/rufield` submodule crates (`rufield-core`/`-provenance`/`-privacy`/`-fusion`). **Critical §3.3 privacy mapping** (`map_privacy`): maps RuView class → RuField P0–P5 by **information content, never byte value**, fail-closed (`Derived → P4/P5`, never P1; `demoted` floors to ≥ P2). 15 tests / 0 failed (round-trip / `is_fusable` / fusion-ingest / privacy-safety / determinism). P1 plumbing — not wired into the live server (P3), no accuracy claim. |
|
||||
| `ruview-swarm` | Drone swarm control system (ADR-148) — hierarchical-mesh topology, Raft consensus, MARL, CSI sensing payload, MAVLink/PX4 compat, Ruflo AI-agent integration |
|
||||
| `ruview-unified` | ADR-273..277 **unified RF spatial world model**: canonical `RfTensor` + fail-closed hardware adapter registry (WiFi CSI / FMCW cube / UWB CIR / 5G SRS), universal RF foundation encoder (masked-reconstruction pretraining with finite-difference-verified backprop, `z = Enc ⊙ σ(Age) + Geom` fusion, ≤1% task adapters), RF-aware Gaussian spatial memory (fusion/decay/channel-gain queries + inverse updates, task-gated scene graph), physics-guided synthetic RF world generator (image-method multipath, Fresnel materials, emergent Doppler, seeded domain randomization), and the edge sensing control plane (802.11bf/ETSI-ISAC-aligned purposes/zones; raw RF structurally unexportable). Pure Rust leaf; all accuracy numbers SYNTHETIC until P2 real-data validation. |
|
||||
|
||||
### RuvSense Modules (`signal/src/ruvsense/`)
|
||||
| Module | Purpose |
|
||||
@@ -62,7 +63,7 @@ All 5 ruvector crates integrated in workspace:
|
||||
- `ruvector-attention` → `model.rs` (apply_spatial_attention) + `bvp.rs`
|
||||
|
||||
### Architecture Decisions
|
||||
182 ADRs in `docs/adr/` (numbered ADR-001 through ADR-265, with gaps). Key ones:
|
||||
201 ADRs in `docs/adr/` (numbered ADR-001 through ADR-278, with gaps). Key ones:
|
||||
- ADR-014: SOTA signal processing (Accepted)
|
||||
- ADR-015: MM-Fi + Wi-Pose training datasets (Accepted)
|
||||
- ADR-016: RuVector training pipeline integration (Accepted — complete)
|
||||
@@ -81,6 +82,12 @@ All 5 ruvector crates integrated in workspace:
|
||||
- ADR-263: `@ruvnet/ruview` npm harness deep review + optimization strategy (Proposed)
|
||||
- ADR-264: `@ruvnet/rvagent` MCP server + `@ruv/ruview-cli` deep review + optimization strategy (Proposed)
|
||||
- ADR-265: RuView npm distribution strategy — CI gate, provenance, version single-sourcing (Proposed)
|
||||
- ADR-273: Unified RF spatial world model — umbrella + anti-leakage evaluation protocol + acceptance gates (Accepted — P1 implemented in `ruview-unified`)
|
||||
- ADR-274: Universal RF foundation encoder + hardware adapter registry (Accepted — P1 implemented)
|
||||
- ADR-275: RF-aware Gaussian spatial memory — fusion, decay, channel-gain queries, inverse updates, task-gated scene graph (Accepted — P1 implemented)
|
||||
- ADR-276: Physics-guided synthetic RF world generator — randomize physics, not textures (Accepted — P1 implemented)
|
||||
- ADR-277: Edge sensing control plane — purposes/zones/retention/identity double-gate; raw RF unexportable (Accepted — P1 implemented)
|
||||
- ADR-278: Radar inverse rendering + differentiable RF SLAM research program — RISE/DiffRadar/GeRaF reproduction gates (Proposed)
|
||||
|
||||
### Supported Hardware
|
||||
|
||||
|
||||
@@ -638,11 +638,12 @@ Verify the plugin structure: `bash plugins/ruview/scripts/smoke.sh`. Full detail
|
||||
| [Semantic Primitives — Precision/Recall](docs/integrations/semantic-primitives-metrics.md) | Per-primitive F1 on the held-out paired-capture set: someone-sleeping, possible-distress, room-active, elderly-inactivity-anomaly, meeting, bathroom, fall-risk, bed-exit, no-movement, multi-room. |
|
||||
| [Claude Code / Codex Plugin](plugins/ruview/README.md) | The `ruview` plugin + marketplace — skills, `/ruview-*` commands, agents, and the Codex prompt mirror |
|
||||
| [Portable harness — `npx @ruvnet/ruview`](harness/ruview/README.md) | MetaHarness-minted, host-portable RuView operator harness — `ruview.*` MCP tools + the MEASURED-vs-CLAIMED honesty guardrail enforced in code ([ADR-182](docs/adr/ADR-182-npx-ruview-harness-via-metaharness.md)). A lighter, multi-host companion to the in-repo plugin. |
|
||||
| [Architecture Decisions](docs/adr/README.md) | 182 ADRs — why each technical choice was made, organized by domain (hardware, signal processing, ML, platform, infrastructure) |
|
||||
| [Architecture Decisions](docs/adr/README.md) | 201 ADRs — why each technical choice was made, organized by domain (hardware, signal processing, ML, platform, infrastructure) |
|
||||
| [Domain Models](docs/ddd/README.md) | 8 DDD models (RuvSense, Signal Processing, Training Pipeline, Hardware Platform, Sensing Server, WiFi-Mat, CHCI, rvCSI) — bounded contexts, aggregates, domain events, and ubiquitous language |
|
||||
| [rvCSI — edge RF sensing runtime](https://github.com/ruvnet/rvcsi) | Rust-first / TypeScript-accessible / hardware-abstracted CSI runtime: multi-source ingestion (incl. real nexmon_csi `.pcap` from a **Raspberry Pi 5** / Pi 4 / Pi 3B+ — CYW43455 / BCM43455c0) → validation → DSP → typed events → RuVector RF memory ([ADR-095](docs/adr/ADR-095-rvcsi-edge-rf-sensing-platform.md), [ADR-096](docs/adr/ADR-096-rvcsi-ffi-crate-layout.md), [domain model](docs/ddd/rvcsi-domain-model.md)). Now its own repo — [`ruvnet/rvcsi`](https://github.com/ruvnet/rvcsi) — vendored here under `vendor/rvcsi`; 9 `rvcsi-*` crates on crates.io, `@ruv/rvcsi` on npm, plus a Claude Code plugin. |
|
||||
| [Desktop App](v2/crates/wifi-densepose-desktop/README.md) | **WIP** — Tauri v2 desktop app for node management, OTA updates, WASM deployment, and mesh visualization |
|
||||
| `ruview-swarm` | Drone swarm control system (ADR-148) — hierarchical-mesh topology, Raft consensus, MARL, CSI sensing payload, MAVLink/PX4/ArduPilot compatibility, Ruflo AI-agent integration |
|
||||
| `ruview-unified` | Unified RF spatial world model ([ADR-273](docs/adr/ADR-273-unified-rf-spatial-world-model.md)..[277](docs/adr/ADR-277-edge-sensing-control-plane.md)) — canonical RF tensor + hardware adapters (WiFi CSI / FMCW radar / UWB / 5G SRS), universal RF foundation encoder with ≤1% task adapters, RF-aware Gaussian spatial memory with channel-gain queries + inverse updates, physics-guided synthetic RF worlds, and an 802.11bf/ETSI-ISAC-aligned sensing policy plane (raw RF structurally unexportable). All accuracy numbers SYNTHETIC until real-data validation. |
|
||||
| [Medical Examples](examples/medical/README.md) | Contactless blood pressure, heart rate, breathing rate via 60 GHz mmWave radar — $15 hardware, no wearable |
|
||||
| [Extended Documentation](docs/readme-details.md) | Latest additions, key features, installation, quick start, signal processing, training, CLI, testing, deployment, and changelog |
|
||||
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
# ADR-273: Unified RF Spatial World Model — one shared representation, not another isolated RF classifier
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Status** | Accepted — **P1 implemented** (new v2 workspace crate `ruview-unified`; 66 unit + 3 acceptance-pipeline tests, 0 failed; criterion benches) |
|
||||
| **Date** | 2026-07-26 |
|
||||
| **Deciders** | ruv |
|
||||
| **Codebase target** | `v2/crates/ruview-unified/` (new leaf crate; single internal dep on `wifi-densepose-core` for `CsiFrame`) |
|
||||
| **Sub-ADRs** | ADR-274 (universal RF encoder + adapter registry), ADR-275 (RF-aware Gaussian spatial memory), ADR-276 (physics-guided synthetic RF worlds), ADR-277 (edge sensing control plane), ADR-278 (radar inverse rendering research program) |
|
||||
| **Relates to** | ADR-152 (WiFi-Pose SOTA intake: geometry conditioning), ADR-153 (802.11bf protocol model), ADR-260/262 (RuField MFS + bridge), ADR-135/136 (calibration + canonical frame provenance), ADR-024 (AETHER), ADR-027 (MERIDIAN domain generalization) |
|
||||
| **Scope** | Decide the target architecture for RuView + RuVector sensing through 2026-H2: one persistent, queryable spatial world model that vision, WiFi CSI, cellular CFR/SRS, radar, geometry, semantics, uncertainty, and time all update — and the priority order for building it. |
|
||||
|
||||
---
|
||||
|
||||
## 0. PROOF discipline
|
||||
|
||||
Every number in this ADR family is one of:
|
||||
|
||||
- **MEASURED-SYNTHETIC** — produced by this repo's tests/benches on data from the ADR-276 physics generator. Reproducible: `cd v2 && cargo test -p ruview-unified` / `cargo bench -p ruview-unified`. **No claim of real-world accuracy is made or implied.**
|
||||
- **MEASURED-CODE** — a structural property of the implementation (parameter counts, gradient-check error, determinism), verified by a named test.
|
||||
- **EXTERNAL-UNVERIFIED** — a number reported by an external paper/preprint (WiFo-2, WiLHPE, RISE, DiffRadar, HybridSim, OAI SRS demo, …) that this repo has **not** reproduced. These motivated design choices; they are never presented as our results.
|
||||
|
||||
## 1. Context
|
||||
|
||||
Through mid-2026 the field moved decisively away from task-specific RF classifiers:
|
||||
|
||||
1. **RF foundation models** (WiFo-2 scaling across 11.6 B CSI points/12 tasks; WiLLM's dataset adapters + shared self-supervised transformer; age-aware CSI fusion) — the architectural signal: *standardize heterogeneous CSI, pretrain with masked reconstruction, attach small task adapters* (all EXTERNAL-UNVERIFIED).
|
||||
2. **Gaussian fields as spatial memory** (EmbodiedSplat online semantic 3-D Gaussian mapping; TGSFormer bounded temporal Gaussian memory; July's physics-informed channel-gain mapping with incremental Gaussian insertion) — the missing bridge between RuView sensing and a queryable digital twin.
|
||||
3. **Synthetic RF worlds** (WaveVerse phase-coherent ray tracing; HybridSim's 92 % vs 54 % synthetic-to-real gap when *physics parameters*, not textures, are randomized) — the fastest path out of data scarcity.
|
||||
4. **Standards became actionable**: IEEE 802.11bf-2025 published (2025-09), 802.11bk (320 MHz positioning), ETSI ISAC architecture (2026-02) + security report (19 privacy/security issue classes), 3GPP Rel-20 sensing studies, OAI SRS xApp localization demo.
|
||||
5. **Generalization lessons**: PerceptAlign (condition on TX/RX geometry), RePos (factor root-relative pose from absolute localization), JITOMA (task-gated scene memory).
|
||||
|
||||
RuView already has the ingredients (calibration ADR-151, canonical frames ADR-136, ruvsense multistatic stack, RuField bridge ADR-262) but they update **separate** state. The decision is to converge on **one shared representation with persistent scene memory**.
|
||||
|
||||
## 2. Decision
|
||||
|
||||
Build the unified model as five pillars in strict priority order (scored 35 % business value / 25 % readiness / 20 % defensibility / 20 % strategic learning):
|
||||
|
||||
| # | Pillar | Score | Sub-ADR | P1 status |
|
||||
|---|--------|-------|---------|-----------|
|
||||
| 1 | Universal RF foundation encoder + hardware adapter registry | 4.7 | ADR-274 | **implemented** |
|
||||
| 2 | RF-aware Gaussian spatial memory | 4.5 | ADR-275 | **implemented** |
|
||||
| 3 | Age/geometry/uncertainty-aware inference (folded into the encoder contract) | 4.4 | ADR-274 §3 | **implemented** |
|
||||
| 4 | Physics-guided synthetic RF world generator | 4.1 | ADR-276 | **implemented** |
|
||||
| 5 | Edge sensing control plane (802.11bf / ETSI ISAC aligned) | 3.9* | ADR-277 | **implemented** (policy engine; O-RAN xApp is roadmap) |
|
||||
| 6 | Radar inverse rendering + differentiable RF SLAM | 3.6 | ADR-278 | research program (not implemented) |
|
||||
|
||||
\* the 3.9-scored item is the O-RAN SRS xApp; its *policy plane* and its *SRS adapter seam* ship in P1 because they are cheap and gate everything else.
|
||||
|
||||
The representation contract every pillar shares:
|
||||
|
||||
```text
|
||||
z = Encoder(RF tokens) ⊙ σ(AgeEncoder(age)) + GeometryEncoder(sensor_pose)
|
||||
```
|
||||
|
||||
served from one canonical tensor (`RfTensor`, ADR-274 §2) and persisted into one scene memory (`GaussianMap` + task-gated `SceneGraph`, ADR-275).
|
||||
|
||||
## 3. Architecture (implemented, `v2/crates/ruview-unified/src/`)
|
||||
|
||||
```text
|
||||
vendor captures ──▶ adapters.rs (WiFi CSI / FMCW cube / UWB CIR / 5G SRS)
|
||||
│ normalize: layout → gain → phase (ADR-274 §2.3)
|
||||
▼
|
||||
tensor.rs RfTensor (links × 56 bins × 8 snapshots, complex)
|
||||
│
|
||||
tokenizer.rs amplitude/delay/Doppler/phase/age/geometry/
|
||||
│ clock/uncertainty tokens (CFO-aligned,
|
||||
│ median-scale-normalized)
|
||||
▼
|
||||
encoder.rs + pretrain.rs masked-reconstruction pretraining,
|
||||
│ exact hand-derived backprop (gradient-checked)
|
||||
▼
|
||||
┌── heads.rs ≤1 % task adapters (presence/activity/localization/anomaly)
|
||||
│
|
||||
├── gaussian/ RF-aware Gaussian memory: fusion, decay, channel-gain
|
||||
│ queries, inverse updates, task-gated scene graph
|
||||
│
|
||||
└── policy.rs purposes/zones/retention/identity gating; BoundedEvent
|
||||
is the only exportable type (raw RF unrepresentable)
|
||||
```
|
||||
|
||||
`synth/` (ADR-276) generates the labeled physics worlds that train and gate all of it; `eval.rs` implements the anti-leakage protocol below.
|
||||
|
||||
## 4. The non-negotiable evaluation protocol (anti-leakage)
|
||||
|
||||
The biggest failure mode in this field is **domain leakage disguised as accuracy**: random frame splits let a model recognize the room, session, person, device, or trajectory. Bigger models make it worse. Therefore:
|
||||
|
||||
- **No result counts unless the test set holds out complete** rooms, days, people, chipsets, firmware versions, and antenna layouts. `eval::StrictSplit` constructs such splits and `verify()` independently proves disjointness (`eval.rs`; test `verify_catches_a_manufactured_leak`).
|
||||
- Track **relative degradation** known→unknown (`relative_degradation`, gate < 20 %), **calibration** (`expected_calibration_error`), and **abstention quality** (`selective_metrics` — an uncertain result must become *no decision*, not a confident guess).
|
||||
- Every synthetic number is labeled SYNTHETIC in test output and in these ADRs.
|
||||
|
||||
## 5. Acceptance gates — P1 (synthetic analogue) results
|
||||
|
||||
The ADR's acceptance test (frozen shared encoder, adapters < 1 % of backbone, unseen rooms/chipsets/layouts) is implemented end-to-end in `tests/e2e_acceptance.rs`. **MEASURED-SYNTHETIC** results on the ADR-276 generator (8 rooms × 20 windows × 3 links, seed 273273):
|
||||
|
||||
| Gate (ADR target) | P1 synthetic result | Verdict |
|
||||
|---|---|---|
|
||||
| Presence F1 ≥ 0.90, unseen rooms | **1.0000** (rooms 6–7 held out of pretraining *and* head training) | pass |
|
||||
| Presence F1 ≥ 0.90, unseen chipset | **1.0000** (`chip-2` held out; per-room random gain/phase/CFO/noise) | pass |
|
||||
| Cross-environment degradation < 20 % | **0.0000** | pass |
|
||||
| Adapter budget < 1 % of backbone | presence 129 / activity 268 / localization 387 / anomaly 2 params vs 40,856-param backbone (< 408) | pass (MEASURED-CODE) |
|
||||
| Edge latency p95 < 50 ms | **2.0 ms** debug profile (tokenize+encode); 105 µs encode / 67 µs tokenize release (criterion) | pass |
|
||||
| Held-out ECE | **0.0122**; abstention risk monotone in threshold | pass |
|
||||
| Raw RF never crosses the trust boundary | structural: only `policy::BoundedEvent` exports (no tensor-carrying variant exists) | pass |
|
||||
| Every output carries uncertainty, provenance, model version, purpose | enforced at `BoundedEvent::new` (construction fails otherwise) | pass |
|
||||
|
||||
**Honest reading**: a synthetic world where presence ⇔ a moving scatterer is *separable by construction*; F1 = 1.0 here validates the **pipeline and the anti-leakage machinery**, not real-world performance. The real-data gate (5 unseen rooms, 2 unseen chipsets, 2 unseen layouts, measured CSI) is P2 and remains open.
|
||||
|
||||
## 6. Consequences
|
||||
|
||||
- RuView gains a single, tested substrate that all future sensing work (vision fusion, SRS xApp, radar) updates instead of forking.
|
||||
- The synthetic-first discipline means every accuracy claim is grade-labeled; publishing an unlabeled number is now a process violation.
|
||||
- The Gaussian memory becomes the integration point for RuVector (vector retrieval → graph constraints → geometric verification; the LLM plans the query, the renderer verifies the answer).
|
||||
- Cost: a new crate to maintain (~4.6 k lines incl. tests); mitigations: zero heavy deps, deterministic tests, files < 500 lines each.
|
||||
|
||||
## 7. Roadmap after P1
|
||||
|
||||
| Phase | Content | Gate |
|
||||
|-------|---------|------|
|
||||
| P2 | Replay real `.csi.jsonl` (rvCSI / ADR-262 corpus) through the WiFi adapter; calibrate the anomaly head on real empty-room captures | strict-split F1/ECE on measured data, reported with degradation vs synthetic |
|
||||
| P3 | Wire `GaussianMap` into `wifi-densepose-sensing-server` behind the ADR-277 boundary; RuVector embedding of Gaussian clusters | live map consistency + bounded-event-only egress audit |
|
||||
| P4 | OAI SRS xApp feeding `CellularSrsAdapter` (the adapter + registry seam already exists) | 0.5 m p90 localization under *non-random* splits |
|
||||
| P5 | ADR-278 radar inverse rendering reproduction (RISE first) |
|
||||
@@ -0,0 +1,95 @@
|
||||
# ADR-274: Universal RF foundation encoder + hardware adapter registry
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Status** | Accepted — **P1 implemented** (`ruview-unified`: `tensor.rs`, `adapters.rs`, `tokenizer.rs`, `encoder.rs`, `pretrain.rs`, `heads.rs`, `eval.rs`) |
|
||||
| **Date** | 2026-07-26 |
|
||||
| **Parent** | ADR-273 |
|
||||
| **Relates to** | ADR-136 (`CanonicalFrame` provenance — the WiFi adapter consumes `wifi-densepose-core::CsiFrame` directly), ADR-152 §2 (geometry conditioning intake), ADR-016/017 (ruvector integration points) |
|
||||
|
||||
## 0. PROOF discipline
|
||||
|
||||
Grades as in ADR-273 §0. Every number below is MEASURED-CODE or MEASURED-SYNTHETIC unless marked EXTERNAL-UNVERIFIED.
|
||||
|
||||
## 1. Context
|
||||
|
||||
WiFo-2 and WiLLM (EXTERNAL-UNVERIFIED) demonstrated that heterogeneous CSI standardization + masked-reconstruction pretraining + small task adapters beats per-task models, and the age-aware CSI line showed a cheap win from encoding sample freshness multiplicatively. RuView has four incompatible capture families today (802.11 CSI, FMCW radar cubes, UWB CIR, and — via O-RAN — 5G SRS). Each previously implied its own model.
|
||||
|
||||
## 2. Decision — canonical tensor + adapter registry
|
||||
|
||||
### 2.1 Canonical tensor
|
||||
|
||||
All modalities normalize to `RfTensor` (`tensor.rs`): complex `(links × 56 bins × 8 snapshots)` plus carrier/bandwidth, per-link `LinkGeometry`, `sample_age_s`, `clock_quality ∈ [0,1]`, `uncertainty ∈ [0,1]`, `device_id`, and a `CalibrationMeta` contract. 56 bins = usable 20 MHz 802.11n subcarriers (and the existing 114→56 interpolation in `wifi-densepose-train`), so the most common source resamples trivially.
|
||||
|
||||
**Boundary rule**: `RfTensor::new` is the only constructor and validates every field (finite samples, geometry/link arity, ranges). Downstream code assumes validity. Tests: `tensor.rs::tests` (4).
|
||||
|
||||
### 2.2 Normalization pipeline (every adapter, 3 stages)
|
||||
|
||||
1. **Layout** — vendor shape → `(links, bins, snapshots)`; FMCW gets a fast-time DFT to range bins; SRS gets comb de-interleaving; then linear complex resampling to canonical dims.
|
||||
2. **Amplitude** — per-link division by median amplitude (chipset gain invariance; offset recorded in `CalibrationMeta.gain_offset_db`).
|
||||
3. **Phase** — per (link, snapshot), remove constant offset + least-squares linear ramp across bins (CFO residual + sampling-time offset), with unwrapping. Skipped for delay-domain modalities (radar range profiles, UWB taps) where a detrend would erase ToF structure.
|
||||
|
||||
Measured (test `wifi_adapter_normalizes_shape_gain_and_phase`): a synthetic capture with per-link gains ×3.7/×7.4 and phase ramp `0.9 + 0.11·bin` comes out with median amplitude 1.0 ± 1e-9 and residual phase < 1e-4 rad (the ~7 µrad residue is second-order chord-vs-arc error from complex resampling). The radar adapter localizes a fast-time beat tone to the analytically expected canonical range bin (`radar_adapter_localizes_beat_tone_to_range_bin`).
|
||||
|
||||
### 2.3 Registry
|
||||
|
||||
`AdapterRegistry` maps hardware id → `dyn RfAdapter`, **fail-closed** (unknown hardware is an error; wrong modality is a typed `ModalityMismatch`). Reference adapters ship for `esp32s3-csi`, `mr60bha2` (FMCW), `dw3000` (UWB), `oai-srs-xapp` (5G SRS) — the last being the ADR-273 P4 seam.
|
||||
|
||||
## 3. Decision — encoder, fusion contract, adapters
|
||||
|
||||
### 3.1 Tokenizer
|
||||
|
||||
One token per (link, 8-bin subcarrier group); 24 features: log-amplitudes, delay-spectrum DFT (4), Doppler DFT bins 1–4 (log-compressed `ln(1+100·mag)`), temporal amplitude deviation (`ln(1+20·std)`), phase velocity, sample age, link distance/height/azimuth, clock quality, uncertainty (`tokenizer.rs`, layout table on `RfToken`).
|
||||
|
||||
Two hardware-invariance steps precede feature extraction, and both were *forced by measurement*, not aesthetics (see §5 evidence trail):
|
||||
|
||||
- **window-median amplitude normalization** — raw Friis-scale features (~1e-3) left every head unable to learn;
|
||||
- **CFO alignment** — per link, each snapshot is de-rotated by `arg Σ_b H[b,s]·H̄[b,0]`; carrier-frequency-offset drift is a *common* rotation and cancels, while a moving scatterer's frequency-selective perturbation survives (test `motion_raises_doppler_and_variance_features` uses a bin-dependent perturbation precisely so alignment cannot cancel it).
|
||||
|
||||
### 3.2 Encoder + pretraining
|
||||
|
||||
Pure-Rust, exactly differentiable (`encoder.rs`):
|
||||
|
||||
```text
|
||||
h_i = tanh(W1·x_i + b1) token embedding
|
||||
c = mean_i h_i permutation-invariant pool
|
||||
m = tanh(W2·c + b2); g = tanh(W2b·m + b2b)
|
||||
gate = σ(age_w·age + age_b) multiplicative freshness gate
|
||||
z = g ⊙ gate + Wg·geo + bg ← the ADR-273 fusion contract, verbatim
|
||||
```
|
||||
|
||||
Masked-reconstruction pretraining (`pretrain.rs`): mask 25 % of tokens, reconstruct each from `[z ; sinusoidal-position]` via a linear head discarded at deployment; SGD.
|
||||
|
||||
**Proof of the backward pass** (MEASURED-CODE, `gradients_match_finite_differences`): analytic gradients of **all 12 parameter groups** vs central finite differences — 174 sampled parameters, max relative error **1.31e-5**, with the absolute floor at central-difference roundoff (≈5e-11). Training halves masked loss and beats the constant-predictor variance baseline (`0.2757 → 0.0966` vs baseline `0.1550`; `pretraining_reduces_masked_loss_and_beats_mean_baseline`). Same seed ⇒ bit-identical weights (`training_is_deterministic`).
|
||||
|
||||
Backbone at deployment config (d_model 128): **40,856 parameters** (hand-count asserted in `param_count_matches_hand_computation`).
|
||||
|
||||
### 3.3 Two representation views (the PerceptAlign lesson, applied)
|
||||
|
||||
- `encode()` → full `z` (geometry-conditioned) — for localization/channel-prediction heads where sensor pose is signal.
|
||||
- `encode_content()` → `[g ⊙ gate ; mean token features]` — for environment-invariant heads (presence/activity/anomaly). The additive `Wg·geo` term is a **room-specific offset a linear adapter would memorize** — measured: with it, held-out-room presence F1 was 0.00 while training F1 fit; without it plus the pooled-statistics skip connection, held-out F1 is 1.00 (SYNTHETIC, ADR-273 §5).
|
||||
|
||||
### 3.4 Task adapters, ≤ 1 % budget
|
||||
|
||||
`heads.rs`: presence (logistic, 129 params), activity (rank-2 LoRA-style factorized softmax, 268), localization (linear ℝ³, 387), anomaly (2 calibration statistics on reconstruction error). All < 408 = 1 % of the 40,856-param backbone, asserted in `every_head_fits_the_one_percent_budget_at_deployment_config`. Convex heads train full-batch (deterministic); tests show they fit separable/multiclass toys to ≥ 95 %.
|
||||
|
||||
### 3.5 Anti-leakage evaluation (ADR-273 §4)
|
||||
|
||||
`eval.rs`: `PartitionKey` (room/day/person/chipset/firmware/layout), `StrictSplit::holdout` + independent `verify()`, ECE, coverage/selective-risk, degradation ratio, F1. Six unit tests including a manufactured-leak detection test.
|
||||
|
||||
## 4. Alternatives considered
|
||||
|
||||
- **Candle/ONNX backbone now** — rejected for P1: the deliverable is a *proven contract* (gradient-checked fusion formula, budget enforcement, leakage protocol); porting to `wifi-densepose-nn` backends is mechanical once real-data P2 justifies scale.
|
||||
- **Per-modality encoders with late fusion** — rejected: reproduces the isolated-classifier status quo ADR-273 exists to end.
|
||||
- **Full transformer attention** — deferred: mean-pool + 2 mixing layers passed every P1 gate; attention is a P2 measurement question, not a default.
|
||||
|
||||
## 5. Evidence trail (what the measurements changed)
|
||||
|
||||
P1 development falsified two comfortable assumptions, recorded here because the *fixes are the ADR*:
|
||||
|
||||
1. Raw-scale tokens: presence head stuck at F1 0.47 even on training rooms → window-median normalization + CFO alignment (train F1 → 0.76).
|
||||
2. Geometry-additive `z` for invariant tasks: held-out-room F1 0.00 → content view + pooled-statistic skip (held-out F1 → 1.00) — i.e. *the leak the eval protocol was designed to catch, caught in our own architecture first*.
|
||||
|
||||
## 6. Consequences
|
||||
|
||||
One encoder now serves presence, activity, localization, respiration-class, channel prediction, and anomaly through < 1 % adapters; new hardware lands as an adapter, not a model. Cost: the pure-Rust trainer is CPU-bound (fine at 40 k params; a P2 scale-up moves to `wifi-densepose-nn`).
|
||||
@@ -0,0 +1,78 @@
|
||||
# ADR-275: RF-aware Gaussian spatial memory — the persistent scene representation
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Status** | Accepted — **P1 implemented** (`ruview-unified/src/gaussian/`: `primitive.rs`, `map.rs`, `gain.rs`, `graph.rs`; 16 unit tests, criterion benches) |
|
||||
| **Date** | 2026-07-26 |
|
||||
| **Parent** | ADR-273 |
|
||||
| **Relates to** | ADR-030 (persistent field model — superseded in direction by this), ADR-134 (CIR/ISTA), ADR-147 (OccWorld priors), ADR-261 (RuVector graph-ANN — the retrieval layer this memory will index into) |
|
||||
|
||||
## 0. PROOF discipline
|
||||
|
||||
Grades per ADR-273 §0. The July 2026 external motivators (EmbodiedSplat ~5 fps online semantic Gaussian mapping, ~67× memory efficiency; TGSFormer bounded temporal Gaussian memory; physics-informed channel-gain mapping with incremental Gaussian insertion; JITOMA task-gated activation) are EXTERNAL-UNVERIFIED throughout.
|
||||
|
||||
## 1. Context
|
||||
|
||||
RuView's spatial state is currently scattered (pose tracker state, field-model eigenstructure, worldgraph tracks). Vision-side SOTA converged on Gaussian fields as the common continuous scene memory, and — the July signal that matters here — the representation crossed into RF: propagation geometry, opacity, attenuation, and scattering as Gaussian primitives, updated *incrementally* when the environment changes. That is exactly the bridge from RuView sensing to a queryable digital twin: one store that answers both geometric questions ("what is near the sofa") and RF questions ("which object caused the channel anomaly", "where did multipath change").
|
||||
|
||||
## 2. Decision — the primitive
|
||||
|
||||
`RfGaussian` (`primitive.rs`) carries all six ADR-273 attribute groups:
|
||||
|
||||
1. **Geometry**: position, per-axis scale (σ), unit-quaternion orientation → anisotropic metric `Σ⁻¹ = R·diag(1/σ²)·Rᵀ`.
|
||||
2. **Semantics**: 16-d embedding (RuVector-alignable).
|
||||
3. **RF response**: reflectivity `[4 bands × 4 incident-angle bins]` (2.4/5/6/60 GHz), plus `occupancy` = peak extinction coefficient (nepers/m) used by the gain model.
|
||||
4. **Motion**: signed Doppler m/s + `{Static, Slow, Fast}` class.
|
||||
5. **Trust/lifecycle**: confidence ∈ [0,1], timestamp, decay τ, `Provenance {device, model_version, synthetic}`.
|
||||
6. **Links**: typed references into the scene graph / RuVector entities.
|
||||
|
||||
Validated constructor (quaternion normalized, ranges checked); anisotropy and rotation are proven behaviorally (thin axis decays ≥ 80× faster at 0.3 m — the analytic ratio is 86; a 90° quaternion rotates the metric with it).
|
||||
|
||||
## 3. Decision — the map
|
||||
|
||||
`GaussianMap` (`map.rs`): spatial-hash grid (1 m default pitch) over a flat store.
|
||||
|
||||
- **Fusion, not accumulation**: an insert within Mahalanobis² 9 of a same-entity-kind Gaussian merges — confidence-weighted position/scale/occupancy/semantics/reflectivity/Doppler, noisy-OR confidence (`c₁+c₂−c₁c₂`), newest provenance wins, links union. Test: two 0.5-confidence observations 0.1 m apart fuse to one Gaussian at the weighted midpoint with confidence 0.75.
|
||||
- **Decay**: exponential confidence decay per Gaussian τ; prune below 0.02; deterministic (replay test).
|
||||
- **Queries**: radius (hash + linear reference impl, equivalence-tested on 100-Gaussian grids), kNN (expanding ring), semantic cosine top-k, and the segment-corridor query below.
|
||||
|
||||
## 4. Decision — channel gain as a first-class query + inverse update
|
||||
|
||||
`gain.rs` implements the RF query surface:
|
||||
|
||||
```text
|
||||
H(tx,rx,f) = (λ/4πd)·e^{-j2πd/λ} · exp(−Σ_g occ_g·I_g)
|
||||
```
|
||||
|
||||
with `I_g` the **closed-form** line integral of each Gaussian's density along the TX→RX segment (1-D Gaussian integral via erf; derivation in the module doc).
|
||||
|
||||
**Exactness anchors (MEASURED-CODE):**
|
||||
|
||||
- Empty map ⇒ **exact Friis** amplitude (< 1e-15) and propagation phase (`empty_map_returns_exact_friis`).
|
||||
- Closed-form line integral matches 1 mm trapezoid quadrature through a rotated anisotropic Gaussian to < 1e-6 (`line_integral_matches_numeric_quadrature`).
|
||||
- On-path absorber attenuates strictly monotonically in occupancy; a 10σ off-path absorber changes LoS gain < 1e-6 dB.
|
||||
|
||||
**Inverse update** (`observe_link`) — the incremental-mapping move: measured link amplitude → target optical depth `τ* = ln(friis/measured)`; a projected-gradient step distributes the residual over intersected Gaussians proportional to their path integrals (exact Newton along the link at lr = 1), clamped at occupancy ≥ 0; if nothing intersects and attenuation is demanded, a compact absorber is spawned at the midpoint sized to close the residual. **Measured**: from an empty map, 20 observations of a link with an unseen 0.7-neper (≈6.1 dB) obstruction converge to < 0.06 neper residual and < 0.5 dB prediction error (`inverse_update_learns_a_wall_from_link_residuals`).
|
||||
|
||||
## 5. Decision — task-gated scene graph
|
||||
|
||||
`graph.rs`: sparse typed nodes (`Object/Room/PersonClass/Device/Event` — person *classes* only; identity lives behind ADR-277's double gate) and relations (`Contains/Near/CausedBy/ObservedBy`). The only sanctioned read is `activate(relevant_kinds, seeds, max_nodes)` — bounded BFS that reports truncation instead of silently scanning (the JITOMA lesson). Tests: an "which object caused the anomaly" activation pulls exactly {event, object, room} and gates out devices/person-classes; the node budget is enforced and truncation is flagged.
|
||||
|
||||
## 6. Performance (criterion, release, this machine)
|
||||
|
||||
| Benchmark | Result | Note |
|
||||
|---|---|---|
|
||||
| `channel_gain`, 1 k Gaussians | **26.9 µs** | was 139 µs with the midpoint-ball candidate query |
|
||||
| `channel_gain`, 16 k Gaussians | **27.7 µs** | ~O(1) in map size after the corridor rewrite |
|
||||
| segment corridor query, hash vs linear | 24 µs vs 6 µs (1 k) / 24 µs vs **163 µs** (16 k) | crossover ≈ 4 k Gaussians — reported honestly; both paths kept + equivalence-tested |
|
||||
| radius query, hash vs linear | 4.3 µs vs 101 µs @ 16 k (23×) | hash loses at 1 k (4.0 vs 1.9 µs) — small maps are brute-force territory |
|
||||
| `observe_link` inverse update | **74 µs** | was 305 µs pre-optimization |
|
||||
| map insert+fuse (64 Gaussians, in observe bench setup) | included above | |
|
||||
|
||||
The optimization pass replaced a midpoint-ball candidate search (`(2·(L/2+3)+1)³ ≈ 9,300` cell lookups on a 14 m link) with an AABB sweep prefiltered by cell-centre-to-segment distance (bound `margin + √3/2·cell`), after a first corridor attempt (per-sample cube inserts into a BTreeSet) measured *worse* (1.2 ms) and was discarded — kept in this record as the honest negative result.
|
||||
|
||||
## 7. Consequences
|
||||
|
||||
- The map answers "where is a person likely", "where did multipath change", and "which object caused a channel anomaly" (gain residual → `CausedBy` edge) from one store.
|
||||
- RuVector integration (ADR-261) becomes: vector search retrieves candidate Gaussians/nodes → graph traversal enforces relations → the gain model *verifies* answers against geometry. The LLM plans the query; it never invents the spatial answer.
|
||||
- Not yet done (P3): live wiring into `wifi-densepose-sensing-server`, visual/depth Gaussian ingestion, and RuVector index sync.
|
||||
@@ -0,0 +1,68 @@
|
||||
# ADR-276: Physics-guided synthetic RF world generator — randomize physics, not textures
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Status** | Accepted — **P1 implemented** (`ruview-unified/src/synth/`: `room.rs`, `raytrace.rs`, `generator.rs`; 10 unit tests + the ADR-273 acceptance pipeline consumes it end-to-end) |
|
||||
| **Date** | 2026-07-26 |
|
||||
| **Parent** | ADR-273 |
|
||||
| **Relates to** | ADR-015 (MM-Fi/Wi-Pose datasets), ADR-089 (nvsim — the determinism pattern this follows), ADR-135 (empty-room baselines the generator can emulate) |
|
||||
|
||||
## 0. PROOF discipline
|
||||
|
||||
Grades per ADR-273 §0. WaveVerse (released simulator, phase-coherent ray tracing) and HybridSim (92.07 % vs 54.22 % synthetic-only→real activity recognition when physics is modeled explicitly) are EXTERNAL-UNVERIFIED motivators. Every output of this generator is stamped `RfModality::Synthetic` and every number derived from it is labeled SYNTHETIC — that stamp survives into `Provenance.synthetic` at the ADR-277 export boundary.
|
||||
|
||||
## 1. Context
|
||||
|
||||
RuView's scarcest resource is labeled, *diverse* RF data: rooms, materials, antenna placements, people, chipsets. The 2026 evidence says synthetic RF transfers **when the physics is explicit and the randomization hits physical parameters** (permittivity, geometry, kinematics, hardware nuisances) rather than cosmetic noise. A physics generator also gives the ADR-273 acceptance machinery something it can never get from captures alone: *ground truth by construction* and unlimited strict-split diversity.
|
||||
|
||||
## 2. Decision — physics core
|
||||
|
||||
### 2.1 Rooms and materials (`room.rs`)
|
||||
|
||||
Shoebox rooms `[0,Lx]×[0,Ly]×[0,Lz]`, one wall material with **complex permittivity** `ε = ε_r − j·σ/(ωε₀)` and normal-incidence Fresnel reflection `Γ = (1−√ε)/(1+√ε)`. Presets (concrete/drywall/glass, ITU-R P.2040 ballpark) plus a perfect absorber for test isolation. Measured sanity: concrete at 2.4 GHz gives |Γ| ≈ 0.39–0.45 with phase inversion; |Γ| < 1 for all passive presets; ε_r = 1, σ = 0 gives Γ = 0 exactly. People are validated-in-room point scatterers with constant velocity and RCS.
|
||||
|
||||
### 2.2 Multipath (`raytrace.rs`)
|
||||
|
||||
Allen–Berkley image method, reflection order ≤ 2 (per-axis images `±x + 2nL`, bounce count `|2n|` / `|2n−1|`), plus single-bounce bistatic person scattering with amplitude `√(σ_rcs/4π)/(d₁·d₂)` (bistatic radar equation, amplitude form):
|
||||
|
||||
```text
|
||||
H(f) = Σ_paths Γ^order · (c/f)/(4π) · s_p · e^{−j2πf·d_p/c}
|
||||
```
|
||||
|
||||
**Doppler is never injected** — it emerges from the person's path length changing between snapshots.
|
||||
|
||||
**Physics gates (MEASURED-CODE):**
|
||||
|
||||
| Gate | Test | Result |
|
||||
|---|---|---|
|
||||
| Direct path ≡ Friis | `direct_path_is_exact_friis` | < 1e-15 per subcarrier (absorber walls) |
|
||||
| Reciprocity `H(a→b) = H(b→a)` | `channel_is_reciprocal` | < 1e-12, with person + concrete walls |
|
||||
| Image geometry | `first_order_reflection_matches_mirror_geometry` | floor/ceiling bounce at exactly the mirror distance; 1 direct + 6 first-order + second-order set |
|
||||
| Doppler | `moving_person_produces_the_analytic_doppler_phase_rate` | residual-phase rotation matches `−2πf·Δd/c` to < 1e-6 rad across 4 steps |
|
||||
|
||||
## 3. Decision — domain randomization (`generator.rs`)
|
||||
|
||||
Per room, seeded ChaCha20 (nvsim discipline — same seed ⇒ byte-identical corpus, cross-machine):
|
||||
|
||||
- **Physics**: dimensions 4–10 × 3–8 × 2.4–3.2 m; ε_r ∈ [2,7], σ ∈ [0.002,0.1] S/m; random TX/RX placements; person start/heading/speed/RCS.
|
||||
- **Hardware nuisances** (what breaks naive models in the field): per-room gain ×0.5–2, static phase offset, **CFO drift** ±0.3 rad/snapshot, thermal noise, 5 % packet loss (snapshot re-delivery), 3 % wideband interference bursts.
|
||||
- **Provenance for strict splits**: every window carries a full `PartitionKey` (room/day/person/chipset/firmware/layout) so ADR-273 §4 holdouts exist by construction.
|
||||
|
||||
Measured: byte-determinism per seed (and divergence across seeds); presence windows carry > 5× the temporal amplitude variance of empty windows (actual measured ratio on the test corpus is far higher); labels/keys complete.
|
||||
|
||||
The CFO nuisance earned its keep immediately: it *defeated the first tokenizer* (empty rooms looked like motion) and forced the CFO-alignment step now documented in ADR-274 §3.1 — exactly the class of failure a physics-parameter randomizer exists to surface before real deployments do.
|
||||
|
||||
## 4. What this generator is NOT
|
||||
|
||||
- Not a WaveVerse replacement: order-2 specular + point scatterers, no diffraction, no diffuse scattering, no angle-dependent Fresnel, no antenna patterns. These are refinements to add *when a P2 real-data gap analysis demands them*, not before.
|
||||
- Not evidence of real-world accuracy: the ADR-273 acceptance numbers on this data validate the pipeline; the synthetic→real transfer claim (HybridSim-style) is untested here and stays EXTERNAL-UNVERIFIED until P2 replay experiments.
|
||||
|
||||
## 5. Performance
|
||||
|
||||
Criterion (release): 1 room × 4 windows × 3 links generates in **3.1 ms** (≈ 260 µs/window) — corpus generation is never the bottleneck; the 8-room acceptance corpus builds in well under a second even in debug.
|
||||
|
||||
## 6. Consequences
|
||||
|
||||
- Every pipeline stage gains a deterministic, physics-proven test bed; regressions in adapters/tokenizer/encoder now fail loudly against ground truth.
|
||||
- Data scarcity stops gating architecture work: strict-split experiments (rooms/chipsets/layouts) run in CI.
|
||||
- The honest-labeling chain (`RfModality::Synthetic` → `Provenance.synthetic` → SYNTHETIC-graded ADR claims) is structural, not editorial.
|
||||
@@ -0,0 +1,52 @@
|
||||
# ADR-277: Edge sensing control plane — purposes, zones, retention, and a trust boundary raw RF cannot cross
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Status** | Accepted — **P1 implemented** (`ruview-unified/src/policy.rs`; 5 unit tests + the acceptance-pipeline export test) |
|
||||
| **Date** | 2026-07-26 |
|
||||
| **Parent** | ADR-273 |
|
||||
| **Relates to** | ADR-153 (802.11bf protocol model), ADR-141/120 (BFLD privacy control plane + privacy classes), ADR-262 §3.3 (RuField P0–P5 fail-closed mapping — the same philosophy, applied to sensing outputs), ADR-032 (mesh security hardening) |
|
||||
|
||||
## 0. PROOF discipline
|
||||
|
||||
Grades per ADR-273 §0. Standards status (EXTERNAL, checkable): IEEE 802.11bf-2025 published 2025-09; IEEE 802.11bk addresses ≤ 320 MHz positioning; ETSI published an ISAC architecture 2026-02 (monostatic/bistatic/multistatic/network/device sensing) followed by a security report identifying **19 privacy and security issue classes**; 3GPP Release 20 sensing studies are active. The OpenAirInterface SRS-xApp demo (0.12 m MAE under a **random** split) is EXTERNAL-UNVERIFIED and its split methodology is exactly the leakage ADR-273 §4 rejects — we cite the *implementation path*, not the number.
|
||||
|
||||
## 1. Context
|
||||
|
||||
Sensing purposes and sensing zones are becoming first-class authorization objects in the standards (802.11bf sensing sessions; ETSI ISAC purposes/exposure). Meanwhile the ETSI security report's issue classes make one thing clear: a sensing stack without a policy plane is a liability. RuView already fails closed at other boundaries (ADR-262 §3.3 maps privacy by information content, never byte value); this ADR gives sensing *outputs* the same discipline, on-device, before any transport.
|
||||
|
||||
## 2. Decision — three structural rules
|
||||
|
||||
### 2.1 Raw RF never leaves the trust boundary
|
||||
|
||||
The only exportable type is `BoundedEvent` — typed verdicts only (`Presence(bool)`, `ActivityClass(u8)`, `RespirationBpm(f64)`, `Location([f64;3])`, `AnomalyScore(f64)`). **No variant can carry RF samples, so raw CSI/radar export is unrepresentable, not merely forbidden**; `TrustBoundary::export` is the single egress and there is deliberately no API that serializes an `RfTensor` outward. External systems receive bounded events + uncertainty, never signal history.
|
||||
|
||||
### 2.2 Fail closed, everywhere
|
||||
|
||||
`PolicyEngine::authorize`: unknown zone ⇒ deny; purpose not granted in the zone ⇒ deny; **identity recognition is double-gated** — it must be in the zone's `allowed_purposes` *and* the zone must set `identity_explicitly_enabled` (either alone denies). Retention: an event older than the zone's `retention_s` at export time is dropped with a typed `PolicyDenied`. Tests cover every branch, including the manufactured cases (identity granted-but-not-enabled; enabled-but-not-granted; stale event).
|
||||
|
||||
### 2.3 Every output is accountable (ADR-273 acceptance item 8)
|
||||
|
||||
`BoundedEvent::new` is the only constructor and *fails* without: uncertainty ∈ [0,1], provenance (device + `synthetic` flag — the ADR-276 honest label survives export), a non-zero model version, timestamp, purpose, and zone. The acceptance test (`outputs_leave_only_through_the_policy_boundary_fully_attributed`) runs the full pipeline — synthetic world → encoder → presence head → event → export — and asserts the attribution and the denial of an ungranted purpose on the same zone.
|
||||
|
||||
## 3. Purpose taxonomy
|
||||
|
||||
`SensingPurpose`: `Presence, Activity, Vitals, Localization, PoseTracking, IdentityRecognition, ChannelDiagnostics` — deliberately aligned with the ETSI ISAC sensing-service classes and WLAN-sensing use cases so a future 802.11bf sensing-session negotiation or ISAC exposure API maps 1:1 onto zone grants. Person *identity* is additionally kept out of the ADR-275 scene graph by type (`EntityKind::PersonClass`, never a person id) — the graph cannot leak what it cannot store.
|
||||
|
||||
## 4. O-RAN / cellular path (roadmap, seams shipped)
|
||||
|
||||
The P1 control plane is transport-agnostic and already fronts the cellular seam:
|
||||
|
||||
- `CellularSrsAdapter` (`oai-srs-xapp`, ADR-274 §2.3) normalizes comb-sampled SRS frequency responses into the canonical tensor — the data-plane contract an OAI xApp needs.
|
||||
- P4 (ADR-273 §7) places the sensing application beside the DU for sub-ms I/Q–CSI–SRS access, with the xApp performing wider-area fusion; **every output of that path still exits through this ADR's `TrustBoundary`**, and its localization claims will be reported only under strict splits (the OAI demo's random split is the cautionary example, not the target).
|
||||
|
||||
## 5. Alternatives considered
|
||||
|
||||
- **Reuse BFLD's privacy classes directly** — rejected: BFLD (ADR-120) classifies *captures*; this plane authorizes *outputs by purpose and zone*. They compose (a BFLD-classified capture feeding a head still exits through `TrustBoundary`), and ADR-262's `map_privacy` remains the capture-side mapping.
|
||||
- **Config-file allow-lists without types** — rejected: the 19 ETSI issue classes are mostly "the code path existed" failures; unrepresentability beats configuration.
|
||||
|
||||
## 6. Consequences
|
||||
|
||||
- Enterprise/telecom conversations get a concrete artifact: a privacy manifest is a serialization of zones + purposes + retention (all types already `serde`).
|
||||
- Every future surface (sensing-server WS, RuField bridge, SRS xApp, MCP tools) must route sensing outputs through `TrustBoundary` — added to the pre-merge security-review checklist item 12.
|
||||
- Cost: purposes are coarse (no per-consumer grants yet); P3 adds consumer identity when the sensing-server wiring lands.
|
||||
@@ -0,0 +1,46 @@
|
||||
# ADR-278: Radar inverse rendering + differentiable RF SLAM — a gated research program, not a dependency
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Status** | Proposed — research program (deliberately **no code in P1**) |
|
||||
| **Date** | 2026-07-26 |
|
||||
| **Parent** | ADR-273 (pillar 6, score 3.6 — highest strategic value, highest hardware + reproduction risk) |
|
||||
| **Relates to** | ADR-275 (the Gaussian memory these methods would write into), ADR-263/264 (RTL8720F radar platform + wire protocol), ADR-021 (mmWave vitals hardware), ADR-276 (synthetic worlds as the reproduction sandbox) |
|
||||
|
||||
## 0. PROOF discipline
|
||||
|
||||
Everything numeric in this ADR is **EXTERNAL-UNVERIFIED** — reported by fresh papers/preprints that this repo has not reproduced. That is the point of this ADR: to fix the reproduction gates *before* any of these numbers are allowed to influence the roadmap as if they were ours.
|
||||
|
||||
## 1. Context — what the field reports (July 2026)
|
||||
|
||||
| System | Claim (theirs) | Availability | Risk read |
|
||||
|---|---|---|---|
|
||||
| **RISE** | Single static mmWave radar + multipath inversion → joint room layout + furniture; 16 cm scene Chamfer (baseline 40 cm), 58 % furniture IoU | code available | most reproducible; static sensor matches our appliance posture |
|
||||
| **DiffRadar** | Radar SLAM + Gaussian fields + differentiable rendering; 0.129 m vs 0.823 m ATE, 94.78 % vs 42.59 % map consistency, 70 fps, 40 MB maps | fresh preprint | treat as **reproduction target, not component** — numbers are single-team, single-venue |
|
||||
| **GeRaF** | Differentiable RF renderer + SDF + reflectivity, near-range reconstruction; ~32 h on one H100 for 50 k iterations | published setup | offline calibration / digital-twin tool only; unsuitable for continuous adaptation |
|
||||
|
||||
The strategic pull is real: all three converge on *inverse rendering into continuous scene representations* — exactly the ADR-275 memory. The risks are equally real: single-source numbers, mmWave hardware variance, and compute profiles (GeRaF) incompatible with edge deployment.
|
||||
|
||||
## 2. Decision
|
||||
|
||||
1. **No production dependency** on any of these systems or their claims. ADR-275's gain model + inverse update is the only RF-inverse machinery in the deployment path.
|
||||
2. **Reproduction order: RISE → DiffRadar → GeRaF-lite**, each on one controlled test site, each gated (§3) before the next starts. RISE first because a static radar matches the RuView appliance posture and its inversion writes naturally into `RfGaussian` (occupancy + reflectivity fields already exist for it).
|
||||
3. **Sandbox-first**: before hardware, each method's core inversion is exercised against ADR-276 synthetic worlds extended with a radar-cube output mode (the `FmcwRadarCube` adapter already normalizes such cubes), so failures separate into "our reimplementation" vs "their claim" cleanly.
|
||||
4. **Integration contract**: any reproduced system emits into `GaussianMap` via the existing primitive — no parallel scene store. SLAM trajectories, if any, become `Provenance`-stamped map updates subject to ADR-277 export rules like everything else.
|
||||
|
||||
## 3. Gates (each phase passes all or the program pauses)
|
||||
|
||||
| Gate | Threshold | Split discipline |
|
||||
|---|---|---|
|
||||
| G1 RISE-repro (synthetic) | layout Chamfer within 2× of paper's on our synthetic rooms | held-out room geometries |
|
||||
| G2 RISE-repro (one real site) | qualitative layout recovery + quantified Chamfer vs measured floor plan; report *our* number, whatever it is | site never used in tuning |
|
||||
| G3 DiffRadar-repro | ATE and map consistency on our trajectory rig; publish the delta vs paper | held-out trajectories |
|
||||
| G4 Edge viability | inversion or map-update loop ≤ 50 ms p95 on target hardware, or explicit reclassification as offline-calibration tooling (GeRaF's honest category) | — |
|
||||
|
||||
A gate failure is a *result*, recorded in this ADR's log — the program exists to convert EXTERNAL-UNVERIFIED into MEASURED, in either direction.
|
||||
|
||||
## 4. Consequences
|
||||
|
||||
- The roadmap cannot silently absorb preprint numbers; anything radar-inverse must pass through §3.
|
||||
- ADR-275's primitive already reserves the fields (per-band × angle reflectivity, occupancy, motion) these methods need, so a successful reproduction integrates without schema churn.
|
||||
- Cost of delay is accepted: pillar 6 scored lowest on readiness, and P1–P4 (encoder, memory, synth, control plane, SRS) do not depend on it.
|
||||
@@ -132,6 +132,12 @@ Statuses: **Proposed** (under discussion), **Accepted** (approved and/or impleme
|
||||
| [ADR-263](ADR-263-ruview-npm-harness-deep-review.md) | `@ruvnet/ruview` npm harness — deep review + optimization strategy | Proposed |
|
||||
| [ADR-264](ADR-264-rvagent-mcp-and-cli-npm-deep-review.md) | `@ruvnet/rvagent` MCP server + `@ruv/ruview-cli` — deep review + optimization strategy | Proposed |
|
||||
| [ADR-265](ADR-265-ruview-npm-distribution-strategy.md) | RuView npm distribution strategy — CI gate, provenance, version single-sourcing, namespace | Proposed |
|
||||
| [ADR-273](ADR-273-unified-rf-spatial-world-model.md) | Unified RF spatial world model — umbrella, anti-leakage protocol, acceptance gates | Accepted (P1 implemented) |
|
||||
| [ADR-274](ADR-274-universal-rf-encoder-adapter-registry.md) | Universal RF foundation encoder + hardware adapter registry | Accepted (P1 implemented) |
|
||||
| [ADR-275](ADR-275-rf-aware-gaussian-spatial-memory.md) | RF-aware Gaussian spatial memory | Accepted (P1 implemented) |
|
||||
| [ADR-276](ADR-276-physics-guided-synthetic-rf-worlds.md) | Physics-guided synthetic RF world generator | Accepted (P1 implemented) |
|
||||
| [ADR-277](ADR-277-edge-sensing-control-plane.md) | Edge sensing control plane (802.11bf / ETSI ISAC aligned) | Accepted (P1 implemented) |
|
||||
| [ADR-278](ADR-278-radar-inverse-rendering-research-program.md) | Radar inverse rendering + differentiable RF SLAM research program | Proposed |
|
||||
|
||||
---
|
||||
|
||||
|
||||
Generated
+15
@@ -7675,6 +7675,21 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ruview-unified"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"ndarray 0.17.2",
|
||||
"num-complex",
|
||||
"proptest",
|
||||
"rand 0.8.5",
|
||||
"rand_chacha 0.3.1",
|
||||
"serde",
|
||||
"thiserror 2.0.18",
|
||||
"wifi-densepose-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ryu"
|
||||
version = "1.0.23"
|
||||
|
||||
@@ -78,6 +78,11 @@ members = [
|
||||
"crates/homecore-assist", # ADR-133 — HOMECORE voice assistant + ruflo bridge
|
||||
"crates/homecore-server", # iter-9 — HOMECORE integration binary (all 8 crates wired together)
|
||||
"crates/ruview-swarm", # ADR-148 — drone swarm control system
|
||||
# ADR-273..277 — unified RF spatial world model: canonical RF tensor +
|
||||
# hardware adapters, universal foundation encoder (masked-reconstruction
|
||||
# pretraining, ≤1% task adapters), RF-aware Gaussian spatial memory,
|
||||
# physics-guided synthetic RF worlds, edge sensing control plane.
|
||||
"crates/ruview-unified",
|
||||
# ADR-262 P1 — anti-corruption bridge converting RuView WiFi-CSI sensing
|
||||
# output into signed RuField FieldEvents. Path-deps the `vendor/rufield`
|
||||
# submodule crates (rufield-core/-provenance/-privacy/-fusion); single
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
[package]
|
||||
name = "ruview-unified"
|
||||
description = "Unified RF spatial world model (ADR-273): canonical RF tensor + hardware adapters, universal RF foundation encoder, RF-aware Gaussian spatial memory, physics-guided synthetic RF worlds, and the edge sensing control plane"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
documentation.workspace = true
|
||||
keywords = ["wifi", "csi", "rf-sensing", "gaussian-splatting", "world-model"]
|
||||
categories = ["science", "simulation"]
|
||||
|
||||
# `ruview-unified` is deliberately a *thin-dependency* crate: pure-Rust math,
|
||||
# deterministic ChaCha20 randomness (nvsim pattern — same seed ⇒ byte-identical
|
||||
# output on every machine), and a single internal dependency on
|
||||
# `wifi-densepose-core` so the WiFi adapter consumes the real `CsiFrame`
|
||||
# boundary type instead of a parallel invention. No GPU, no ONNX, no tokio.
|
||||
[dependencies]
|
||||
wifi-densepose-core = { workspace = true }
|
||||
ndarray = { workspace = true }
|
||||
num-complex = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
|
||||
# Deterministic PRNG for domain randomization + weight init (see nvsim §Pass 4
|
||||
# for the rationale: default features off drops the getrandom OS-entropy path,
|
||||
# keeping the crate WASM-ready and reproducible).
|
||||
rand = { version = "0.8", default-features = false }
|
||||
rand_chacha = { version = "0.3", default-features = false }
|
||||
|
||||
[dev-dependencies]
|
||||
criterion = { workspace = true }
|
||||
proptest = { workspace = true }
|
||||
|
||||
[[bench]]
|
||||
name = "unified_bench"
|
||||
harness = false
|
||||
|
||||
[lints.rust]
|
||||
unsafe_code = "forbid"
|
||||
missing_docs = "warn"
|
||||
|
||||
[lints.clippy]
|
||||
all = "warn"
|
||||
@@ -0,0 +1,167 @@
|
||||
//! Criterion benchmarks for the unified RF spatial world model hot paths
|
||||
//! (ADR-273 §7): encoder inference (the edge-latency budget), tokenization
|
||||
//! (with vs without precomputed DFT twiddles), Gaussian map queries (spatial
|
||||
//! hash vs linear-scan baseline), channel-gain evaluation, and synthetic
|
||||
//! window generation.
|
||||
#![allow(missing_docs)]
|
||||
|
||||
use criterion::{criterion_group, criterion_main, BatchSize, Criterion};
|
||||
|
||||
use ruview_unified::encoder::{EncoderConfig, RfEncoder};
|
||||
use ruview_unified::gaussian::map::GaussianMap;
|
||||
use ruview_unified::gaussian::primitive::{Provenance, RfGaussian};
|
||||
use ruview_unified::gaussian::{channel_gain, observe_link};
|
||||
use ruview_unified::math::{dft_magnitudes, DftPlan};
|
||||
use ruview_unified::synth::{SynthConfig, SynthGenerator};
|
||||
use ruview_unified::tokenizer::RfTokenizer;
|
||||
|
||||
fn synth_corpus() -> Vec<ruview_unified::synth::LabeledWindow> {
|
||||
SynthGenerator::new(SynthConfig {
|
||||
seed: 11,
|
||||
n_rooms: 2,
|
||||
windows_per_room: 4,
|
||||
links: 3,
|
||||
snapshot_dt_s: 0.05,
|
||||
})
|
||||
.generate()
|
||||
}
|
||||
|
||||
fn bench_encoder_forward(c: &mut Criterion) {
|
||||
let corpus = synth_corpus();
|
||||
let tokenizer = RfTokenizer::new();
|
||||
let window = tokenizer.tokenize(&corpus[0].tensor);
|
||||
let encoder = RfEncoder::new(EncoderConfig::default(), 42);
|
||||
c.bench_function("encoder_encode_window_21tok_d128", |b| {
|
||||
b.iter(|| std::hint::black_box(encoder.encode(&window)));
|
||||
});
|
||||
}
|
||||
|
||||
fn bench_tokenizer(c: &mut Criterion) {
|
||||
let corpus = synth_corpus();
|
||||
let tokenizer = RfTokenizer::new();
|
||||
c.bench_function("tokenize_3link_56bin_8snap", |b| {
|
||||
b.iter(|| std::hint::black_box(tokenizer.tokenize(&corpus[0].tensor)));
|
||||
});
|
||||
}
|
||||
|
||||
fn bench_dft_plan_vs_naive(c: &mut Criterion) {
|
||||
use num_complex::Complex64;
|
||||
let x: Vec<Complex64> =
|
||||
(0..8).map(|i| Complex64::new((i as f64).sin(), (i as f64).cos())).collect();
|
||||
let plan = DftPlan::new(8, 4);
|
||||
c.bench_function("dft8x4_naive", |b| {
|
||||
b.iter(|| std::hint::black_box(dft_magnitudes(&x, 4)));
|
||||
});
|
||||
c.bench_function("dft8x4_planned", |b| {
|
||||
b.iter(|| std::hint::black_box(plan.magnitudes(&x)));
|
||||
});
|
||||
}
|
||||
|
||||
fn populated_map(n_side: usize) -> GaussianMap {
|
||||
let mut map = GaussianMap::new(1.0);
|
||||
for x in 0..n_side {
|
||||
for y in 0..n_side {
|
||||
let g = RfGaussian::new(
|
||||
[x as f64 * 1.5, y as f64 * 1.5, 1.0],
|
||||
[0.3, 0.3, 0.3],
|
||||
[1.0, 0.0, 0.0, 0.0],
|
||||
0.4,
|
||||
0.9,
|
||||
0,
|
||||
600.0,
|
||||
Provenance { device_id: "bench".into(), model_version: 1, synthetic: true },
|
||||
)
|
||||
.expect("valid");
|
||||
map.insert(g);
|
||||
}
|
||||
}
|
||||
map
|
||||
}
|
||||
|
||||
fn bench_gaussian_map(c: &mut Criterion) {
|
||||
// Two sizes to show the hash/linear crossover honestly: at 1,024
|
||||
// Gaussians a brute-force scan is competitive for small-radius queries;
|
||||
// at 16,384 the hash wins decisively.
|
||||
for (label, side) in [("1k", 32usize), ("16k", 128)] {
|
||||
let map = populated_map(side);
|
||||
let centre = [side as f64 * 0.75, side as f64 * 0.75, 1.0];
|
||||
c.bench_function(&format!("map{label}_query_radius_hash"), |b| {
|
||||
b.iter(|| std::hint::black_box(map.query_radius(centre, 3.0)));
|
||||
});
|
||||
c.bench_function(&format!("map{label}_query_radius_linear"), |b| {
|
||||
b.iter(|| std::hint::black_box(map.query_radius_linear(centre, 3.0)));
|
||||
});
|
||||
c.bench_function(&format!("map{label}_segment_corridor_hash"), |b| {
|
||||
b.iter(|| {
|
||||
std::hint::black_box(map.query_near_segment(
|
||||
[0.0, 0.0, 1.0],
|
||||
[10.0, 10.0, 1.0],
|
||||
3.0,
|
||||
))
|
||||
});
|
||||
});
|
||||
c.bench_function(&format!("map{label}_segment_corridor_linear"), |b| {
|
||||
b.iter(|| {
|
||||
std::hint::black_box(map.query_near_segment_linear(
|
||||
[0.0, 0.0, 1.0],
|
||||
[10.0, 10.0, 1.0],
|
||||
3.0,
|
||||
))
|
||||
});
|
||||
});
|
||||
c.bench_function(&format!("map{label}_channel_gain"), |b| {
|
||||
b.iter(|| {
|
||||
std::hint::black_box(channel_gain(
|
||||
&map,
|
||||
[0.0, 0.0, 1.0],
|
||||
[10.0, 10.0, 1.0],
|
||||
2.437e9,
|
||||
))
|
||||
});
|
||||
});
|
||||
}
|
||||
c.bench_function("map_observe_link_inverse_update", |b| {
|
||||
b.iter_batched(
|
||||
|| populated_map(8),
|
||||
|mut m| {
|
||||
std::hint::black_box(observe_link(
|
||||
&mut m,
|
||||
[0.0, 0.0, 1.0],
|
||||
[10.0, 10.0, 1.0],
|
||||
2.437e9,
|
||||
1e-4,
|
||||
0.7,
|
||||
1,
|
||||
))
|
||||
},
|
||||
BatchSize::SmallInput,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
fn bench_synth_generation(c: &mut Criterion) {
|
||||
c.bench_function("synth_generate_1room_4windows_3links", |b| {
|
||||
b.iter(|| {
|
||||
std::hint::black_box(
|
||||
SynthGenerator::new(SynthConfig {
|
||||
seed: 3,
|
||||
n_rooms: 1,
|
||||
windows_per_room: 4,
|
||||
links: 3,
|
||||
snapshot_dt_s: 0.05,
|
||||
})
|
||||
.generate(),
|
||||
)
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
criterion_group!(
|
||||
benches,
|
||||
bench_encoder_forward,
|
||||
bench_tokenizer,
|
||||
bench_dft_plan_vs_naive,
|
||||
bench_gaussian_map,
|
||||
bench_synth_generation
|
||||
);
|
||||
criterion_main!(benches);
|
||||
@@ -0,0 +1,703 @@
|
||||
//! Hardware adapters — vendor formats in, canonical [`RfTensor`] out
|
||||
//! (ADR-274 §2.3).
|
||||
//!
|
||||
//! Each adapter performs the same three-stage normalization so every
|
||||
//! downstream consumer sees hardware-invariant data:
|
||||
//!
|
||||
//! 1. **Layout**: reshape the vendor capture to `(links, bins, snapshots)`
|
||||
//! (for FMCW radar this includes the fast-time DFT to range bins), then
|
||||
//! resample both the bin and snapshot axes to
|
||||
//! [`CANONICAL_BINS`] × [`CANONICAL_SNAPSHOTS`].
|
||||
//! 2. **Amplitude**: divide each link by its median amplitude, removing
|
||||
//! front-end gain differences between chipsets (the offset is recorded in
|
||||
//! [`CalibrationMeta::gain_offset_db`] for provenance).
|
||||
//! 3. **Phase**: per (link, snapshot), remove the constant phase offset (CFO
|
||||
//! residual) and the linear ramp across bins (sampling-time offset) via
|
||||
//! least squares — unless the capture declares itself phase-calibrated.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use ndarray::{Array3, Axis};
|
||||
use num_complex::Complex64;
|
||||
use wifi_densepose_core::types::CsiFrame;
|
||||
|
||||
use crate::math::{linear_slope, median, resample_complex};
|
||||
use crate::tensor::{
|
||||
CalibrationMeta, LinkGeometry, RfModality, RfTensor, CANONICAL_BINS, CANONICAL_SNAPSHOTS,
|
||||
};
|
||||
use crate::{Result, UnifiedError};
|
||||
|
||||
/// A raw capture from some hardware front-end, before normalization.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum RawCapture {
|
||||
/// 802.11 CSI: one [`CsiFrame`] per temporal snapshot (all frames must
|
||||
/// share the spatial-stream count) plus per-stream geometry.
|
||||
WifiCsi {
|
||||
/// Snapshots, oldest first.
|
||||
frames: Vec<CsiFrame>,
|
||||
/// One entry per spatial stream (link).
|
||||
links: Vec<LinkGeometry>,
|
||||
/// Age of the oldest snapshot at capture handoff, seconds.
|
||||
age_s: f64,
|
||||
/// Clock quality in `[0, 1]`.
|
||||
clock_quality: f64,
|
||||
},
|
||||
/// FMCW radar cube `(rx_channels, fast_time_samples, chirps)` before the
|
||||
/// range FFT, plus chirp parameters.
|
||||
FmcwRadarCube {
|
||||
/// Raw ADC cube.
|
||||
cube: Array3<Complex64>,
|
||||
/// Per-RX-channel geometry.
|
||||
links: Vec<LinkGeometry>,
|
||||
/// Carrier centre frequency, Hz.
|
||||
center_freq_hz: f64,
|
||||
/// Sweep bandwidth, Hz.
|
||||
bandwidth_hz: f64,
|
||||
/// Age in seconds.
|
||||
age_s: f64,
|
||||
/// Device identifier.
|
||||
device_id: String,
|
||||
},
|
||||
/// UWB channel impulse response `(links, taps, repeats)`.
|
||||
UwbCir {
|
||||
/// CIR taps.
|
||||
taps: Array3<Complex64>,
|
||||
/// Per-link geometry.
|
||||
links: Vec<LinkGeometry>,
|
||||
/// Carrier centre frequency, Hz.
|
||||
center_freq_hz: f64,
|
||||
/// Bandwidth, Hz.
|
||||
bandwidth_hz: f64,
|
||||
/// Age in seconds.
|
||||
age_s: f64,
|
||||
/// Device identifier.
|
||||
device_id: String,
|
||||
},
|
||||
/// 5G NR uplink SRS frequency response `(links, comb_res, symbols)` with
|
||||
/// a comb factor (only every `comb`-th subcarrier is sounded).
|
||||
CellularSrs {
|
||||
/// Comb-sampled frequency response.
|
||||
comb_res: Array3<Complex64>,
|
||||
/// SRS comb factor (2 or 4).
|
||||
comb: usize,
|
||||
/// Per-link geometry.
|
||||
links: Vec<LinkGeometry>,
|
||||
/// Carrier centre frequency, Hz.
|
||||
center_freq_hz: f64,
|
||||
/// Sounded bandwidth, Hz.
|
||||
bandwidth_hz: f64,
|
||||
/// Age in seconds.
|
||||
age_s: f64,
|
||||
/// Device identifier.
|
||||
device_id: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl RawCapture {
|
||||
/// Modality this capture belongs to.
|
||||
#[must_use]
|
||||
pub fn modality(&self) -> RfModality {
|
||||
match self {
|
||||
Self::WifiCsi { .. } => RfModality::WifiCsi,
|
||||
Self::FmcwRadarCube { .. } => RfModality::FmcwRadar,
|
||||
Self::UwbCir { .. } => RfModality::UwbCir,
|
||||
Self::CellularSrs { .. } => RfModality::CellularSrs,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A hardware adapter: normalizes one family of raw captures into the
|
||||
/// canonical tensor. Object-safe so the registry can hold heterogeneous
|
||||
/// adapters behind one interface.
|
||||
pub trait RfAdapter: Send + Sync {
|
||||
/// Modality this adapter accepts.
|
||||
fn modality(&self) -> RfModality;
|
||||
/// Stable hardware identifier, e.g. `"esp32s3-csi"`, `"iwl5300"`.
|
||||
fn hardware_id(&self) -> &str;
|
||||
/// Normalize a raw capture into the canonical tensor.
|
||||
fn normalize(&self, raw: &RawCapture) -> Result<RfTensor>;
|
||||
}
|
||||
|
||||
/// Shared stage 1–3 pipeline: resample to canonical dims, per-link median
|
||||
/// amplitude normalization, per-(link, snapshot) phase detrend.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn finish_normalization(
|
||||
modality: RfModality,
|
||||
mut grid: Array3<Complex64>, // (links, bins, snapshots) at source resolution
|
||||
links: Vec<LinkGeometry>,
|
||||
center_freq_hz: f64,
|
||||
bandwidth_hz: f64,
|
||||
age_s: f64,
|
||||
timestamp_ns: u64,
|
||||
device_id: String,
|
||||
clock_quality: f64,
|
||||
uncertainty: f64,
|
||||
phase_calibrated: bool,
|
||||
) -> Result<RfTensor> {
|
||||
let (n_links, n_bins, n_snaps) = grid.dim();
|
||||
if n_links == 0 || n_bins == 0 || n_snaps == 0 {
|
||||
return Err(UnifiedError::ShapeMismatch("empty raw capture".into()));
|
||||
}
|
||||
|
||||
// Stage 1: resample bin axis then snapshot axis to canonical dims.
|
||||
if n_bins != CANONICAL_BINS {
|
||||
let mut resampled = Array3::zeros((n_links, CANONICAL_BINS, n_snaps));
|
||||
for l in 0..n_links {
|
||||
for s in 0..n_snaps {
|
||||
let col: Vec<Complex64> = (0..n_bins).map(|b| grid[[l, b, s]]).collect();
|
||||
for (b, v) in resample_complex(&col, CANONICAL_BINS).into_iter().enumerate() {
|
||||
resampled[[l, b, s]] = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
grid = resampled;
|
||||
}
|
||||
let n_snaps_now = grid.dim().2;
|
||||
if n_snaps_now != CANONICAL_SNAPSHOTS {
|
||||
let mut resampled = Array3::zeros((n_links, CANONICAL_BINS, CANONICAL_SNAPSHOTS));
|
||||
for l in 0..n_links {
|
||||
for b in 0..CANONICAL_BINS {
|
||||
let row: Vec<Complex64> = (0..n_snaps_now).map(|s| grid[[l, b, s]]).collect();
|
||||
for (s, v) in resample_complex(&row, CANONICAL_SNAPSHOTS).into_iter().enumerate() {
|
||||
resampled[[l, b, s]] = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
grid = resampled;
|
||||
}
|
||||
|
||||
// Stage 2: per-link median amplitude normalization (gain invariance).
|
||||
let mut gain_offset_db = 0.0;
|
||||
for l in 0..n_links {
|
||||
let amps: Vec<f64> = grid.index_axis(Axis(0), l).iter().map(|z| z.norm()).collect();
|
||||
let med = median(&s);
|
||||
if med > 0.0 {
|
||||
gain_offset_db += -20.0 * med.log10();
|
||||
grid.index_axis_mut(Axis(0), l).mapv_inplace(|z| z / med);
|
||||
}
|
||||
}
|
||||
gain_offset_db /= n_links as f64;
|
||||
|
||||
// Stage 3: phase sanitization — remove per-(link, snapshot) constant
|
||||
// offset and linear ramp across bins (CFO residual + sampling-time
|
||||
// offset). Skipped when the front-end already phase-calibrates.
|
||||
if !phase_calibrated {
|
||||
for l in 0..n_links {
|
||||
for s in 0..CANONICAL_SNAPSHOTS {
|
||||
// Unwrap phase across bins before fitting the ramp.
|
||||
let mut phases = Vec::with_capacity(CANONICAL_BINS);
|
||||
let mut prev = grid[[l, 0, s]].arg();
|
||||
phases.push(prev);
|
||||
for b in 1..CANONICAL_BINS {
|
||||
let mut p = grid[[l, b, s]].arg();
|
||||
while p - prev > std::f64::consts::PI {
|
||||
p -= 2.0 * std::f64::consts::PI;
|
||||
}
|
||||
while p - prev < -std::f64::consts::PI {
|
||||
p += 2.0 * std::f64::consts::PI;
|
||||
}
|
||||
phases.push(p);
|
||||
prev = p;
|
||||
}
|
||||
let slope = linear_slope(&phases);
|
||||
let mean = phases.iter().sum::<f64>() / CANONICAL_BINS as f64;
|
||||
let mid = (CANONICAL_BINS as f64 - 1.0) / 2.0;
|
||||
for b in 0..CANONICAL_BINS {
|
||||
let correction = mean + slope * (b as f64 - mid);
|
||||
let rot = Complex64::new(correction.cos(), -correction.sin());
|
||||
grid[[l, b, s]] *= rot;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
RfTensor::new(
|
||||
modality,
|
||||
center_freq_hz,
|
||||
bandwidth_hz,
|
||||
grid,
|
||||
links,
|
||||
age_s,
|
||||
timestamp_ns,
|
||||
device_id,
|
||||
clock_quality,
|
||||
uncertainty,
|
||||
CalibrationMeta {
|
||||
clock_ppm: (1.0 - clock_quality) * 40.0,
|
||||
phase_calibrated: true, // after stage 3 the tensor is detrended
|
||||
gain_offset_db,
|
||||
baseline_id: None,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// 802.11 CSI adapter (ESP32-S3 / Intel-style spatial-stream CSI).
|
||||
pub struct WifiCsiAdapter {
|
||||
hardware_id: String,
|
||||
}
|
||||
|
||||
impl WifiCsiAdapter {
|
||||
/// New adapter for the given hardware id (e.g. `"esp32s3-csi"`).
|
||||
#[must_use]
|
||||
pub fn new(hardware_id: impl Into<String>) -> Self {
|
||||
Self { hardware_id: hardware_id.into() }
|
||||
}
|
||||
}
|
||||
|
||||
impl RfAdapter for WifiCsiAdapter {
|
||||
fn modality(&self) -> RfModality {
|
||||
RfModality::WifiCsi
|
||||
}
|
||||
|
||||
fn hardware_id(&self) -> &str {
|
||||
&self.hardware_id
|
||||
}
|
||||
|
||||
fn normalize(&self, raw: &RawCapture) -> Result<RfTensor> {
|
||||
let RawCapture::WifiCsi { frames, links, age_s, clock_quality } = raw else {
|
||||
return Err(UnifiedError::ModalityMismatch {
|
||||
adapter: self.hardware_id.clone(),
|
||||
got: raw.modality(),
|
||||
});
|
||||
};
|
||||
if frames.is_empty() {
|
||||
return Err(UnifiedError::ShapeMismatch("no CSI frames".into()));
|
||||
}
|
||||
let n_links = frames[0].num_spatial_streams();
|
||||
let n_bins = frames[0].num_subcarriers();
|
||||
for f in frames {
|
||||
if f.num_spatial_streams() != n_links || f.num_subcarriers() != n_bins {
|
||||
return Err(UnifiedError::ShapeMismatch(
|
||||
"inconsistent CSI frame shapes within window".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
let mut grid = Array3::zeros((n_links, n_bins, frames.len()));
|
||||
for (s, f) in frames.iter().enumerate() {
|
||||
for l in 0..n_links {
|
||||
for b in 0..n_bins {
|
||||
grid[[l, b, s]] = f.data[[l, b]];
|
||||
}
|
||||
}
|
||||
}
|
||||
let meta = &frames[0].metadata;
|
||||
// SNR → uncertainty: 0 dB SNR ⇒ 1.0 (noise floor), ≥ 40 dB ⇒ 0.0.
|
||||
let snr_db =
|
||||
frames.iter().map(|f| f.metadata.snr_db()).sum::<f64>() / frames.len() as f64;
|
||||
let uncertainty = (1.0 - snr_db / 40.0).clamp(0.0, 1.0);
|
||||
let center_freq_hz = f64::from(meta.frequency_band.center_frequency_mhz()) * 1e6;
|
||||
let ts = &meta.timestamp;
|
||||
let timestamp_ns = u64::try_from(ts.seconds).unwrap_or(0) * 1_000_000_000 + u64::from(ts.nanos);
|
||||
finish_normalization(
|
||||
RfModality::WifiCsi,
|
||||
grid,
|
||||
links.clone(),
|
||||
center_freq_hz,
|
||||
f64::from(meta.bandwidth_mhz) * 1e6,
|
||||
*age_s,
|
||||
timestamp_ns,
|
||||
meta.device_id.as_str().to_string(),
|
||||
*clock_quality,
|
||||
uncertainty,
|
||||
false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// FMCW radar adapter: fast-time DFT to range bins, then shared pipeline.
|
||||
pub struct FmcwRadarAdapter {
|
||||
hardware_id: String,
|
||||
}
|
||||
|
||||
impl FmcwRadarAdapter {
|
||||
/// New adapter for the given radar front-end id (e.g. `"mr60bha2"`).
|
||||
#[must_use]
|
||||
pub fn new(hardware_id: impl Into<String>) -> Self {
|
||||
Self { hardware_id: hardware_id.into() }
|
||||
}
|
||||
}
|
||||
|
||||
impl RfAdapter for FmcwRadarAdapter {
|
||||
fn modality(&self) -> RfModality {
|
||||
RfModality::FmcwRadar
|
||||
}
|
||||
|
||||
fn hardware_id(&self) -> &str {
|
||||
&self.hardware_id
|
||||
}
|
||||
|
||||
fn normalize(&self, raw: &RawCapture) -> Result<RfTensor> {
|
||||
let RawCapture::FmcwRadarCube { cube, links, center_freq_hz, bandwidth_hz, age_s, device_id } =
|
||||
raw
|
||||
else {
|
||||
return Err(UnifiedError::ModalityMismatch {
|
||||
adapter: self.hardware_id.clone(),
|
||||
got: raw.modality(),
|
||||
});
|
||||
};
|
||||
let (n_rx, n_fast, n_chirps) = cube.dim();
|
||||
if n_rx == 0 || n_fast == 0 || n_chirps == 0 {
|
||||
return Err(UnifiedError::ShapeMismatch("empty radar cube".into()));
|
||||
}
|
||||
// Fast-time DFT: beat-frequency bin b ↔ range bin b. Positive-range
|
||||
// half only (b < n_fast/2) mirrors what commercial FMCW parts export.
|
||||
let n_range = (n_fast / 2).max(1);
|
||||
let mut grid = Array3::zeros((n_rx, n_range, n_chirps));
|
||||
for l in 0..n_rx {
|
||||
for c in 0..n_chirps {
|
||||
for b in 0..n_range {
|
||||
let mut acc = Complex64::new(0.0, 0.0);
|
||||
for t in 0..n_fast {
|
||||
let ang =
|
||||
-2.0 * std::f64::consts::PI * (b as f64) * (t as f64) / (n_fast as f64);
|
||||
acc += cube[[l, t, c]] * Complex64::new(ang.cos(), ang.sin());
|
||||
}
|
||||
grid[[l, b, c]] = acc / n_fast as f64;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Range profiles are already phase-meaningful per bin; the linear
|
||||
// detrend would erase target range information, so radar declares
|
||||
// itself phase-calibrated and only amplitude-normalizes.
|
||||
finish_normalization(
|
||||
RfModality::FmcwRadar,
|
||||
grid,
|
||||
links.clone(),
|
||||
*center_freq_hz,
|
||||
*bandwidth_hz,
|
||||
*age_s,
|
||||
0,
|
||||
device_id.clone(),
|
||||
0.9,
|
||||
0.1,
|
||||
true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// UWB CIR adapter: taps are already a delay-domain profile.
|
||||
pub struct UwbCirAdapter {
|
||||
hardware_id: String,
|
||||
}
|
||||
|
||||
impl UwbCirAdapter {
|
||||
/// New adapter for the given UWB chip id (e.g. `"dw3000"`).
|
||||
#[must_use]
|
||||
pub fn new(hardware_id: impl Into<String>) -> Self {
|
||||
Self { hardware_id: hardware_id.into() }
|
||||
}
|
||||
}
|
||||
|
||||
impl RfAdapter for UwbCirAdapter {
|
||||
fn modality(&self) -> RfModality {
|
||||
RfModality::UwbCir
|
||||
}
|
||||
|
||||
fn hardware_id(&self) -> &str {
|
||||
&self.hardware_id
|
||||
}
|
||||
|
||||
fn normalize(&self, raw: &RawCapture) -> Result<RfTensor> {
|
||||
let RawCapture::UwbCir { taps, links, center_freq_hz, bandwidth_hz, age_s, device_id } = raw
|
||||
else {
|
||||
return Err(UnifiedError::ModalityMismatch {
|
||||
adapter: self.hardware_id.clone(),
|
||||
got: raw.modality(),
|
||||
});
|
||||
};
|
||||
finish_normalization(
|
||||
RfModality::UwbCir,
|
||||
taps.clone(),
|
||||
links.clone(),
|
||||
*center_freq_hz,
|
||||
*bandwidth_hz,
|
||||
*age_s,
|
||||
0,
|
||||
device_id.clone(),
|
||||
0.95, // UWB timestamps are hardware-disciplined
|
||||
0.05,
|
||||
true, // delay-domain taps: detrending would destroy ToF structure
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 5G NR SRS adapter: de-comb by interpolating the unsounded subcarriers.
|
||||
pub struct CellularSrsAdapter {
|
||||
hardware_id: String,
|
||||
}
|
||||
|
||||
impl CellularSrsAdapter {
|
||||
/// New adapter for the given gNB/xApp source id (e.g. `"oai-srs-xapp"`).
|
||||
#[must_use]
|
||||
pub fn new(hardware_id: impl Into<String>) -> Self {
|
||||
Self { hardware_id: hardware_id.into() }
|
||||
}
|
||||
}
|
||||
|
||||
impl RfAdapter for CellularSrsAdapter {
|
||||
fn modality(&self) -> RfModality {
|
||||
RfModality::CellularSrs
|
||||
}
|
||||
|
||||
fn hardware_id(&self) -> &str {
|
||||
&self.hardware_id
|
||||
}
|
||||
|
||||
fn normalize(&self, raw: &RawCapture) -> Result<RfTensor> {
|
||||
let RawCapture::CellularSrs {
|
||||
comb_res,
|
||||
comb,
|
||||
links,
|
||||
center_freq_hz,
|
||||
bandwidth_hz,
|
||||
age_s,
|
||||
device_id,
|
||||
} = raw
|
||||
else {
|
||||
return Err(UnifiedError::ModalityMismatch {
|
||||
adapter: self.hardware_id.clone(),
|
||||
got: raw.modality(),
|
||||
});
|
||||
};
|
||||
if *comb == 0 {
|
||||
return Err(UnifiedError::InvalidInput("SRS comb factor must be >= 1".into()));
|
||||
}
|
||||
let (n_links, n_res, n_sym) = comb_res.dim();
|
||||
if n_links == 0 || n_res == 0 || n_sym == 0 {
|
||||
return Err(UnifiedError::ShapeMismatch("empty SRS capture".into()));
|
||||
}
|
||||
// De-comb: the comb-sampled response is a uniform subsampling of the
|
||||
// full band, so linear interpolation onto comb×n_res bins restores a
|
||||
// dense grid before canonical resampling.
|
||||
let dense_bins = n_res * comb;
|
||||
let mut grid = Array3::zeros((n_links, dense_bins, n_sym));
|
||||
for l in 0..n_links {
|
||||
for s in 0..n_sym {
|
||||
let col: Vec<Complex64> = (0..n_res).map(|b| comb_res[[l, b, s]]).collect();
|
||||
for (b, v) in resample_complex(&col, dense_bins).into_iter().enumerate() {
|
||||
grid[[l, b, s]] = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
finish_normalization(
|
||||
RfModality::CellularSrs,
|
||||
grid,
|
||||
links.clone(),
|
||||
*center_freq_hz,
|
||||
*bandwidth_hz,
|
||||
*age_s,
|
||||
0,
|
||||
device_id.clone(),
|
||||
0.99, // gNB reference clock
|
||||
0.1,
|
||||
false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Registry mapping hardware ids to adapters (ADR-274 §2.4). Lookup is
|
||||
/// fail-closed: unknown hardware is an error, never a silent default.
|
||||
#[derive(Default)]
|
||||
pub struct AdapterRegistry {
|
||||
adapters: HashMap<String, Box<dyn RfAdapter>>,
|
||||
}
|
||||
|
||||
impl AdapterRegistry {
|
||||
/// Empty registry.
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Registry pre-populated with the four reference adapters.
|
||||
#[must_use]
|
||||
pub fn with_reference_adapters() -> Self {
|
||||
let mut r = Self::new();
|
||||
r.register(Box::new(WifiCsiAdapter::new("esp32s3-csi")));
|
||||
r.register(Box::new(FmcwRadarAdapter::new("mr60bha2")));
|
||||
r.register(Box::new(UwbCirAdapter::new("dw3000")));
|
||||
r.register(Box::new(CellularSrsAdapter::new("oai-srs-xapp")));
|
||||
r
|
||||
}
|
||||
|
||||
/// Registers an adapter under its hardware id (replaces any previous).
|
||||
pub fn register(&mut self, adapter: Box<dyn RfAdapter>) {
|
||||
self.adapters.insert(adapter.hardware_id().to_string(), adapter);
|
||||
}
|
||||
|
||||
/// Normalizes a capture with the adapter registered for `hardware_id`.
|
||||
pub fn normalize(&self, hardware_id: &str, raw: &RawCapture) -> Result<RfTensor> {
|
||||
self.adapters
|
||||
.get(hardware_id)
|
||||
.ok_or_else(|| UnifiedError::UnknownHardware(hardware_id.to_string()))?
|
||||
.normalize(raw)
|
||||
}
|
||||
|
||||
/// Registered hardware ids (sorted, for deterministic display).
|
||||
#[must_use]
|
||||
pub fn hardware_ids(&self) -> Vec<&str> {
|
||||
let mut ids: Vec<&str> = self.adapters.keys().map(String::as_str).collect();
|
||||
ids.sort_unstable();
|
||||
ids
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ndarray::Array2;
|
||||
use wifi_densepose_core::types::{CsiMetadata, DeviceId, FrequencyBand};
|
||||
|
||||
fn test_links(n: usize) -> Vec<LinkGeometry> {
|
||||
(0..n)
|
||||
.map(|i| LinkGeometry {
|
||||
tx_pos: [0.0, 0.0, 2.0],
|
||||
rx_pos: [4.0, i as f64 * 0.05, 2.0],
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// CSI frames with a known linear phase ramp + constant offset and a
|
||||
/// gain factor — exactly what stages 2–3 must remove.
|
||||
fn ramped_frames(n_frames: usize, n_streams: usize, n_sub: usize) -> Vec<CsiFrame> {
|
||||
(0..n_frames)
|
||||
.map(|s| {
|
||||
let meta = CsiMetadata::new(
|
||||
DeviceId::new("esp32s3-a1"),
|
||||
FrequencyBand::Band2_4GHz,
|
||||
6,
|
||||
);
|
||||
let data = Array2::from_shape_fn((n_streams, n_sub), |(l, b)| {
|
||||
let gain = 3.7 * (1.0 + l as f64);
|
||||
let phase = 0.9 + 0.11 * b as f64 + 0.01 * s as f64;
|
||||
Complex64::new(0.0, phase).exp() * gain
|
||||
});
|
||||
CsiFrame::new(meta, data)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wifi_adapter_normalizes_shape_gain_and_phase() {
|
||||
let adapter = WifiCsiAdapter::new("esp32s3-csi");
|
||||
let raw = RawCapture::WifiCsi {
|
||||
frames: ramped_frames(12, 2, 114),
|
||||
links: test_links(2),
|
||||
age_s: 0.02,
|
||||
clock_quality: 0.7,
|
||||
};
|
||||
let t = adapter.normalize(&raw).expect("normalizes");
|
||||
assert_eq!(t.dims(), (2, CANONICAL_BINS, CANONICAL_SNAPSHOTS));
|
||||
assert_eq!(t.modality, RfModality::WifiCsi);
|
||||
|
||||
// Gain invariance: median amplitude per link ≈ 1 after stage 2.
|
||||
for l in 0..2 {
|
||||
let amps: Vec<f64> =
|
||||
t.data.index_axis(Axis(0), l).iter().map(|z| z.norm()).collect();
|
||||
assert!((median(&s) - 1.0).abs() < 1e-9, "link {l} median {:?}", median(&s));
|
||||
}
|
||||
|
||||
// Phase sanitization: the constant-plus-ramp phase must be gone.
|
||||
// Bound is 1e-4 rad: complex linear resampling (114→56 bins,
|
||||
// 12→8 snapshots) leaves second-order chord-vs-arc phase residue
|
||||
// of a few µrad on top of the exact detrend.
|
||||
for l in 0..2 {
|
||||
for s in 0..CANONICAL_SNAPSHOTS {
|
||||
for b in 0..CANONICAL_BINS {
|
||||
assert!(
|
||||
t.data[[l, b, s]].arg().abs() < 1e-4,
|
||||
"residual phase at ({l},{b},{s}): {}",
|
||||
t.data[[l, b, s]].arg()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn radar_adapter_localizes_beat_tone_to_range_bin() {
|
||||
// Beat tone at fast-time bin 9 of 64 ⇒ range profile peak at bin 9,
|
||||
// which the canonical resampler maps to 9 · (56−1)/(32−1) ≈ 16.
|
||||
let n_fast = 64;
|
||||
let cube = Array3::from_shape_fn((1, n_fast, 16), |(_, t, _)| {
|
||||
let ang = 2.0 * std::f64::consts::PI * 9.0 * t as f64 / n_fast as f64;
|
||||
Complex64::new(ang.cos(), ang.sin())
|
||||
});
|
||||
let adapter = FmcwRadarAdapter::new("mr60bha2");
|
||||
let raw = RawCapture::FmcwRadarCube {
|
||||
cube,
|
||||
links: test_links(1),
|
||||
center_freq_hz: 60e9,
|
||||
bandwidth_hz: 1e9,
|
||||
age_s: 0.0,
|
||||
device_id: "mr60".into(),
|
||||
};
|
||||
let t = adapter.normalize(&raw).expect("normalizes");
|
||||
assert_eq!(t.dims(), (1, CANONICAL_BINS, CANONICAL_SNAPSHOTS));
|
||||
let amps: Vec<f64> =
|
||||
(0..CANONICAL_BINS).map(|b| t.data[[0, b, 0]].norm()).collect();
|
||||
let peak = amps
|
||||
.iter()
|
||||
.enumerate()
|
||||
.max_by(|a, b| a.1.partial_cmp(b.1).unwrap())
|
||||
.unwrap()
|
||||
.0;
|
||||
let expected = (9.0 * (CANONICAL_BINS as f64 - 1.0) / 31.0).round() as usize;
|
||||
assert!(
|
||||
peak.abs_diff(expected) <= 1,
|
||||
"range peak at bin {peak}, expected ≈{expected}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn srs_adapter_decombs_and_normalizes() {
|
||||
let adapter = CellularSrsAdapter::new("oai-srs-xapp");
|
||||
let comb_res = Array3::from_shape_fn((1, 24, 4), |(_, b, _)| {
|
||||
Complex64::new(1.0 + 0.01 * b as f64, 0.0)
|
||||
});
|
||||
let raw = RawCapture::CellularSrs {
|
||||
comb_res,
|
||||
comb: 2,
|
||||
links: test_links(1),
|
||||
center_freq_hz: 3.5e9,
|
||||
bandwidth_hz: 40e6,
|
||||
age_s: 0.001,
|
||||
device_id: "gnb-1".into(),
|
||||
};
|
||||
let t = adapter.normalize(&raw).expect("normalizes");
|
||||
assert_eq!(t.dims(), (1, CANONICAL_BINS, CANONICAL_SNAPSHOTS));
|
||||
assert_eq!(t.modality, RfModality::CellularSrs);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_is_fail_closed_and_type_safe() {
|
||||
let registry = AdapterRegistry::with_reference_adapters();
|
||||
assert_eq!(
|
||||
registry.hardware_ids(),
|
||||
vec!["dw3000", "esp32s3-csi", "mr60bha2", "oai-srs-xapp"]
|
||||
);
|
||||
|
||||
// Unknown hardware ⇒ error, never a default adapter.
|
||||
let raw = RawCapture::UwbCir {
|
||||
taps: Array3::from_elem((1, 32, 4), Complex64::new(1.0, 0.0)),
|
||||
links: test_links(1),
|
||||
center_freq_hz: 6.5e9,
|
||||
bandwidth_hz: 500e6,
|
||||
age_s: 0.0,
|
||||
device_id: "dw".into(),
|
||||
};
|
||||
assert!(matches!(
|
||||
registry.normalize("unknown-chip", &raw),
|
||||
Err(UnifiedError::UnknownHardware(_))
|
||||
));
|
||||
|
||||
// Wrong modality for the adapter ⇒ typed mismatch error.
|
||||
assert!(matches!(
|
||||
registry.normalize("esp32s3-csi", &raw),
|
||||
Err(UnifiedError::ModalityMismatch { .. })
|
||||
));
|
||||
|
||||
// Right adapter succeeds.
|
||||
assert!(registry.normalize("dw3000", &raw).is_ok());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
//! Universal RF foundation encoder (ADR-274 §3).
|
||||
//!
|
||||
//! A deliberately small, pure-Rust, exactly-differentiable network. The
|
||||
//! representation contract is the one ADR-273 fixes:
|
||||
//!
|
||||
//! ```text
|
||||
//! z = Encoder(tokens) ⊙ σ(AgeEncoder(age)) + GeometryEncoder(sensor_pose)
|
||||
//! ```
|
||||
//!
|
||||
//! Architecture (all f64, weights row-major):
|
||||
//!
|
||||
//! ```text
|
||||
//! h_i = tanh(W1·x_i + b1) token embedding (unmasked tokens)
|
||||
//! c = mean_i h_i permutation-invariant context pool
|
||||
//! m = tanh(W2·c + b2) context mixing 1
|
||||
//! g = tanh(W2b·m + b2b) context mixing 2
|
||||
//! gate = σ(age_w·age + age_b) multiplicative freshness gate
|
||||
//! z = g ⊙ gate + Wg·geo + bg fused window representation
|
||||
//! ```
|
||||
//!
|
||||
//! Pretraining (see [`crate::pretrain`]) reconstructs *masked* tokens from
|
||||
//! `[z ; position_encoding(j)]` through a linear head `W3, b3` that is
|
||||
//! discarded at deployment. The backward pass is hand-derived and verified
|
||||
//! against central finite differences in `pretrain::tests` — the gradient
|
||||
//! check is the crate's proof that this module computes what it claims.
|
||||
|
||||
use rand_chacha::ChaCha20Rng;
|
||||
|
||||
use crate::math::{sigmoid, xavier_init};
|
||||
use crate::tokenizer::{position_encoding, RfToken, TokenizedWindow, D_IN, D_POS};
|
||||
|
||||
/// Dense row-major matrix with a bias vector (one linear layer).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Linear {
|
||||
/// Output rows.
|
||||
pub rows: usize,
|
||||
/// Input columns.
|
||||
pub cols: usize,
|
||||
/// Row-major weights, `rows × cols`.
|
||||
pub w: Vec<f64>,
|
||||
/// Bias, length `rows`.
|
||||
pub b: Vec<f64>,
|
||||
}
|
||||
|
||||
impl Linear {
|
||||
/// Xavier-initialized layer.
|
||||
#[must_use]
|
||||
pub fn new(rng: &mut ChaCha20Rng, rows: usize, cols: usize) -> Self {
|
||||
Self { rows, cols, w: xavier_init(rng, rows, cols), b: vec![0.0; rows] }
|
||||
}
|
||||
|
||||
/// Zeroed layer with the same shape (gradient accumulator).
|
||||
#[must_use]
|
||||
pub fn zeros_like(&self) -> Self {
|
||||
Self { rows: self.rows, cols: self.cols, w: vec![0.0; self.w.len()], b: vec![0.0; self.rows] }
|
||||
}
|
||||
|
||||
/// `y = W·x + b`.
|
||||
#[must_use]
|
||||
pub fn forward(&self, x: &[f64]) -> Vec<f64> {
|
||||
debug_assert_eq!(x.len(), self.cols);
|
||||
let mut y = self.b.clone();
|
||||
for r in 0..self.rows {
|
||||
let row = &self.w[r * self.cols..(r + 1) * self.cols];
|
||||
let mut acc = 0.0;
|
||||
for (wv, xv) in row.iter().zip(x) {
|
||||
acc += wv * xv;
|
||||
}
|
||||
y[r] += acc;
|
||||
}
|
||||
y
|
||||
}
|
||||
|
||||
/// `x_grad = Wᵀ·dy` (input gradient).
|
||||
#[must_use]
|
||||
pub fn backward_input(&self, dy: &[f64]) -> Vec<f64> {
|
||||
let mut dx = vec![0.0; self.cols];
|
||||
for r in 0..self.rows {
|
||||
let row = &self.w[r * self.cols..(r + 1) * self.cols];
|
||||
for (c, wv) in row.iter().enumerate() {
|
||||
dx[c] += wv * dy[r];
|
||||
}
|
||||
}
|
||||
dx
|
||||
}
|
||||
|
||||
/// Accumulates `dW += dy ⊗ x`, `db += dy` into `grad`.
|
||||
pub fn accumulate_grad(&self, grad: &mut Linear, dy: &[f64], x: &[f64]) {
|
||||
for r in 0..self.rows {
|
||||
grad.b[r] += dy[r];
|
||||
let row = &mut grad.w[r * self.cols..(r + 1) * self.cols];
|
||||
for (c, xv) in x.iter().enumerate() {
|
||||
row[c] += dy[r] * xv;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// SGD step: `p -= lr·g`.
|
||||
pub fn sgd(&mut self, grad: &Linear, lr: f64) {
|
||||
for (p, g) in self.w.iter_mut().zip(&grad.w) {
|
||||
*p -= lr * g;
|
||||
}
|
||||
for (p, g) in self.b.iter_mut().zip(&grad.b) {
|
||||
*p -= lr * g;
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of parameters (weights + biases).
|
||||
#[must_use]
|
||||
pub fn param_count(&self) -> usize {
|
||||
self.w.len() + self.b.len()
|
||||
}
|
||||
}
|
||||
|
||||
/// Encoder hyper-parameters. The default is the deployment config; tests use
|
||||
/// tiny configs for the finite-difference gradient check.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct EncoderConfig {
|
||||
/// Token feature dimension.
|
||||
pub d_in: usize,
|
||||
/// Position-encoding dimension (pretraining decoder only).
|
||||
pub d_pos: usize,
|
||||
/// Model (representation) dimension.
|
||||
pub d_model: usize,
|
||||
}
|
||||
|
||||
impl Default for EncoderConfig {
|
||||
fn default() -> Self {
|
||||
Self { d_in: D_IN, d_pos: D_POS, d_model: 128 }
|
||||
}
|
||||
}
|
||||
|
||||
/// Window-level context consumed by the fusion stage.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct WindowContext {
|
||||
/// Sample age in seconds.
|
||||
pub age_s: f64,
|
||||
/// Geometry summary (mean TX xyz, mean RX xyz, decametres).
|
||||
pub geometry: [f64; 6],
|
||||
}
|
||||
|
||||
impl From<&TokenizedWindow> for WindowContext {
|
||||
fn from(w: &TokenizedWindow) -> Self {
|
||||
Self { age_s: w.age_s, geometry: w.geometry }
|
||||
}
|
||||
}
|
||||
|
||||
/// Intermediate activations kept for the backward pass.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ForwardCache {
|
||||
/// Indices of unmasked tokens (context providers).
|
||||
pub unmasked: Vec<usize>,
|
||||
/// Embeddings `h_i` for unmasked tokens (parallel to `unmasked`).
|
||||
pub h: Vec<Vec<f64>>,
|
||||
/// Pooled context `c`.
|
||||
pub c: Vec<f64>,
|
||||
/// Mixing activations `m`, `g`.
|
||||
pub m: Vec<f64>,
|
||||
/// Second mixing output.
|
||||
pub g: Vec<f64>,
|
||||
/// Freshness gate `σ(age_w·age + age_b)`.
|
||||
pub gate: Vec<f64>,
|
||||
/// Fused representation `z`.
|
||||
pub z: Vec<f64>,
|
||||
/// Window context used.
|
||||
pub ctx: WindowContext,
|
||||
}
|
||||
|
||||
/// The universal RF foundation encoder.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RfEncoder {
|
||||
/// Config.
|
||||
pub cfg: EncoderConfig,
|
||||
/// Token embedding.
|
||||
pub w1: Linear,
|
||||
/// Context mixing 1.
|
||||
pub w2: Linear,
|
||||
/// Context mixing 2.
|
||||
pub w2b: Linear,
|
||||
/// Age gate weight (elementwise on the scalar age).
|
||||
pub age_w: Vec<f64>,
|
||||
/// Age gate bias.
|
||||
pub age_b: Vec<f64>,
|
||||
/// Geometry encoder.
|
||||
pub wg: Linear,
|
||||
/// Masked-token reconstruction head (pretraining only; not counted as a
|
||||
/// deployment adapter).
|
||||
pub w3: Linear,
|
||||
}
|
||||
|
||||
impl RfEncoder {
|
||||
/// Deterministically initialized encoder.
|
||||
#[must_use]
|
||||
pub fn new(cfg: EncoderConfig, seed: u64) -> Self {
|
||||
let mut rng = crate::math::seeded_rng(seed);
|
||||
let h = cfg.d_model;
|
||||
Self {
|
||||
cfg,
|
||||
w1: Linear::new(&mut rng, h, cfg.d_in),
|
||||
w2: Linear::new(&mut rng, h, h),
|
||||
w2b: Linear::new(&mut rng, h, h),
|
||||
age_w: xavier_init(&mut rng, h, 1),
|
||||
age_b: vec![0.0; h],
|
||||
wg: Linear::new(&mut rng, h, 6),
|
||||
w3: Linear::new(&mut rng, cfg.d_in, h + cfg.d_pos),
|
||||
}
|
||||
}
|
||||
|
||||
/// Total trainable parameters (the "backbone" for the ≤1 % adapter
|
||||
/// budget of ADR-273's acceptance test).
|
||||
#[must_use]
|
||||
pub fn param_count(&self) -> usize {
|
||||
self.w1.param_count()
|
||||
+ self.w2.param_count()
|
||||
+ self.w2b.param_count()
|
||||
+ self.age_w.len()
|
||||
+ self.age_b.len()
|
||||
+ self.wg.param_count()
|
||||
+ self.w3.param_count()
|
||||
}
|
||||
|
||||
/// Forward pass over the unmasked token set, producing the fused window
|
||||
/// representation `z` and the cache needed for backprop.
|
||||
///
|
||||
/// `masked` lists token indices excluded from the context pool (empty at
|
||||
/// inference time).
|
||||
#[must_use]
|
||||
pub fn forward(&self, tokens: &[RfToken], masked: &[usize], ctx: WindowContext) -> ForwardCache {
|
||||
let h_dim = self.cfg.d_model;
|
||||
let unmasked: Vec<usize> =
|
||||
(0..tokens.len()).filter(|i| !masked.contains(i)).collect();
|
||||
assert!(!unmasked.is_empty(), "cannot encode a fully masked window");
|
||||
|
||||
let mut h = Vec::with_capacity(unmasked.len());
|
||||
let mut c = vec![0.0; h_dim];
|
||||
for &i in &unmasked {
|
||||
let mut hi = self.w1.forward(&tokens[i].features);
|
||||
for v in &mut hi {
|
||||
*v = v.tanh();
|
||||
}
|
||||
for (cv, hv) in c.iter_mut().zip(&hi) {
|
||||
*cv += hv;
|
||||
}
|
||||
h.push(hi);
|
||||
}
|
||||
for cv in &mut c {
|
||||
*cv /= unmasked.len() as f64;
|
||||
}
|
||||
|
||||
let mut m = self.w2.forward(&c);
|
||||
for v in &mut m {
|
||||
*v = v.tanh();
|
||||
}
|
||||
let mut g = self.w2b.forward(&m);
|
||||
for v in &mut g {
|
||||
*v = v.tanh();
|
||||
}
|
||||
|
||||
let gate: Vec<f64> = self
|
||||
.age_w
|
||||
.iter()
|
||||
.zip(&self.age_b)
|
||||
.map(|(w, b)| sigmoid(w * ctx.age_s + b))
|
||||
.collect();
|
||||
|
||||
let geo = self.wg.forward(&ctx.geometry);
|
||||
let z: Vec<f64> =
|
||||
(0..h_dim).map(|k| g[k] * gate[k] + geo[k]).collect();
|
||||
|
||||
ForwardCache { unmasked, h, c, m, g, gate, z, ctx }
|
||||
}
|
||||
|
||||
/// Inference entry point: encode a full window (nothing masked) into the
|
||||
/// fused representation `z = g ⊙ gate + Wg·geo + bg`.
|
||||
///
|
||||
/// Use `z` for geometry-conditioned tasks (localization, channel
|
||||
/// prediction) where sensor pose is signal, not nuisance.
|
||||
#[must_use]
|
||||
pub fn encode(&self, window: &TokenizedWindow) -> Vec<f64> {
|
||||
self.forward(&window.tokens, &[], WindowContext::from(window)).z
|
||||
}
|
||||
|
||||
/// Environment-invariant content representation
|
||||
/// `[g ⊙ gate ; mean_i(x_i)]` — the fused `z` *without* the additive
|
||||
/// geometry term, concatenated with the window-mean token features
|
||||
/// (dimension `d_model + d_in`).
|
||||
///
|
||||
/// Two deliberate choices, both anti-leakage (ADR-273 §5):
|
||||
/// - The PerceptAlign lesson: geometry should *condition* spatial tasks,
|
||||
/// but for environment-invariant heads (presence, activity, anomaly)
|
||||
/// the additive `Wg·geo` term is a room-specific offset a linear
|
||||
/// adapter would memorize.
|
||||
/// - The skip connection exposes pooled token statistics (Doppler
|
||||
/// energy, temporal variance, freshness) whose *semantics are
|
||||
/// identical in every room*, so a small head can generalize across
|
||||
/// environments instead of re-deriving them through mixing layers that
|
||||
/// entangle them with room-specific fading structure.
|
||||
#[must_use]
|
||||
pub fn encode_content(&self, window: &TokenizedWindow) -> Vec<f64> {
|
||||
let cache = self.forward(&window.tokens, &[], WindowContext::from(window));
|
||||
let mut out: Vec<f64> =
|
||||
(0..self.cfg.d_model).map(|k| cache.g[k] * cache.gate[k]).collect();
|
||||
let n = window.tokens.len().max(1) as f64;
|
||||
let mut mean = vec![0.0; self.cfg.d_in];
|
||||
for t in &window.tokens {
|
||||
for (m, v) in mean.iter_mut().zip(&t.features) {
|
||||
*m += v / n;
|
||||
}
|
||||
}
|
||||
out.extend_from_slice(&mean);
|
||||
out
|
||||
}
|
||||
|
||||
/// Dimension of the [`Self::encode_content`] representation.
|
||||
#[must_use]
|
||||
pub fn content_dim(&self) -> usize {
|
||||
self.cfg.d_model + self.cfg.d_in
|
||||
}
|
||||
|
||||
/// Reconstruction of masked token `j` from the cache: `W3·[z ; pos(j)]`.
|
||||
#[must_use]
|
||||
pub fn reconstruct(&self, cache: &ForwardCache, token_idx: usize) -> Vec<f64> {
|
||||
let mut u = cache.z.clone();
|
||||
u.extend_from_slice(&position_encoding(token_idx)[..self.cfg.d_pos]);
|
||||
self.w3.forward(&u)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn toy_tokens(n: usize) -> Vec<RfToken> {
|
||||
(0..n)
|
||||
.map(|i| {
|
||||
let mut f = [0.0f64; D_IN];
|
||||
for (k, v) in f.iter_mut().enumerate() {
|
||||
*v = ((i * 31 + k * 7) % 13) as f64 / 13.0 - 0.5;
|
||||
}
|
||||
RfToken { features: f, link: 0, group: i }
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encoder_is_deterministic_given_seed() {
|
||||
let a = RfEncoder::new(EncoderConfig::default(), 42);
|
||||
let b = RfEncoder::new(EncoderConfig::default(), 42);
|
||||
assert_eq!(a.w1.w, b.w1.w);
|
||||
let tokens = toy_tokens(6);
|
||||
let ctx = WindowContext { age_s: 0.1, geometry: [0.1; 6] };
|
||||
assert_eq!(a.forward(&tokens, &[], ctx).z, b.forward(&tokens, &[], ctx).z);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn param_count_matches_hand_computation() {
|
||||
let e = RfEncoder::new(EncoderConfig::default(), 1);
|
||||
let h = 128;
|
||||
let expected = (h * D_IN + h) // w1
|
||||
+ (h * h + h) // w2
|
||||
+ (h * h + h) // w2b
|
||||
+ h + h // age_w, age_b
|
||||
+ (h * 6 + h) // wg
|
||||
+ (D_IN * (h + D_POS) + D_IN); // w3
|
||||
assert_eq!(e.param_count(), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_windows_are_gated_toward_geometry_prior() {
|
||||
// As age → ∞ with negative gate logits, σ → 0 or 1 per unit; what we
|
||||
// verify is the *contract*: z depends on age only through the gate,
|
||||
// so two ages produce different z while geometry contribution stays.
|
||||
let e = RfEncoder::new(EncoderConfig::default(), 3);
|
||||
let tokens = toy_tokens(8);
|
||||
let fresh = e.forward(&tokens, &[], WindowContext { age_s: 0.0, geometry: [0.2; 6] });
|
||||
let stale = e.forward(&tokens, &[], WindowContext { age_s: 9.0, geometry: [0.2; 6] });
|
||||
assert_ne!(fresh.z, stale.z);
|
||||
// Same age, different geometry ⇒ additive path shifts z.
|
||||
let moved = e.forward(&tokens, &[], WindowContext { age_s: 0.0, geometry: [0.4; 6] });
|
||||
assert_ne!(fresh.z, moved.z);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn masking_excludes_tokens_from_context() {
|
||||
let e = RfEncoder::new(EncoderConfig::default(), 5);
|
||||
let tokens = toy_tokens(8);
|
||||
let ctx = WindowContext { age_s: 0.1, geometry: [0.0; 6] };
|
||||
let full = e.forward(&tokens, &[], ctx);
|
||||
let masked = e.forward(&tokens, &[2, 5], ctx);
|
||||
assert_eq!(masked.unmasked.len(), 6);
|
||||
assert_ne!(full.z, masked.z);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
//! Anti-leakage evaluation protocol (ADR-273 §5).
|
||||
//!
|
||||
//! The biggest failure mode of RF sensing results is domain leakage
|
||||
//! disguised as accuracy: random frame splits let a model recognize the
|
||||
//! room, session, person, device, or trajectory instead of learning
|
||||
//! transferable physics. The fix is structural, not statistical:
|
||||
//!
|
||||
//! - [`StrictSplit`] holds out **complete values** of a partition dimension
|
||||
//! (room / day / person / chipset / firmware / antenna layout) and proves
|
||||
//! the train and test sides share none of them.
|
||||
//! - [`expected_calibration_error`] and [`selective_metrics`] make
|
||||
//! confidence quality and abstention first-class metrics: for
|
||||
//! safety-relevant uses an uncertain result must become *no decision*.
|
||||
//! - [`relative_degradation`] tracks known→unknown condition degradation
|
||||
//! (the ADR-273 acceptance gate is < 20 %).
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
/// Provenance key of one sample — every dimension that could leak identity
|
||||
/// or environment structure into a random split.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
pub struct PartitionKey {
|
||||
/// Room / environment identifier.
|
||||
pub room: String,
|
||||
/// Capture day (coarse session time).
|
||||
pub day: String,
|
||||
/// Person identifier (or "none").
|
||||
pub person: String,
|
||||
/// Chipset family.
|
||||
pub chipset: String,
|
||||
/// Firmware version.
|
||||
pub firmware: String,
|
||||
/// Antenna layout identifier.
|
||||
pub layout: String,
|
||||
}
|
||||
|
||||
/// Which dimension of [`PartitionKey`] a split holds out.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PartitionDim {
|
||||
/// Hold out complete rooms.
|
||||
Room,
|
||||
/// Hold out complete days.
|
||||
Day,
|
||||
/// Hold out complete people.
|
||||
Person,
|
||||
/// Hold out complete chipsets.
|
||||
Chipset,
|
||||
/// Hold out complete firmware versions.
|
||||
Firmware,
|
||||
/// Hold out complete antenna layouts.
|
||||
Layout,
|
||||
}
|
||||
|
||||
impl PartitionDim {
|
||||
fn value<'a>(&self, k: &'a PartitionKey) -> &'a str {
|
||||
match self {
|
||||
Self::Room => &k.room,
|
||||
Self::Day => &k.day,
|
||||
Self::Person => &k.person,
|
||||
Self::Chipset => &k.chipset,
|
||||
Self::Firmware => &k.firmware,
|
||||
Self::Layout => &k.layout,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A train/test index split with a proof-of-disjointness certificate.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StrictSplit {
|
||||
/// Training sample indices.
|
||||
pub train: Vec<usize>,
|
||||
/// Held-out test sample indices.
|
||||
pub test: Vec<usize>,
|
||||
/// Dimension that was held out.
|
||||
pub dim: PartitionDim,
|
||||
}
|
||||
|
||||
impl StrictSplit {
|
||||
/// Splits by holding out every sample whose `dim` value is in
|
||||
/// `holdout_values`. Guaranteed disjoint by construction; [`Self::verify`]
|
||||
/// re-checks it independently (belt and braces for downstream callers
|
||||
/// that mutate splits).
|
||||
#[must_use]
|
||||
pub fn holdout(keys: &[PartitionKey], dim: PartitionDim, holdout_values: &[&str]) -> Self {
|
||||
let held: BTreeSet<&str> = holdout_values.iter().copied().collect();
|
||||
let mut train = Vec::new();
|
||||
let mut test = Vec::new();
|
||||
for (i, k) in keys.iter().enumerate() {
|
||||
if held.contains(dim.value(k)) {
|
||||
test.push(i);
|
||||
} else {
|
||||
train.push(i);
|
||||
}
|
||||
}
|
||||
Self { train, test, dim }
|
||||
}
|
||||
|
||||
/// Independently verifies that no held-out dimension value appears on
|
||||
/// the training side (and vice versa). Returns `false` on any leak.
|
||||
#[must_use]
|
||||
pub fn verify(&self, keys: &[PartitionKey]) -> bool {
|
||||
let train_vals: BTreeSet<&str> =
|
||||
self.train.iter().map(|&i| self.dim.value(&keys[i])).collect();
|
||||
let test_vals: BTreeSet<&str> =
|
||||
self.test.iter().map(|&i| self.dim.value(&keys[i])).collect();
|
||||
train_vals.is_disjoint(&test_vals)
|
||||
}
|
||||
}
|
||||
|
||||
/// Expected Calibration Error over equal-width confidence bins.
|
||||
///
|
||||
/// `probs[i]` is the predicted probability of the positive class;
|
||||
/// `labels[i]` the truth. ECE = Σ (|bin|/N) · |accuracy(bin) − confidence(bin)|.
|
||||
#[must_use]
|
||||
pub fn expected_calibration_error(probs: &[f64], labels: &[bool], n_bins: usize) -> f64 {
|
||||
assert_eq!(probs.len(), labels.len());
|
||||
assert!(n_bins > 0);
|
||||
let n = probs.len();
|
||||
if n == 0 {
|
||||
return 0.0;
|
||||
}
|
||||
let mut ece = 0.0;
|
||||
for b in 0..n_bins {
|
||||
let lo = b as f64 / n_bins as f64;
|
||||
let hi = (b + 1) as f64 / n_bins as f64;
|
||||
let mut count = 0usize;
|
||||
let mut conf_sum = 0.0;
|
||||
let mut acc_sum = 0.0;
|
||||
for (p, &y) in probs.iter().zip(labels) {
|
||||
// Confidence of the *predicted* class.
|
||||
let (conf, pred) = if *p >= 0.5 { (*p, true) } else { (1.0 - *p, false) };
|
||||
let in_bin = if b == n_bins - 1 { conf >= lo && conf <= hi } else { conf >= lo && conf < hi };
|
||||
if in_bin {
|
||||
count += 1;
|
||||
conf_sum += conf;
|
||||
acc_sum += f64::from(pred == y);
|
||||
}
|
||||
}
|
||||
if count > 0 {
|
||||
let cf = count as f64;
|
||||
ece += (cf / n as f64) * ((acc_sum / cf) - (conf_sum / cf)).abs();
|
||||
}
|
||||
}
|
||||
ece
|
||||
}
|
||||
|
||||
/// Coverage/selective-risk pair at a confidence threshold `tau`: predictions
|
||||
/// with confidence below `tau` abstain (become *no decision*).
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct SelectiveMetrics {
|
||||
/// Fraction of samples on which a decision was made.
|
||||
pub coverage: f64,
|
||||
/// Error rate among decided samples (0 when nothing decided).
|
||||
pub selective_risk: f64,
|
||||
}
|
||||
|
||||
/// Computes coverage and selective risk at threshold `tau` ∈ [0.5, 1].
|
||||
#[must_use]
|
||||
pub fn selective_metrics(probs: &[f64], labels: &[bool], tau: f64) -> SelectiveMetrics {
|
||||
assert_eq!(probs.len(), labels.len());
|
||||
let mut decided = 0usize;
|
||||
let mut wrong = 0usize;
|
||||
for (p, &y) in probs.iter().zip(labels) {
|
||||
let (conf, pred) = if *p >= 0.5 { (*p, true) } else { (1.0 - *p, false) };
|
||||
if conf >= tau {
|
||||
decided += 1;
|
||||
if pred != y {
|
||||
wrong += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
SelectiveMetrics {
|
||||
coverage: decided as f64 / probs.len().max(1) as f64,
|
||||
selective_risk: if decided == 0 { 0.0 } else { wrong as f64 / decided as f64 },
|
||||
}
|
||||
}
|
||||
|
||||
/// Relative degradation from a known-condition metric to an
|
||||
/// unknown-condition metric (higher-is-better metrics such as F1).
|
||||
/// ADR-273's acceptance gate: `< 0.20`.
|
||||
#[must_use]
|
||||
pub fn relative_degradation(known: f64, unknown: f64) -> f64 {
|
||||
if known <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
(known - unknown) / known
|
||||
}
|
||||
|
||||
/// Binary F1 score.
|
||||
#[must_use]
|
||||
pub fn f1_score(predictions: &[bool], labels: &[bool]) -> f64 {
|
||||
assert_eq!(predictions.len(), labels.len());
|
||||
let mut tp = 0.0;
|
||||
let mut fp = 0.0;
|
||||
let mut fn_ = 0.0;
|
||||
for (&p, &y) in predictions.iter().zip(labels) {
|
||||
match (p, y) {
|
||||
(true, true) => tp += 1.0,
|
||||
(true, false) => fp += 1.0,
|
||||
(false, true) => fn_ += 1.0,
|
||||
(false, false) => {}
|
||||
}
|
||||
}
|
||||
if tp == 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
let precision = tp / (tp + fp);
|
||||
let recall = tp / (tp + fn_);
|
||||
2.0 * precision * recall / (precision + recall)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn keys() -> Vec<PartitionKey> {
|
||||
let mut out = Vec::new();
|
||||
for room in ["room-a", "room-b", "room-c"] {
|
||||
for day in ["d1", "d2"] {
|
||||
for person in ["p1", "p2"] {
|
||||
out.push(PartitionKey {
|
||||
room: room.into(),
|
||||
day: day.into(),
|
||||
person: person.into(),
|
||||
chipset: "esp32s3".into(),
|
||||
firmware: "v1.2".into(),
|
||||
layout: "L".into(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_split_holds_out_complete_rooms() {
|
||||
let keys = keys();
|
||||
let split = StrictSplit::holdout(&keys, PartitionDim::Room, &["room-c"]);
|
||||
assert_eq!(split.test.len(), 4);
|
||||
assert_eq!(split.train.len(), 8);
|
||||
assert!(split.verify(&keys), "disjointness certificate must hold");
|
||||
for &i in &split.test {
|
||||
assert_eq!(keys[i].room, "room-c");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_catches_a_manufactured_leak() {
|
||||
let keys = keys();
|
||||
let mut split = StrictSplit::holdout(&keys, PartitionDim::Room, &["room-c"]);
|
||||
// Manufacture a leak: push a room-c sample into training.
|
||||
split.train.push(split.test[0]);
|
||||
assert!(!split.verify(&keys), "leak must be detected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ece_is_low_for_calibrated_and_high_for_overconfident() {
|
||||
// Calibrated: p = 0.8 predictions that are right 80 % of the time.
|
||||
let mut probs = Vec::new();
|
||||
let mut labels = Vec::new();
|
||||
for i in 0..100 {
|
||||
probs.push(0.8);
|
||||
labels.push(i % 10 < 8);
|
||||
}
|
||||
let ece = expected_calibration_error(&probs, &labels, 10);
|
||||
assert!(ece < 0.02, "calibrated ECE should be tiny, got {ece}");
|
||||
|
||||
// Overconfident: p = 0.99 but only 60 % correct.
|
||||
let mut probs = Vec::new();
|
||||
let mut labels = Vec::new();
|
||||
for i in 0..100 {
|
||||
probs.push(0.99);
|
||||
labels.push(i % 10 < 6);
|
||||
}
|
||||
let ece = expected_calibration_error(&probs, &labels, 10);
|
||||
assert!(ece > 0.3, "overconfident ECE should be large, got {ece}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn abstention_trades_coverage_for_risk() {
|
||||
// Confident predictions are correct; near-0.5 ones are coin flips.
|
||||
let mut probs = Vec::new();
|
||||
let mut labels = Vec::new();
|
||||
for i in 0..50 {
|
||||
probs.push(0.95);
|
||||
labels.push(true);
|
||||
probs.push(0.55);
|
||||
labels.push(i % 2 == 0); // half wrong at low confidence
|
||||
}
|
||||
let loose = selective_metrics(&probs, &labels, 0.5);
|
||||
let strict = selective_metrics(&probs, &labels, 0.9);
|
||||
assert!(strict.coverage < loose.coverage);
|
||||
assert!(
|
||||
strict.selective_risk < loose.selective_risk,
|
||||
"raising the threshold must lower risk: {strict:?} vs {loose:?}"
|
||||
);
|
||||
assert!((strict.selective_risk - 0.0).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn f1_and_degradation_basics() {
|
||||
let preds = [true, true, false, true];
|
||||
let labels = [true, false, false, true];
|
||||
// tp=2 fp=1 fn=0 → precision 2/3, recall 1 → F1 = 0.8.
|
||||
assert!((f1_score(&preds, &labels) - 0.8).abs() < 1e-12);
|
||||
assert!((relative_degradation(0.95, 0.85) - 0.105_263).abs() < 1e-4);
|
||||
assert!((relative_degradation(0.0, 0.5)).abs() < 1e-12);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
//! Channel-gain queries against the Gaussian map, and the inverse update
|
||||
//! that makes the map *learn* from measured links (ADR-275 §4).
|
||||
//!
|
||||
//! Physics model: free-space Friis amplitude with Beer–Lambert extinction
|
||||
//! through the Gaussians,
|
||||
//!
|
||||
//! ```text
|
||||
//! H(tx,rx,f) = (λ / 4πd) · e^{-j·2πd/λ} · exp(-Σ_g occ_g · I_g)
|
||||
//! ```
|
||||
//!
|
||||
//! where `I_g = ∫₀ᴸ exp(-½·q_g(o + t·u)) dt` is the closed-form line
|
||||
//! integral of Gaussian `g`'s unnormalized density along the TX→RX segment
|
||||
//! (a 1-D Gaussian integral, evaluated with `erf`). Two exactness anchors
|
||||
//! make this testable: an **empty map returns exact Friis**, and adding an
|
||||
//! absorber strictly, monotonically reduces gain.
|
||||
|
||||
use num_complex::Complex64;
|
||||
|
||||
use super::map::GaussianMap;
|
||||
use super::primitive::{Provenance, RfGaussian};
|
||||
use crate::math::erf;
|
||||
|
||||
const C: f64 = 299_792_458.0;
|
||||
|
||||
/// Closed-form line integral of `exp(-½·(x-μ)ᵀΣ⁻¹(x-μ))` along the segment
|
||||
/// `o → o + L·u` (`u` unit).
|
||||
///
|
||||
/// With `a = uᵀΣ⁻¹u`, `b = uᵀΣ⁻¹(μ−o)`, `c = (μ−o)ᵀΣ⁻¹(μ−o)`:
|
||||
/// `q(t) = a·(t − b/a)² + (c − b²/a)`, so
|
||||
/// `I = e^{-(c−b²/a)/2} · √(π/2a) · [erf(√(a/2)(L−t₀)) + erf(√(a/2)·t₀)]`.
|
||||
#[must_use]
|
||||
pub fn line_integral(g: &RfGaussian, o: [f64; 3], u: [f64; 3], len: f64) -> f64 {
|
||||
let si = g.inv_covariance();
|
||||
let mo = [g.position[0] - o[0], g.position[1] - o[1], g.position[2] - o[2]];
|
||||
let mut a = 0.0;
|
||||
let mut b = 0.0;
|
||||
let mut c = 0.0;
|
||||
for i in 0..3 {
|
||||
for j in 0..3 {
|
||||
a += u[i] * si[i][j] * u[j];
|
||||
b += u[i] * si[i][j] * mo[j];
|
||||
c += mo[i] * si[i][j] * mo[j];
|
||||
}
|
||||
}
|
||||
if a <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
let t0 = b / a;
|
||||
let peak = (-0.5 * (c - b * b / a)).exp();
|
||||
let s = (a / 2.0).sqrt();
|
||||
peak * (std::f64::consts::PI / (2.0 * a)).sqrt() * (erf(s * (len - t0)) + erf(s * t0))
|
||||
}
|
||||
|
||||
/// Total optical depth (nepers) of the map along a TX→RX segment, plus the
|
||||
/// per-Gaussian integrals for gradient use: `(τ, [(idx, I_g)])`.
|
||||
#[must_use]
|
||||
pub fn optical_depth(map: &GaussianMap, tx: [f64; 3], rx: [f64; 3]) -> (f64, Vec<(usize, f64)>) {
|
||||
let d = [rx[0] - tx[0], rx[1] - tx[1], rx[2] - tx[2]];
|
||||
let len = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
|
||||
if len < 1e-9 {
|
||||
return (0.0, Vec::new());
|
||||
}
|
||||
let u = [d[0] / len, d[1] / len, d[2] / len];
|
||||
// Candidate set: Gaussians within a 3 m corridor of the segment (3σ for
|
||||
// σ ≤ 1 m primitives), gathered by walking only the hash cells along
|
||||
// the link instead of a ball around its midpoint — see
|
||||
// `GaussianMap::query_near_segment` for the measured effect.
|
||||
let candidates = map.query_near_segment(tx, rx, 3.0);
|
||||
let mut tau = 0.0;
|
||||
let mut parts = Vec::new();
|
||||
for i in candidates {
|
||||
let g = &map.gaussians()[i];
|
||||
let integral = line_integral(g, tx, u, len);
|
||||
if integral > 1e-12 {
|
||||
tau += g.occupancy * integral;
|
||||
parts.push((i, integral));
|
||||
}
|
||||
}
|
||||
(tau, parts)
|
||||
}
|
||||
|
||||
/// Complex channel gain between two points at carrier `freq_hz`.
|
||||
///
|
||||
/// Empty map ⇒ exact free-space Friis amplitude `λ/(4πd)` with propagation
|
||||
/// phase `e^{-j2πd/λ}`.
|
||||
#[must_use]
|
||||
pub fn channel_gain(map: &GaussianMap, tx: [f64; 3], rx: [f64; 3], freq_hz: f64) -> Complex64 {
|
||||
let d = ((rx[0] - tx[0]).powi(2) + (rx[1] - tx[1]).powi(2) + (rx[2] - tx[2]).powi(2)).sqrt();
|
||||
if d < 1e-9 {
|
||||
return Complex64::new(1.0, 0.0);
|
||||
}
|
||||
let lambda = C / freq_hz;
|
||||
let amp = lambda / (4.0 * std::f64::consts::PI * d);
|
||||
let phase = -2.0 * std::f64::consts::PI * d / lambda;
|
||||
let (tau, _) = optical_depth(map, tx, rx);
|
||||
Complex64::from_polar(amp * (-tau).exp(), phase)
|
||||
}
|
||||
|
||||
/// Power gain in dB.
|
||||
#[must_use]
|
||||
pub fn gain_db(map: &GaussianMap, tx: [f64; 3], rx: [f64; 3], freq_hz: f64) -> f64 {
|
||||
20.0 * channel_gain(map, tx, rx, freq_hz).norm().log10()
|
||||
}
|
||||
|
||||
/// One inverse-update step from a measured link amplitude (ADR-275 §4.2 —
|
||||
/// the physics-informed incremental mapping move: when the environment
|
||||
/// changes, adjust or add a *compact* set of Gaussians instead of remapping).
|
||||
///
|
||||
/// The measured optical depth is `τ* = ln(friis_amp / measured_amp)`; the
|
||||
/// map's depth is `τ = Σ occ_g·I_g`. A projected-gradient step moves the
|
||||
/// intersected occupancies toward the residual `r = τ* − τ`:
|
||||
///
|
||||
/// `occ_g += lr · r · I_g / (Σ I_g² + ε)`, clamped at 0 —
|
||||
///
|
||||
/// which is exact Newton along this link's direction at `lr = 1`. When no
|
||||
/// Gaussian meaningfully intersects the segment and the residual demands
|
||||
/// attenuation, a new isotropic Gaussian is spawned at the segment midpoint
|
||||
/// sized to explain the residual. Returns the post-update residual (nepers).
|
||||
pub fn observe_link(
|
||||
map: &mut GaussianMap,
|
||||
tx: [f64; 3],
|
||||
rx: [f64; 3],
|
||||
freq_hz: f64,
|
||||
measured_amp: f64,
|
||||
lr: f64,
|
||||
timestamp_ns: u64,
|
||||
) -> f64 {
|
||||
assert!(measured_amp > 0.0, "measured amplitude must be positive");
|
||||
let d = ((rx[0] - tx[0]).powi(2) + (rx[1] - tx[1]).powi(2) + (rx[2] - tx[2]).powi(2)).sqrt();
|
||||
let lambda = C / freq_hz;
|
||||
let friis = lambda / (4.0 * std::f64::consts::PI * d);
|
||||
let tau_target = (friis / measured_amp).ln();
|
||||
|
||||
let (tau, parts) = optical_depth(map, tx, rx);
|
||||
let residual = tau_target - tau;
|
||||
|
||||
let sum_i2: f64 = parts.iter().map(|(_, i)| i * i).sum();
|
||||
if sum_i2 > 1e-9 {
|
||||
for (idx, integral) in &parts {
|
||||
let g = &mut map.gaussians_mut()[*idx];
|
||||
g.occupancy = (g.occupancy + lr * residual * integral / sum_i2).max(0.0);
|
||||
g.timestamp_ns = g.timestamp_ns.max(timestamp_ns);
|
||||
}
|
||||
map.rebuild_grid();
|
||||
} else if residual > 0.05 {
|
||||
// Nothing on the path can explain the attenuation: spawn a compact
|
||||
// absorber at the midpoint sized to close the residual exactly.
|
||||
let mid = [(tx[0] + rx[0]) / 2.0, (tx[1] + rx[1]) / 2.0, (tx[2] + rx[2]) / 2.0];
|
||||
let mut g = RfGaussian::new(
|
||||
mid,
|
||||
[0.3, 0.3, 0.3],
|
||||
[1.0, 0.0, 0.0, 0.0],
|
||||
0.0,
|
||||
0.6,
|
||||
timestamp_ns,
|
||||
300.0,
|
||||
Provenance { device_id: "gain-inverse".into(), model_version: 1, synthetic: false },
|
||||
)
|
||||
.expect("spawn parameters are statically valid");
|
||||
let dvec = [rx[0] - tx[0], rx[1] - tx[1], rx[2] - tx[2]];
|
||||
let u = [dvec[0] / d, dvec[1] / d, dvec[2] / d];
|
||||
let unit_integral = line_integral(&g, tx, u, d);
|
||||
if unit_integral > 1e-9 {
|
||||
g.occupancy = residual / unit_integral;
|
||||
map.insert(g);
|
||||
}
|
||||
}
|
||||
|
||||
let (tau_after, _) = optical_depth(map, tx, rx);
|
||||
tau_target - tau_after
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::gaussian::primitive::Provenance;
|
||||
|
||||
fn prov() -> Provenance {
|
||||
Provenance { device_id: "gain-test".into(), model_version: 1, synthetic: true }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_map_returns_exact_friis() {
|
||||
let map = GaussianMap::new(1.0);
|
||||
let f = 2.437e9;
|
||||
let tx = [0.0, 0.0, 1.0];
|
||||
let rx = [4.0, 3.0, 1.0]; // d = 5
|
||||
let h = channel_gain(&map, tx, rx, f);
|
||||
let lambda = C / f;
|
||||
let expected_amp = lambda / (4.0 * std::f64::consts::PI * 5.0);
|
||||
assert!((h.norm() - expected_amp).abs() < 1e-15, "amplitude must be exact Friis");
|
||||
let expected_phase = -2.0 * std::f64::consts::PI * 5.0 / lambda;
|
||||
// Compare phasors (phase is only defined mod 2π).
|
||||
let diff = (h / Complex64::from_polar(expected_amp, expected_phase)) - 1.0;
|
||||
assert!(diff.norm() < 1e-9, "propagation phase must match");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absorber_on_path_reduces_gain_monotonically() {
|
||||
let f = 5.18e9;
|
||||
let tx = [0.0, 0.0, 1.0];
|
||||
let rx = [6.0, 0.0, 1.0];
|
||||
let mut last = f64::INFINITY;
|
||||
for occ in [0.0, 0.2, 0.5, 1.0, 2.0] {
|
||||
let mut map = GaussianMap::new(1.0);
|
||||
let g = RfGaussian::new(
|
||||
[3.0, 0.0, 1.0],
|
||||
[0.4, 0.4, 0.4],
|
||||
[1.0, 0.0, 0.0, 0.0],
|
||||
occ,
|
||||
0.9,
|
||||
0,
|
||||
300.0,
|
||||
prov(),
|
||||
)
|
||||
.expect("valid");
|
||||
map.insert(g);
|
||||
let db = gain_db(&map, tx, rx, f);
|
||||
assert!(db < last + 1e-12, "gain must fall as occupancy rises");
|
||||
last = db;
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn off_path_absorber_barely_matters() {
|
||||
let f = 5.18e9;
|
||||
let tx = [0.0, 0.0, 1.0];
|
||||
let rx = [6.0, 0.0, 1.0];
|
||||
let mut map = GaussianMap::new(1.0);
|
||||
map.insert(
|
||||
RfGaussian::new(
|
||||
[3.0, 4.0, 1.0], // 4 m off the LoS, σ = 0.4 ⇒ 10σ away
|
||||
[0.4, 0.4, 0.4],
|
||||
[1.0, 0.0, 0.0, 0.0],
|
||||
2.0,
|
||||
0.9,
|
||||
0,
|
||||
300.0,
|
||||
prov(),
|
||||
)
|
||||
.expect("valid"),
|
||||
);
|
||||
let clean = gain_db(&GaussianMap::new(1.0), tx, rx, f);
|
||||
let with = gain_db(&map, tx, rx, f);
|
||||
assert!((clean - with).abs() < 1e-6, "off-path matter must not attenuate LoS");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_integral_matches_numeric_quadrature() {
|
||||
let g = RfGaussian::new(
|
||||
[2.0, 0.5, 1.0],
|
||||
[0.5, 0.8, 0.3],
|
||||
[0.9, 0.1, 0.2, 0.1], // arbitrary rotation
|
||||
1.0,
|
||||
0.9,
|
||||
0,
|
||||
300.0,
|
||||
prov(),
|
||||
)
|
||||
.expect("valid");
|
||||
let o = [0.0, 0.0, 1.0];
|
||||
let u = [1.0, 0.0, 0.0];
|
||||
let len = 6.0;
|
||||
// Trapezoid quadrature at 1 mm steps as ground truth.
|
||||
let n = 6000;
|
||||
let mut acc = 0.0;
|
||||
for i in 0..=n {
|
||||
let t = len * i as f64 / n as f64;
|
||||
let w = if i == 0 || i == n { 0.5 } else { 1.0 };
|
||||
acc += w * g.density_at([o[0] + t * u[0], o[1] + t * u[1], o[2] + t * u[2]]);
|
||||
}
|
||||
acc *= len / n as f64;
|
||||
let closed = line_integral(&g, o, u, len);
|
||||
assert!(
|
||||
(closed - acc).abs() < 1e-6,
|
||||
"closed form {closed} vs quadrature {acc}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inverse_update_learns_a_wall_from_link_residuals() {
|
||||
let f = 2.437e9;
|
||||
let tx = [0.0, 0.0, 1.0];
|
||||
let rx = [6.0, 0.0, 1.0];
|
||||
let lambda = C / f;
|
||||
let friis = lambda / (4.0 * std::f64::consts::PI * 6.0);
|
||||
// Ground truth: an unseen absorber imposes τ = 0.7 nepers (≈ 6.1 dB).
|
||||
let measured = friis * (-0.7f64).exp();
|
||||
|
||||
let mut map = GaussianMap::new(1.0);
|
||||
let mut residual = f64::INFINITY;
|
||||
for step in 0..20 {
|
||||
residual = observe_link(&mut map, tx, rx, f, measured, 0.7, step);
|
||||
}
|
||||
assert!(!map.is_empty(), "an absorber must have been spawned");
|
||||
assert!(
|
||||
residual.abs() < 0.06,
|
||||
"residual after convergence must be < 0.06 nepers (≈0.5 dB), got {residual}"
|
||||
);
|
||||
let predicted_db = gain_db(&map, tx, rx, f);
|
||||
let measured_db = 20.0 * measured.log10();
|
||||
assert!(
|
||||
(predicted_db - measured_db).abs() < 0.5,
|
||||
"map prediction {predicted_db:.2} dB must match measurement {measured_db:.2} dB"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
//! Task-gated scene graph (ADR-275 §5) — sparse symbolic relations over the
|
||||
//! continuous Gaussian field, activated *only* as far as the current task
|
||||
//! requires (bounded active memory, the JITOMA lesson: stop trying to
|
||||
//! remember everything at once).
|
||||
|
||||
use std::collections::{BTreeSet, HashMap, VecDeque};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Kind of entity a scene node represents.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
pub enum EntityKind {
|
||||
/// A physical object (furniture, appliance).
|
||||
Object,
|
||||
/// A room / zone.
|
||||
Room,
|
||||
/// A person *class* (never an identity — identity inference is gated by
|
||||
/// the ADR-277 policy engine, not stored in the scene graph).
|
||||
PersonClass,
|
||||
/// A sensing device.
|
||||
Device,
|
||||
/// A discrete event (channel anomaly, fall alert, …).
|
||||
Event,
|
||||
}
|
||||
|
||||
/// Relation kinds on scene edges.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum RelationKind {
|
||||
/// Spatial containment (room contains object).
|
||||
Contains,
|
||||
/// Spatial adjacency.
|
||||
Near,
|
||||
/// Causal attribution (event caused by object/person-class).
|
||||
CausedBy,
|
||||
/// Sensing coverage (device observes room/object).
|
||||
ObservedBy,
|
||||
}
|
||||
|
||||
/// A node in the scene graph.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SceneNode {
|
||||
/// Stable identifier.
|
||||
pub id: String,
|
||||
/// Entity kind.
|
||||
pub kind: EntityKind,
|
||||
/// Indices of the Gaussians in the map that ground this node.
|
||||
pub gaussian_refs: Vec<usize>,
|
||||
}
|
||||
|
||||
/// A directed, typed edge.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SceneEdge {
|
||||
/// Source node id.
|
||||
pub from: String,
|
||||
/// Target node id.
|
||||
pub to: String,
|
||||
/// Relation.
|
||||
pub relation: RelationKind,
|
||||
}
|
||||
|
||||
/// The sparse relational layer over the Gaussian field.
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct SceneGraph {
|
||||
nodes: HashMap<String, SceneNode>,
|
||||
edges: Vec<SceneEdge>,
|
||||
}
|
||||
|
||||
/// A bounded, task-relevant activation of the graph.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ActiveSubgraph {
|
||||
/// Activated node ids in BFS order from the seeds.
|
||||
pub nodes: Vec<String>,
|
||||
/// Edges with both endpoints activated.
|
||||
pub edges: Vec<SceneEdge>,
|
||||
/// True when the activation hit `max_nodes` before exhausting reachable
|
||||
/// relevant nodes (callers can widen the budget deliberately).
|
||||
pub truncated: bool,
|
||||
}
|
||||
|
||||
impl SceneGraph {
|
||||
/// Empty graph.
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Number of nodes.
|
||||
#[must_use]
|
||||
pub fn node_count(&self) -> usize {
|
||||
self.nodes.len()
|
||||
}
|
||||
|
||||
/// Inserts or replaces a node.
|
||||
pub fn upsert_node(&mut self, node: SceneNode) {
|
||||
self.nodes.insert(node.id.clone(), node);
|
||||
}
|
||||
|
||||
/// Adds an edge (endpoints need not exist yet; dangling edges are
|
||||
/// simply never activated).
|
||||
pub fn add_edge(&mut self, from: impl Into<String>, to: impl Into<String>, relation: RelationKind) {
|
||||
self.edges.push(SceneEdge { from: from.into(), to: to.into(), relation });
|
||||
}
|
||||
|
||||
/// Node lookup.
|
||||
#[must_use]
|
||||
pub fn node(&self, id: &str) -> Option<&SceneNode> {
|
||||
self.nodes.get(id)
|
||||
}
|
||||
|
||||
/// Task-gated activation: BFS from `seed_ids`, following edges in both
|
||||
/// directions, keeping only nodes whose kind is in `relevant_kinds`,
|
||||
/// visiting at most `max_nodes` nodes. This is the *only* sanctioned way
|
||||
/// for reasoning code to read the graph — full scans are deliberately
|
||||
/// not offered on the public surface.
|
||||
#[must_use]
|
||||
pub fn activate(
|
||||
&self,
|
||||
relevant_kinds: &[EntityKind],
|
||||
seed_ids: &[&str],
|
||||
max_nodes: usize,
|
||||
) -> ActiveSubgraph {
|
||||
let relevant: BTreeSet<EntityKind> = relevant_kinds.iter().copied().collect();
|
||||
let mut visited: BTreeSet<&str> = BTreeSet::new();
|
||||
let mut queue: VecDeque<&str> = VecDeque::new();
|
||||
let mut activated: Vec<String> = Vec::new();
|
||||
let mut truncated = false;
|
||||
|
||||
for &s in seed_ids {
|
||||
if let Some(n) = self.nodes.get(s) {
|
||||
if relevant.contains(&n.kind) && visited.insert(s) {
|
||||
queue.push_back(s);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
while let Some(id) = queue.pop_front() {
|
||||
if activated.len() >= max_nodes {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
activated.push(id.to_string());
|
||||
for e in &self.edges {
|
||||
let neighbor = if e.from == id {
|
||||
Some(e.to.as_str())
|
||||
} else if e.to == id {
|
||||
Some(e.from.as_str())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let Some(nb) = neighbor else { continue };
|
||||
let Some(node) = self.nodes.get(nb) else { continue };
|
||||
if relevant.contains(&node.kind) && !visited.contains(nb) {
|
||||
// Re-borrow with the graph's lifetime for the queue.
|
||||
let (key, _) = self.nodes.get_key_value(nb).expect("just found");
|
||||
visited.insert(key);
|
||||
queue.push_back(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
if !queue.is_empty() {
|
||||
truncated = true;
|
||||
}
|
||||
|
||||
let in_set: BTreeSet<&str> = activated.iter().map(String::as_str).collect();
|
||||
let edges = self
|
||||
.edges
|
||||
.iter()
|
||||
.filter(|e| in_set.contains(e.from.as_str()) && in_set.contains(e.to.as_str()))
|
||||
.cloned()
|
||||
.collect();
|
||||
ActiveSubgraph { nodes: activated, edges, truncated }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn demo_graph() -> SceneGraph {
|
||||
let mut g = SceneGraph::new();
|
||||
for (id, kind) in [
|
||||
("kitchen", EntityKind::Room),
|
||||
("living", EntityKind::Room),
|
||||
("fridge", EntityKind::Object),
|
||||
("sofa", EntityKind::Object),
|
||||
("esp32-a", EntityKind::Device),
|
||||
("person-class-1", EntityKind::PersonClass),
|
||||
("anomaly-7", EntityKind::Event),
|
||||
] {
|
||||
g.upsert_node(SceneNode { id: id.into(), kind, gaussian_refs: vec![] });
|
||||
}
|
||||
g.add_edge("kitchen", "fridge", RelationKind::Contains);
|
||||
g.add_edge("living", "sofa", RelationKind::Contains);
|
||||
g.add_edge("kitchen", "esp32-a", RelationKind::ObservedBy);
|
||||
g.add_edge("anomaly-7", "fridge", RelationKind::CausedBy);
|
||||
g.add_edge("person-class-1", "living", RelationKind::Near);
|
||||
g
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn activation_is_task_scoped() {
|
||||
let g = demo_graph();
|
||||
// Task: "which object caused the channel anomaly?" — events, objects,
|
||||
// rooms are relevant; devices and person classes are not.
|
||||
let active = g.activate(
|
||||
&[EntityKind::Event, EntityKind::Object, EntityKind::Room],
|
||||
&["anomaly-7"],
|
||||
10,
|
||||
);
|
||||
assert!(active.nodes.contains(&"anomaly-7".to_string()));
|
||||
assert!(active.nodes.contains(&"fridge".to_string()));
|
||||
assert!(active.nodes.contains(&"kitchen".to_string()));
|
||||
assert!(!active.nodes.iter().any(|n| n == "esp32-a"), "devices are gated out");
|
||||
assert!(!active.nodes.iter().any(|n| n == "person-class-1"));
|
||||
assert!(!active.truncated);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn activation_respects_the_node_budget() {
|
||||
let g = demo_graph();
|
||||
let active = g.activate(
|
||||
&[
|
||||
EntityKind::Event,
|
||||
EntityKind::Object,
|
||||
EntityKind::Room,
|
||||
EntityKind::Device,
|
||||
EntityKind::PersonClass,
|
||||
],
|
||||
&["anomaly-7"],
|
||||
2,
|
||||
);
|
||||
assert_eq!(active.nodes.len(), 2, "bounded active memory");
|
||||
assert!(active.truncated, "truncation must be reported, not silent");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unreachable_and_irrelevant_seeds_yield_empty() {
|
||||
let g = demo_graph();
|
||||
let active = g.activate(&[EntityKind::Object], &["nonexistent"], 10);
|
||||
assert!(active.nodes.is_empty());
|
||||
// Seed exists but its kind is not relevant ⇒ empty activation.
|
||||
let active = g.activate(&[EntityKind::Object], &["kitchen"], 10);
|
||||
assert!(active.nodes.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,467 @@
|
||||
//! The Gaussian map: spatial-hash-indexed storage with fusion, decay, and
|
||||
//! spatial/semantic queries (ADR-275 §3).
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::primitive::{RfGaussian, SEMANTIC_DIM};
|
||||
|
||||
/// Confidence floor below which a decayed Gaussian is pruned.
|
||||
pub const PRUNE_CONFIDENCE: f64 = 0.02;
|
||||
|
||||
/// Squared-Mahalanobis merge gate: an incoming Gaussian whose centre lies
|
||||
/// within this metric distance of an existing one *of the same entity kind*
|
||||
/// fuses instead of inserting (3² = within 3σ).
|
||||
pub const MERGE_MAHALANOBIS_SQ: f64 = 9.0;
|
||||
|
||||
/// Persistent Gaussian scene memory with an O(1) spatial-hash index.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct GaussianMap {
|
||||
gaussians: Vec<RfGaussian>,
|
||||
/// Cell → indices. Rebuilt on decay/prune, updated on insert.
|
||||
grid: HashMap<(i64, i64, i64), Vec<usize>>,
|
||||
cell_size: f64,
|
||||
}
|
||||
|
||||
impl GaussianMap {
|
||||
/// New map. `cell_size` is the spatial-hash pitch in metres; it should
|
||||
/// be on the order of the largest expected Gaussian extent (≈ 1 m for
|
||||
/// rooms).
|
||||
#[must_use]
|
||||
pub fn new(cell_size: f64) -> Self {
|
||||
Self { gaussians: Vec::new(), grid: HashMap::new(), cell_size: cell_size.max(1e-3) }
|
||||
}
|
||||
|
||||
/// Number of live Gaussians.
|
||||
#[must_use]
|
||||
pub fn len(&self) -> usize {
|
||||
self.gaussians.len()
|
||||
}
|
||||
|
||||
/// Whether the map is empty.
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.gaussians.is_empty()
|
||||
}
|
||||
|
||||
/// Read-only view of the store.
|
||||
#[must_use]
|
||||
pub fn gaussians(&self) -> &[RfGaussian] {
|
||||
&self.gaussians
|
||||
}
|
||||
|
||||
/// Mutable access for the inverse-gain updater (crate-internal).
|
||||
pub(crate) fn gaussians_mut(&mut self) -> &mut Vec<RfGaussian> {
|
||||
&mut self.gaussians
|
||||
}
|
||||
|
||||
fn cell_of(&self, p: [f64; 3]) -> (i64, i64, i64) {
|
||||
(
|
||||
(p[0] / self.cell_size).floor() as i64,
|
||||
(p[1] / self.cell_size).floor() as i64,
|
||||
(p[2] / self.cell_size).floor() as i64,
|
||||
)
|
||||
}
|
||||
|
||||
/// Rebuilds the spatial index from scratch (after decay/prune or
|
||||
/// occupancy edits that may have moved nothing — cheap: O(n)).
|
||||
pub(crate) fn rebuild_grid(&mut self) {
|
||||
self.grid.clear();
|
||||
for (i, g) in self.gaussians.iter().enumerate() {
|
||||
let cell = self.cell_of(g.position);
|
||||
self.grid.entry(cell).or_default().push(i);
|
||||
}
|
||||
}
|
||||
|
||||
/// Inserts a Gaussian, fusing with an existing same-kind neighbor when
|
||||
/// the merge gate fires (ADR-275 §3.2).
|
||||
///
|
||||
/// Fusion is confidence-weighted: position, occupancy, semantics,
|
||||
/// reflectivity, and Doppler average with weights `(c_old, c_new)`;
|
||||
/// confidence combines as noisy-OR `c = c₁ + c₂ − c₁c₂` (two independent
|
||||
/// pieces of evidence); the newer timestamp and provenance win.
|
||||
/// Returns the index of the stored (new or fused) Gaussian.
|
||||
pub fn insert(&mut self, g: RfGaussian) -> usize {
|
||||
// Candidate neighbors from the 3×3×3 cell neighborhood.
|
||||
let cell = self.cell_of(g.position);
|
||||
let mut best: Option<usize> = None;
|
||||
let mut best_d = MERGE_MAHALANOBIS_SQ;
|
||||
for dx in -1..=1 {
|
||||
for dy in -1..=1 {
|
||||
for dz in -1..=1 {
|
||||
let Some(idxs) = self.grid.get(&(cell.0 + dx, cell.1 + dy, cell.2 + dz))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
for &i in idxs {
|
||||
let existing = &self.gaussians[i];
|
||||
let same_kind = match (existing.links.first(), g.links.first()) {
|
||||
(Some(a), Some(b)) => a.kind == b.kind,
|
||||
(None, None) => true,
|
||||
_ => false,
|
||||
};
|
||||
if !same_kind {
|
||||
continue;
|
||||
}
|
||||
let d = existing.mahalanobis_sq(g.position);
|
||||
if d < best_d {
|
||||
best_d = d;
|
||||
best = Some(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(i) = best {
|
||||
let old_cell = self.cell_of(self.gaussians[i].position);
|
||||
{
|
||||
let e = &mut self.gaussians[i];
|
||||
let (wa, wb) = (e.confidence, g.confidence);
|
||||
let wsum = (wa + wb).max(1e-12);
|
||||
for k in 0..3 {
|
||||
e.position[k] = (wa * e.position[k] + wb * g.position[k]) / wsum;
|
||||
e.scale[k] = (wa * e.scale[k] + wb * g.scale[k]) / wsum;
|
||||
}
|
||||
e.occupancy = (wa * e.occupancy + wb * g.occupancy) / wsum;
|
||||
for k in 0..SEMANTIC_DIM {
|
||||
e.semantic[k] =
|
||||
((f64::from(e.semantic[k]) * wa + f64::from(g.semantic[k]) * wb) / wsum) as f32;
|
||||
}
|
||||
for b in 0..e.reflectivity.len() {
|
||||
for a in 0..e.reflectivity[b].len() {
|
||||
e.reflectivity[b][a] =
|
||||
(wa * e.reflectivity[b][a] + wb * g.reflectivity[b][a]) / wsum;
|
||||
}
|
||||
}
|
||||
e.doppler_mps = (wa * e.doppler_mps + wb * g.doppler_mps) / wsum;
|
||||
e.confidence = (wa + wb - wa * wb).clamp(0.0, 1.0);
|
||||
if g.timestamp_ns >= e.timestamp_ns {
|
||||
e.timestamp_ns = g.timestamp_ns;
|
||||
e.provenance = g.provenance;
|
||||
e.motion = g.motion;
|
||||
}
|
||||
for link in g.links {
|
||||
if !e.links.contains(&link) {
|
||||
e.links.push(link);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Re-index if fusion moved the centre across a cell boundary.
|
||||
let new_cell = self.cell_of(self.gaussians[i].position);
|
||||
if new_cell != old_cell {
|
||||
if let Some(v) = self.grid.get_mut(&old_cell) {
|
||||
v.retain(|&x| x != i);
|
||||
}
|
||||
self.grid.entry(new_cell).or_default().push(i);
|
||||
}
|
||||
i
|
||||
} else {
|
||||
let idx = self.gaussians.len();
|
||||
let cell = self.cell_of(g.position);
|
||||
self.gaussians.push(g);
|
||||
self.grid.entry(cell).or_default().push(idx);
|
||||
idx
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies exponential confidence decay up to `now_ns` and prunes below
|
||||
/// [`PRUNE_CONFIDENCE`]. Deterministic: same inputs, same result.
|
||||
pub fn decay(&mut self, now_ns: u64) {
|
||||
for g in &mut self.gaussians {
|
||||
let dt_s = (now_ns.saturating_sub(g.timestamp_ns)) as f64 / 1e9;
|
||||
g.confidence *= (-dt_s / g.decay_tau_s).exp();
|
||||
}
|
||||
self.gaussians.retain(|g| g.confidence >= PRUNE_CONFIDENCE);
|
||||
self.rebuild_grid();
|
||||
}
|
||||
|
||||
/// Indices of Gaussians whose centres lie within `radius` of `p`, via
|
||||
/// the spatial hash (only the covered cell neighborhood is scanned).
|
||||
#[must_use]
|
||||
pub fn query_radius(&self, p: [f64; 3], radius: f64) -> Vec<usize> {
|
||||
let r_cells = (radius / self.cell_size).ceil() as i64;
|
||||
let c = self.cell_of(p);
|
||||
let mut out = Vec::new();
|
||||
let r2 = radius * radius;
|
||||
for dx in -r_cells..=r_cells {
|
||||
for dy in -r_cells..=r_cells {
|
||||
for dz in -r_cells..=r_cells {
|
||||
let Some(idxs) = self.grid.get(&(c.0 + dx, c.1 + dy, c.2 + dz)) else {
|
||||
continue;
|
||||
};
|
||||
for &i in idxs {
|
||||
let q = self.gaussians[i].position;
|
||||
let d2 = (q[0] - p[0]).powi(2) + (q[1] - p[1]).powi(2) + (q[2] - p[2]).powi(2);
|
||||
if d2 <= r2 {
|
||||
out.push(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out.sort_unstable();
|
||||
out
|
||||
}
|
||||
|
||||
/// Reference implementation of [`Self::query_radius`] by linear scan —
|
||||
/// kept for the equivalence test and the benchmark baseline.
|
||||
#[must_use]
|
||||
pub fn query_radius_linear(&self, p: [f64; 3], radius: f64) -> Vec<usize> {
|
||||
let r2 = radius * radius;
|
||||
let mut out: Vec<usize> = self
|
||||
.gaussians
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, g)| {
|
||||
let q = g.position;
|
||||
(q[0] - p[0]).powi(2) + (q[1] - p[1]).powi(2) + (q[2] - p[2]).powi(2) <= r2
|
||||
})
|
||||
.map(|(i, _)| i)
|
||||
.collect();
|
||||
out.sort_unstable();
|
||||
out
|
||||
}
|
||||
|
||||
/// Indices of Gaussians whose centres lie within `margin` of the
|
||||
/// segment `a → b`, by walking only the hash cells along the corridor —
|
||||
/// the hot path of [`super::gain::optical_depth`].
|
||||
///
|
||||
/// Sweeps the segment's margin-inflated AABB once; each cell is
|
||||
/// prefiltered by its *centre's* distance to the segment (bound:
|
||||
/// `margin + √3/2·cell`, which covers any point inside the cell) before
|
||||
/// the hash lookup, so no per-sample set building and no duplicate
|
||||
/// visits. Candidates are then exact-filtered by centre-to-segment
|
||||
/// distance. See `benches/unified_bench.rs` for the measured effect on
|
||||
/// `channel_gain` versus both the midpoint-ball query this replaced and
|
||||
/// the linear-scan baseline.
|
||||
#[must_use]
|
||||
pub fn query_near_segment(&self, a: [f64; 3], b: [f64; 3], margin: f64) -> Vec<usize> {
|
||||
let lo = |axis: usize| a[axis].min(b[axis]) - margin;
|
||||
let hi = |axis: usize| a[axis].max(b[axis]) + margin;
|
||||
let c_lo: Vec<i64> = (0..3).map(|k| (lo(k) / self.cell_size).floor() as i64).collect();
|
||||
let c_hi: Vec<i64> = (0..3).map(|k| (hi(k) / self.cell_size).floor() as i64).collect();
|
||||
let cell_bound = margin + 0.87 * self.cell_size; // √3/2 ≈ 0.866
|
||||
let mut out: Vec<usize> = Vec::new();
|
||||
for cx in c_lo[0]..=c_hi[0] {
|
||||
for cy in c_lo[1]..=c_hi[1] {
|
||||
for cz in c_lo[2]..=c_hi[2] {
|
||||
let centre = [
|
||||
(cx as f64 + 0.5) * self.cell_size,
|
||||
(cy as f64 + 0.5) * self.cell_size,
|
||||
(cz as f64 + 0.5) * self.cell_size,
|
||||
];
|
||||
if Self::dist_point_segment(centre, a, b) > cell_bound {
|
||||
continue;
|
||||
}
|
||||
let Some(idxs) = self.grid.get(&(cx, cy, cz)) else { continue };
|
||||
for &i in idxs {
|
||||
if Self::dist_point_segment(self.gaussians[i].position, a, b) <= margin {
|
||||
out.push(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out.sort_unstable();
|
||||
out
|
||||
}
|
||||
|
||||
/// Reference implementation of [`Self::query_near_segment`] by linear
|
||||
/// scan — equivalence-tested and benchmarked as the baseline.
|
||||
#[must_use]
|
||||
pub fn query_near_segment_linear(&self, a: [f64; 3], b: [f64; 3], margin: f64) -> Vec<usize> {
|
||||
(0..self.gaussians.len())
|
||||
.filter(|&i| Self::dist_point_segment(self.gaussians[i].position, a, b) <= margin)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Distance from a point to a segment.
|
||||
fn dist_point_segment(p: [f64; 3], a: [f64; 3], b: [f64; 3]) -> f64 {
|
||||
let ab = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
|
||||
let ap = [p[0] - a[0], p[1] - a[1], p[2] - a[2]];
|
||||
let denom = ab[0] * ab[0] + ab[1] * ab[1] + ab[2] * ab[2];
|
||||
let t = if denom < 1e-18 {
|
||||
0.0
|
||||
} else {
|
||||
((ap[0] * ab[0] + ap[1] * ab[1] + ap[2] * ab[2]) / denom).clamp(0.0, 1.0)
|
||||
};
|
||||
let q = [a[0] + t * ab[0], a[1] + t * ab[1], a[2] + t * ab[2]];
|
||||
((p[0] - q[0]).powi(2) + (p[1] - q[1]).powi(2) + (p[2] - q[2]).powi(2)).sqrt()
|
||||
}
|
||||
|
||||
/// `k` nearest Gaussians to `p` by centre distance (expanding-ring
|
||||
/// search over the hash grid).
|
||||
#[must_use]
|
||||
pub fn query_nearest(&self, p: [f64; 3], k: usize) -> Vec<usize> {
|
||||
if self.gaussians.is_empty() || k == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut radius = self.cell_size;
|
||||
loop {
|
||||
let hits = self.query_radius(p, radius);
|
||||
if hits.len() >= k || radius > 1e4 {
|
||||
let mut scored: Vec<(f64, usize)> = hits
|
||||
.into_iter()
|
||||
.map(|i| {
|
||||
let q = self.gaussians[i].position;
|
||||
let d2 = (q[0] - p[0]).powi(2)
|
||||
+ (q[1] - p[1]).powi(2)
|
||||
+ (q[2] - p[2]).powi(2);
|
||||
(d2, i)
|
||||
})
|
||||
.collect();
|
||||
scored.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
|
||||
scored.truncate(k);
|
||||
return scored.into_iter().map(|(_, i)| i).collect();
|
||||
}
|
||||
radius *= 2.0;
|
||||
}
|
||||
}
|
||||
|
||||
/// `k` most semantically similar Gaussians by cosine similarity.
|
||||
#[must_use]
|
||||
pub fn query_semantic(&self, embedding: &[f32; SEMANTIC_DIM], k: usize) -> Vec<usize> {
|
||||
let norm_q: f64 = embedding.iter().map(|v| f64::from(*v).powi(2)).sum::<f64>().sqrt();
|
||||
let mut scored: Vec<(f64, usize)> = self
|
||||
.gaussians
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, g)| {
|
||||
let dot: f64 = g
|
||||
.semantic
|
||||
.iter()
|
||||
.zip(embedding)
|
||||
.map(|(a, b)| f64::from(*a) * f64::from(*b))
|
||||
.sum();
|
||||
let norm_g: f64 =
|
||||
g.semantic.iter().map(|v| f64::from(*v).powi(2)).sum::<f64>().sqrt();
|
||||
let sim = if norm_g * norm_q > 0.0 { dot / (norm_g * norm_q) } else { -1.0 };
|
||||
(sim, i)
|
||||
})
|
||||
.collect();
|
||||
scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
|
||||
scored.truncate(k);
|
||||
scored.into_iter().map(|(_, i)| i).collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::gaussian::primitive::Provenance;
|
||||
|
||||
fn prov() -> Provenance {
|
||||
Provenance { device_id: "map-test".into(), model_version: 1, synthetic: true }
|
||||
}
|
||||
|
||||
fn g_at(p: [f64; 3], confidence: f64, ts: u64) -> RfGaussian {
|
||||
RfGaussian::new(p, [0.3, 0.3, 0.3], [1.0, 0.0, 0.0, 0.0], 0.4, confidence, ts, 60.0, prov())
|
||||
.expect("valid gaussian")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nearby_same_kind_observations_fuse() {
|
||||
let mut map = GaussianMap::new(1.0);
|
||||
let a = map.insert(g_at([1.0, 1.0, 1.0], 0.5, 10));
|
||||
let b = map.insert(g_at([1.1, 1.0, 1.0], 0.5, 20));
|
||||
assert_eq!(a, b, "second observation must fuse, not duplicate");
|
||||
assert_eq!(map.len(), 1);
|
||||
let g = &map.gaussians()[0];
|
||||
// Confidence-weighted midpoint and noisy-OR confidence.
|
||||
assert!((g.position[0] - 1.05).abs() < 1e-9);
|
||||
assert!((g.confidence - 0.75).abs() < 1e-9);
|
||||
assert_eq!(g.timestamp_ns, 20);
|
||||
|
||||
// A far observation creates a new Gaussian.
|
||||
map.insert(g_at([5.0, 5.0, 1.0], 0.5, 30));
|
||||
assert_eq!(map.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decay_prunes_stale_gaussians_deterministically() {
|
||||
let mut map = GaussianMap::new(1.0);
|
||||
map.insert(g_at([0.0; 3], 0.9, 0)); // tau = 60 s
|
||||
map.insert(g_at([4.0, 0.0, 0.0], 0.9, 240_000_000_000)); // fresh
|
||||
// 240 s later: first has decayed by e^{-4} ⇒ 0.9·0.0183 ≈ 0.016 < floor.
|
||||
map.decay(240_000_000_000);
|
||||
assert_eq!(map.len(), 1);
|
||||
assert!((map.gaussians()[0].position[0] - 4.0).abs() < 1e-12);
|
||||
|
||||
// Determinism: replay the same sequence, get the same state.
|
||||
let mut replay = GaussianMap::new(1.0);
|
||||
replay.insert(g_at([0.0; 3], 0.9, 0));
|
||||
replay.insert(g_at([4.0, 0.0, 0.0], 0.9, 240_000_000_000));
|
||||
replay.decay(240_000_000_000);
|
||||
assert_eq!(replay.len(), map.len());
|
||||
assert_eq!(replay.gaussians()[0].confidence, map.gaussians()[0].confidence);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spatial_hash_query_matches_linear_scan() {
|
||||
let mut map = GaussianMap::new(0.7);
|
||||
// Grid of well-separated Gaussians (spacing 2 m ≫ merge gate at σ=0.3).
|
||||
for x in 0..10 {
|
||||
for y in 0..10 {
|
||||
map.insert(g_at([x as f64 * 2.0, y as f64 * 2.0, 1.0], 0.9, 0));
|
||||
}
|
||||
}
|
||||
assert_eq!(map.len(), 100);
|
||||
for (centre, radius) in
|
||||
[([5.0, 5.0, 1.0], 3.0), ([0.0, 0.0, 1.0], 1.5), ([18.0, 18.0, 1.0], 5.0)]
|
||||
{
|
||||
assert_eq!(
|
||||
map.query_radius(centre, radius),
|
||||
map.query_radius_linear(centre, radius),
|
||||
"hash and linear scans must agree at {centre:?} r={radius}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn segment_corridor_query_matches_linear_scan() {
|
||||
let mut map = GaussianMap::new(1.0);
|
||||
for x in 0..12 {
|
||||
for y in 0..12 {
|
||||
for z in 0..2 {
|
||||
map.insert(g_at(
|
||||
[x as f64 * 1.7, y as f64 * 1.7, 0.8 + z as f64 * 1.4],
|
||||
0.9,
|
||||
0,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (a, b, m) in [
|
||||
([0.0, 0.0, 1.0], [18.0, 18.0, 1.5], 3.0),
|
||||
([2.0, 15.0, 1.0], [15.0, 2.0, 2.0], 1.5),
|
||||
([5.0, 5.0, 1.0], [5.0, 5.0, 1.0], 2.0), // degenerate segment
|
||||
] {
|
||||
assert_eq!(
|
||||
map.query_near_segment(a, b, m),
|
||||
map.query_near_segment_linear(a, b, m),
|
||||
"corridor hash walk and linear scan must agree for {a:?}→{b:?} m={m}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nearest_and_semantic_queries() {
|
||||
let mut map = GaussianMap::new(1.0);
|
||||
map.insert(g_at([0.0, 0.0, 0.0], 0.9, 0));
|
||||
map.insert(g_at([3.0, 0.0, 0.0], 0.9, 0));
|
||||
let mut tagged = g_at([9.0, 9.0, 0.0], 0.9, 0);
|
||||
tagged.semantic[0] = 1.0;
|
||||
tagged.semantic[1] = 0.5;
|
||||
map.insert(tagged);
|
||||
|
||||
let near = map.query_nearest([0.2, 0.0, 0.0], 2);
|
||||
assert_eq!(near.len(), 2);
|
||||
assert_eq!(near[0], 0, "closest first");
|
||||
|
||||
let mut q = [0.0f32; SEMANTIC_DIM];
|
||||
q[0] = 1.0;
|
||||
q[1] = 0.5;
|
||||
let sem = map.query_semantic(&q, 1);
|
||||
assert_eq!(sem, vec![2], "semantic query finds the tagged Gaussian");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
//! RF-aware Gaussian spatial memory (ADR-275).
|
||||
//!
|
||||
//! The persistent scene representation: anisotropic 3-D Gaussians that carry
|
||||
//! *both* geometric/semantic state (position, scale, orientation, occupancy,
|
||||
//! semantic embedding) *and* RF state (per-band × incident-angle
|
||||
//! reflectivity, Doppler/motion, extinction), plus the bookkeeping a world
|
||||
//! model needs (confidence, provenance, timestamp, decay, entity links).
|
||||
//!
|
||||
//! Three capabilities distinguish this from a point cloud:
|
||||
//!
|
||||
//! 1. **Fusion**: observations of the same region merge by
|
||||
//! confidence-weighted precision updates instead of accumulating
|
||||
//! duplicates ([`map::GaussianMap::insert`]).
|
||||
//! 2. **RF queries**: the map predicts complex channel gain between any two
|
||||
//! points via Beer–Lambert transmittance through the Gaussians
|
||||
//! ([`gain::channel_gain`]) — an empty map degrades to *exact* free-space
|
||||
//! Friis, and measured-vs-predicted residuals update the map inversely
|
||||
//! ([`gain::observe_link`]).
|
||||
//! 3. **Task-gated activation**: reasoning code touches a bounded subgraph
|
||||
//! ([`graph::SceneGraph::activate`]), never the full memory.
|
||||
|
||||
pub mod gain;
|
||||
pub mod graph;
|
||||
pub mod map;
|
||||
pub mod primitive;
|
||||
|
||||
pub use gain::{channel_gain, gain_db, observe_link};
|
||||
pub use graph::{EntityKind, RelationKind, SceneEdge, SceneGraph, SceneNode};
|
||||
pub use map::GaussianMap;
|
||||
pub use primitive::{Band, EntityLink, MotionState, Provenance, RfGaussian};
|
||||
@@ -0,0 +1,324 @@
|
||||
//! The RF-aware Gaussian primitive (ADR-275 §2).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{Result, UnifiedError};
|
||||
|
||||
/// Frequency bands with distinct material interaction (index into the
|
||||
/// reflectivity table).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum Band {
|
||||
/// 2.4 GHz (WiFi b/g/n, BLE).
|
||||
Ghz2_4,
|
||||
/// 5–6 GHz (WiFi a/ac/ax, NR n77/n78-ish).
|
||||
Ghz5,
|
||||
/// 6–8 GHz (WiFi 6E/7, UWB).
|
||||
Ghz6,
|
||||
/// 60 GHz (mmWave radar / 802.11ad).
|
||||
Ghz60,
|
||||
}
|
||||
|
||||
impl Band {
|
||||
/// Number of modeled bands.
|
||||
pub const COUNT: usize = 4;
|
||||
|
||||
/// Table index.
|
||||
#[must_use]
|
||||
pub fn index(self) -> usize {
|
||||
match self {
|
||||
Self::Ghz2_4 => 0,
|
||||
Self::Ghz5 => 1,
|
||||
Self::Ghz6 => 2,
|
||||
Self::Ghz60 => 3,
|
||||
}
|
||||
}
|
||||
|
||||
/// Band for a carrier frequency in Hz (nearest-band bucketing).
|
||||
#[must_use]
|
||||
pub fn from_freq_hz(f: f64) -> Self {
|
||||
if f < 4.0e9 {
|
||||
Self::Ghz2_4
|
||||
} else if f < 6.0e9 {
|
||||
Self::Ghz5
|
||||
} else if f < 2.0e10 {
|
||||
Self::Ghz6
|
||||
} else {
|
||||
Self::Ghz60
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of incident-angle bins in the reflectivity table (0°–90° in 4
|
||||
/// bins of 22.5°).
|
||||
pub const ANGLE_BINS: usize = 4;
|
||||
|
||||
/// Semantic embedding width carried by each Gaussian.
|
||||
pub const SEMANTIC_DIM: usize = 16;
|
||||
|
||||
/// Coarse motion state of the matter a Gaussian represents.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum MotionState {
|
||||
/// Structural / furniture: no observed Doppler.
|
||||
Static,
|
||||
/// Slow biological motion (breathing, posture shifts).
|
||||
Slow,
|
||||
/// Walking-speed or faster motion.
|
||||
Fast,
|
||||
}
|
||||
|
||||
/// Where a Gaussian's evidence came from (ADR-273 acceptance item 8: every
|
||||
/// output carries provenance and model version).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Provenance {
|
||||
/// Device that observed the evidence.
|
||||
pub device_id: String,
|
||||
/// Version of the model that produced the update.
|
||||
pub model_version: u32,
|
||||
/// Whether the evidence is synthetic (ADR-276 honest labeling).
|
||||
pub synthetic: bool,
|
||||
}
|
||||
|
||||
/// What kind of entity a Gaussian links to in the scene graph / RuVector.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct EntityLink {
|
||||
/// Kind of the linked entity.
|
||||
pub kind: super::graph::EntityKind,
|
||||
/// Stable identifier of the entity.
|
||||
pub id: String,
|
||||
}
|
||||
|
||||
/// One anisotropic RF-aware Gaussian (ADR-275 §2.1) — the six attribute
|
||||
/// groups from the ADR: geometry, semantics, RF response, motion,
|
||||
/// trust/lifecycle, and entity links.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RfGaussian {
|
||||
/// Centre, metres, room frame.
|
||||
pub position: [f64; 3],
|
||||
/// Standard deviations along the principal axes, metres (> 0).
|
||||
pub scale: [f64; 3],
|
||||
/// Orientation quaternion `[w, x, y, z]` (normalized on construction).
|
||||
pub orientation: [f64; 4],
|
||||
/// Peak extinction coefficient (nepers/metre at the centre) — the
|
||||
/// Beer–Lambert density used by the gain model. `>= 0`.
|
||||
pub occupancy: f64,
|
||||
/// Visual/semantic embedding.
|
||||
pub semantic: [f32; SEMANTIC_DIM],
|
||||
/// RF reflectivity by `[band][incident-angle bin]`, `0..=1` amplitude.
|
||||
pub reflectivity: [[f64; ANGLE_BINS]; Band::COUNT],
|
||||
/// Radial velocity estimate, m/s (signed).
|
||||
pub doppler_mps: f64,
|
||||
/// Coarse motion class.
|
||||
pub motion: MotionState,
|
||||
/// Confidence in `[0, 1]`.
|
||||
pub confidence: f64,
|
||||
/// Last-updated timestamp, ns since epoch.
|
||||
pub timestamp_ns: u64,
|
||||
/// Confidence e-folding time in seconds (decay clock).
|
||||
pub decay_tau_s: f64,
|
||||
/// Evidence provenance.
|
||||
pub provenance: Provenance,
|
||||
/// Links into the scene graph / RuVector entities.
|
||||
pub links: Vec<EntityLink>,
|
||||
}
|
||||
|
||||
impl RfGaussian {
|
||||
/// Validated constructor: normalizes the quaternion and range-checks
|
||||
/// every numeric field (boundary rule — the map assumes validity).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
position: [f64; 3],
|
||||
scale: [f64; 3],
|
||||
orientation: [f64; 4],
|
||||
occupancy: f64,
|
||||
confidence: f64,
|
||||
timestamp_ns: u64,
|
||||
decay_tau_s: f64,
|
||||
provenance: Provenance,
|
||||
) -> Result<Self> {
|
||||
for v in position.iter().chain(scale.iter()).chain(orientation.iter()) {
|
||||
if !v.is_finite() {
|
||||
return Err(UnifiedError::InvalidInput("non-finite Gaussian field".into()));
|
||||
}
|
||||
}
|
||||
if scale.iter().any(|s| *s <= 0.0) {
|
||||
return Err(UnifiedError::InvalidInput(format!("scale must be > 0, got {scale:?}")));
|
||||
}
|
||||
let norm =
|
||||
orientation.iter().map(|q| q * q).sum::<f64>().sqrt();
|
||||
if norm < 1e-9 {
|
||||
return Err(UnifiedError::InvalidInput("zero quaternion".into()));
|
||||
}
|
||||
let orientation = [
|
||||
orientation[0] / norm,
|
||||
orientation[1] / norm,
|
||||
orientation[2] / norm,
|
||||
orientation[3] / norm,
|
||||
];
|
||||
if !(occupancy.is_finite() && occupancy >= 0.0) {
|
||||
return Err(UnifiedError::InvalidInput(format!("occupancy must be >= 0, got {occupancy}")));
|
||||
}
|
||||
if !(0.0..=1.0).contains(&confidence) {
|
||||
return Err(UnifiedError::InvalidInput(format!(
|
||||
"confidence must be in [0,1], got {confidence}"
|
||||
)));
|
||||
}
|
||||
if !(decay_tau_s.is_finite() && decay_tau_s > 0.0) {
|
||||
return Err(UnifiedError::InvalidInput(format!(
|
||||
"decay_tau_s must be > 0, got {decay_tau_s}"
|
||||
)));
|
||||
}
|
||||
Ok(Self {
|
||||
position,
|
||||
scale,
|
||||
orientation,
|
||||
occupancy,
|
||||
semantic: [0.0; SEMANTIC_DIM],
|
||||
reflectivity: [[0.0; ANGLE_BINS]; Band::COUNT],
|
||||
doppler_mps: 0.0,
|
||||
motion: MotionState::Static,
|
||||
confidence,
|
||||
timestamp_ns,
|
||||
decay_tau_s,
|
||||
provenance,
|
||||
links: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Rotation matrix (row-major 3×3) from the unit quaternion.
|
||||
#[must_use]
|
||||
pub fn rotation(&self) -> [[f64; 3]; 3] {
|
||||
let [w, x, y, z] = self.orientation;
|
||||
[
|
||||
[1.0 - 2.0 * (y * y + z * z), 2.0 * (x * y - w * z), 2.0 * (x * z + w * y)],
|
||||
[2.0 * (x * y + w * z), 1.0 - 2.0 * (x * x + z * z), 2.0 * (y * z - w * x)],
|
||||
[2.0 * (x * z - w * y), 2.0 * (y * z + w * x), 1.0 - 2.0 * (x * x + y * y)],
|
||||
]
|
||||
}
|
||||
|
||||
/// Inverse covariance `Σ⁻¹ = R·diag(1/σ²)·Rᵀ` (row-major 3×3).
|
||||
#[must_use]
|
||||
pub fn inv_covariance(&self) -> [[f64; 3]; 3] {
|
||||
let r = self.rotation();
|
||||
let inv_s2 = [
|
||||
1.0 / (self.scale[0] * self.scale[0]),
|
||||
1.0 / (self.scale[1] * self.scale[1]),
|
||||
1.0 / (self.scale[2] * self.scale[2]),
|
||||
];
|
||||
let mut out = [[0.0; 3]; 3];
|
||||
for i in 0..3 {
|
||||
for j in 0..3 {
|
||||
let mut acc = 0.0;
|
||||
for (k, is2) in inv_s2.iter().enumerate() {
|
||||
acc += r[i][k] * is2 * r[j][k];
|
||||
}
|
||||
out[i][j] = acc;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Unnormalized density `exp(-½·dᵀΣ⁻¹d)` at a point (1 at the centre).
|
||||
#[must_use]
|
||||
pub fn density_at(&self, p: [f64; 3]) -> f64 {
|
||||
let d = [p[0] - self.position[0], p[1] - self.position[1], p[2] - self.position[2]];
|
||||
let si = self.inv_covariance();
|
||||
let mut q = 0.0;
|
||||
for i in 0..3 {
|
||||
for j in 0..3 {
|
||||
q += d[i] * si[i][j] * d[j];
|
||||
}
|
||||
}
|
||||
(-0.5 * q).exp()
|
||||
}
|
||||
|
||||
/// Squared Mahalanobis distance of a point from this Gaussian.
|
||||
#[must_use]
|
||||
pub fn mahalanobis_sq(&self, p: [f64; 3]) -> f64 {
|
||||
let d = [p[0] - self.position[0], p[1] - self.position[1], p[2] - self.position[2]];
|
||||
let si = self.inv_covariance();
|
||||
let mut q = 0.0;
|
||||
for i in 0..3 {
|
||||
for j in 0..3 {
|
||||
q += d[i] * si[i][j] * d[j];
|
||||
}
|
||||
}
|
||||
q
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn prov() -> Provenance {
|
||||
Provenance { device_id: "test".into(), model_version: 1, synthetic: true }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constructor_validates_and_normalizes() {
|
||||
let g = RfGaussian::new(
|
||||
[1.0, 2.0, 1.0],
|
||||
[0.5, 0.5, 1.0],
|
||||
[2.0, 0.0, 0.0, 0.0], // non-unit quaternion → normalized
|
||||
0.5,
|
||||
0.9,
|
||||
0,
|
||||
300.0,
|
||||
prov(),
|
||||
)
|
||||
.expect("valid");
|
||||
assert!((g.orientation[0] - 1.0).abs() < 1e-12);
|
||||
|
||||
assert!(RfGaussian::new([0.0; 3], [0.0, 1.0, 1.0], [1.0, 0.0, 0.0, 0.0], 0.1, 0.5, 0, 60.0, prov())
|
||||
.is_err());
|
||||
assert!(RfGaussian::new([0.0; 3], [1.0; 3], [1.0, 0.0, 0.0, 0.0], -0.1, 0.5, 0, 60.0, prov())
|
||||
.is_err());
|
||||
assert!(RfGaussian::new([0.0; 3], [1.0; 3], [1.0, 0.0, 0.0, 0.0], 0.1, 1.5, 0, 60.0, prov())
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn density_peaks_at_centre_and_respects_anisotropy() {
|
||||
let g = RfGaussian::new(
|
||||
[0.0; 3],
|
||||
[1.0, 0.1, 1.0], // thin along y
|
||||
[1.0, 0.0, 0.0, 0.0],
|
||||
0.5,
|
||||
0.9,
|
||||
0,
|
||||
300.0,
|
||||
prov(),
|
||||
)
|
||||
.expect("valid");
|
||||
assert!((g.density_at([0.0; 3]) - 1.0).abs() < 1e-12);
|
||||
// Same offset along x (wide) vs y (thin): y decays far faster.
|
||||
// Analytic ratio at 0.3 m: exp(-0.045)/exp(-4.5) ≈ 86.
|
||||
assert!(g.density_at([0.3, 0.0, 0.0]) > g.density_at([0.0, 0.3, 0.0]) * 80.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotated_gaussian_rotates_its_metric() {
|
||||
// 90° rotation about z maps the thin y-axis onto x.
|
||||
let s = std::f64::consts::FRAC_1_SQRT_2;
|
||||
let g = RfGaussian::new(
|
||||
[0.0; 3],
|
||||
[1.0, 0.1, 1.0],
|
||||
[s, 0.0, 0.0, s], // quaternion for Rz(90°)
|
||||
0.5,
|
||||
0.9,
|
||||
0,
|
||||
300.0,
|
||||
prov(),
|
||||
)
|
||||
.expect("valid");
|
||||
assert!(g.density_at([0.0, 0.3, 0.0]) > g.density_at([0.3, 0.0, 0.0]) * 80.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn band_bucketing() {
|
||||
assert_eq!(Band::from_freq_hz(2.437e9), Band::Ghz2_4);
|
||||
assert_eq!(Band::from_freq_hz(5.18e9), Band::Ghz5);
|
||||
assert_eq!(Band::from_freq_hz(6.5e9), Band::Ghz6);
|
||||
assert_eq!(Band::from_freq_hz(60e9), Band::Ghz60);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
//! Task adapters ("heads") on the frozen encoder representation `z`
|
||||
//! (ADR-274 §4).
|
||||
//!
|
||||
//! The ADR-273 acceptance test allows adapters **smaller than 1 % of the
|
||||
//! backbone parameters** — enforced here by [`within_adapter_budget`] and by
|
||||
//! a unit test over every head at the deployment config. Multi-class heads
|
||||
//! use a low-rank (LoRA-style) factorization to stay inside the budget.
|
||||
|
||||
use crate::math::{sigmoid, softmax};
|
||||
|
||||
/// True iff the adapter fits the ADR-273 budget: `params(adapter) <
|
||||
/// 1 % · params(backbone)`.
|
||||
#[must_use]
|
||||
pub fn within_adapter_budget(backbone_params: usize, adapter_params: usize) -> bool {
|
||||
adapter_params * 100 < backbone_params
|
||||
}
|
||||
|
||||
/// Binary presence head: logistic regression on `z`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PresenceHead {
|
||||
/// Weights, length `d_model`.
|
||||
pub w: Vec<f64>,
|
||||
/// Bias.
|
||||
pub b: f64,
|
||||
}
|
||||
|
||||
impl PresenceHead {
|
||||
/// Zero-initialized head (logistic regression is convex; zero init is
|
||||
/// the standard, deterministic choice).
|
||||
#[must_use]
|
||||
pub fn new(d_model: usize) -> Self {
|
||||
Self { w: vec![0.0; d_model], b: 0.0 }
|
||||
}
|
||||
|
||||
/// Parameter count.
|
||||
#[must_use]
|
||||
pub fn param_count(&self) -> usize {
|
||||
self.w.len() + 1
|
||||
}
|
||||
|
||||
/// P(present | z).
|
||||
#[must_use]
|
||||
pub fn predict_prob(&self, z: &[f64]) -> f64 {
|
||||
sigmoid(self.w.iter().zip(z).map(|(w, x)| w * x).sum::<f64>() + self.b)
|
||||
}
|
||||
|
||||
/// Full-batch gradient-descent training (convex ⇒ deterministic
|
||||
/// convergence). Returns the final mean cross-entropy.
|
||||
pub fn train(&mut self, zs: &[Vec<f64>], labels: &[bool], lr: f64, epochs: usize) -> f64 {
|
||||
assert_eq!(zs.len(), labels.len());
|
||||
let n = zs.len() as f64;
|
||||
let mut final_ce = f64::INFINITY;
|
||||
for _ in 0..epochs {
|
||||
let mut gw = vec![0.0; self.w.len()];
|
||||
let mut gb = 0.0;
|
||||
let mut ce = 0.0;
|
||||
for (z, &y) in zs.iter().zip(labels) {
|
||||
let p = self.predict_prob(z);
|
||||
let t = if y { 1.0 } else { 0.0 };
|
||||
ce -= t * p.max(1e-12).ln() + (1.0 - t) * (1.0 - p).max(1e-12).ln();
|
||||
let err = (p - t) / n;
|
||||
for (g, x) in gw.iter_mut().zip(z) {
|
||||
*g += err * x;
|
||||
}
|
||||
gb += err;
|
||||
}
|
||||
for (w, g) in self.w.iter_mut().zip(&gw) {
|
||||
*w -= lr * g;
|
||||
}
|
||||
self.b -= lr * gb;
|
||||
final_ce = ce / n;
|
||||
}
|
||||
final_ce
|
||||
}
|
||||
}
|
||||
|
||||
/// Low-rank multi-class head: `logits = U·(V·z) + b` with rank `r`
|
||||
/// (LoRA-style factorization keeps `C`-class heads inside the 1 % budget).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ActivityHead {
|
||||
/// Classes.
|
||||
pub classes: usize,
|
||||
/// Rank.
|
||||
pub rank: usize,
|
||||
/// `classes × rank`, row-major.
|
||||
pub u: Vec<f64>,
|
||||
/// `rank × d_model`, row-major.
|
||||
pub v: Vec<f64>,
|
||||
/// Per-class bias.
|
||||
pub b: Vec<f64>,
|
||||
d_model: usize,
|
||||
}
|
||||
|
||||
impl ActivityHead {
|
||||
/// Deterministic small-value init (breaks the U/V symmetry without RNG).
|
||||
#[must_use]
|
||||
pub fn new(d_model: usize, classes: usize, rank: usize) -> Self {
|
||||
let u = (0..classes * rank)
|
||||
.map(|i| 0.01 * ((i % 7) as f64 - 3.0))
|
||||
.collect();
|
||||
let v = (0..rank * d_model)
|
||||
.map(|i| 0.01 * ((i % 5) as f64 - 2.0))
|
||||
.collect();
|
||||
Self { classes, rank, u, v, b: vec![0.0; classes], d_model }
|
||||
}
|
||||
|
||||
/// Parameter count.
|
||||
#[must_use]
|
||||
pub fn param_count(&self) -> usize {
|
||||
self.u.len() + self.v.len() + self.b.len()
|
||||
}
|
||||
|
||||
/// Class probabilities.
|
||||
#[must_use]
|
||||
pub fn predict(&self, z: &[f64]) -> Vec<f64> {
|
||||
let mut probs = self.logits(z);
|
||||
softmax(&mut probs);
|
||||
probs
|
||||
}
|
||||
|
||||
fn logits(&self, z: &[f64]) -> Vec<f64> {
|
||||
let mut vz = vec![0.0; self.rank];
|
||||
for r in 0..self.rank {
|
||||
let row = &self.v[r * self.d_model..(r + 1) * self.d_model];
|
||||
vz[r] = row.iter().zip(z).map(|(a, b)| a * b).sum();
|
||||
}
|
||||
let mut logits = self.b.clone();
|
||||
for c in 0..self.classes {
|
||||
let row = &self.u[c * self.rank..(c + 1) * self.rank];
|
||||
logits[c] += row.iter().zip(&vz).map(|(a, b)| a * b).sum::<f64>();
|
||||
}
|
||||
logits
|
||||
}
|
||||
|
||||
/// Full-batch softmax cross-entropy training. Returns final mean CE.
|
||||
pub fn train(&mut self, zs: &[Vec<f64>], labels: &[usize], lr: f64, epochs: usize) -> f64 {
|
||||
assert_eq!(zs.len(), labels.len());
|
||||
let n = zs.len() as f64;
|
||||
let mut final_ce = f64::INFINITY;
|
||||
for _ in 0..epochs {
|
||||
let mut gu = vec![0.0; self.u.len()];
|
||||
let mut gv = vec![0.0; self.v.len()];
|
||||
let mut gb = vec![0.0; self.b.len()];
|
||||
let mut ce = 0.0;
|
||||
for (z, &y) in zs.iter().zip(labels) {
|
||||
let mut vz = vec![0.0; self.rank];
|
||||
for r in 0..self.rank {
|
||||
let row = &self.v[r * self.d_model..(r + 1) * self.d_model];
|
||||
vz[r] = row.iter().zip(z).map(|(a, b)| a * b).sum();
|
||||
}
|
||||
let mut p = self.b.clone();
|
||||
for c in 0..self.classes {
|
||||
let row = &self.u[c * self.rank..(c + 1) * self.rank];
|
||||
p[c] += row.iter().zip(&vz).map(|(a, b)| a * b).sum::<f64>();
|
||||
}
|
||||
softmax(&mut p);
|
||||
ce -= p[y].max(1e-12).ln();
|
||||
// dlogits = p − one_hot(y), scaled by 1/n.
|
||||
let mut dvz = vec![0.0; self.rank];
|
||||
for c in 0..self.classes {
|
||||
let dl = (p[c] - if c == y { 1.0 } else { 0.0 }) / n;
|
||||
gb[c] += dl;
|
||||
for r in 0..self.rank {
|
||||
gu[c * self.rank + r] += dl * vz[r];
|
||||
dvz[r] += dl * self.u[c * self.rank + r];
|
||||
}
|
||||
}
|
||||
for r in 0..self.rank {
|
||||
for (d, zv) in z.iter().enumerate() {
|
||||
gv[r * self.d_model + d] += dvz[r] * zv;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (w, g) in self.u.iter_mut().zip(&gu) {
|
||||
*w -= lr * g;
|
||||
}
|
||||
for (w, g) in self.v.iter_mut().zip(&gv) {
|
||||
*w -= lr * g;
|
||||
}
|
||||
for (w, g) in self.b.iter_mut().zip(&gb) {
|
||||
*w -= lr * g;
|
||||
}
|
||||
final_ce = ce / n;
|
||||
}
|
||||
final_ce
|
||||
}
|
||||
}
|
||||
|
||||
/// Linear localization head: `xyz = W·z + b` (metres).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LocalizationHead {
|
||||
/// `3 × d_model` weights.
|
||||
pub w: Vec<f64>,
|
||||
/// Bias.
|
||||
pub b: [f64; 3],
|
||||
d_model: usize,
|
||||
}
|
||||
|
||||
impl LocalizationHead {
|
||||
/// Zero-initialized (linear least squares; convex).
|
||||
#[must_use]
|
||||
pub fn new(d_model: usize) -> Self {
|
||||
Self { w: vec![0.0; 3 * d_model], b: [0.0; 3], d_model }
|
||||
}
|
||||
|
||||
/// Parameter count.
|
||||
#[must_use]
|
||||
pub fn param_count(&self) -> usize {
|
||||
self.w.len() + 3
|
||||
}
|
||||
|
||||
/// Predicted position.
|
||||
#[must_use]
|
||||
pub fn predict(&self, z: &[f64]) -> [f64; 3] {
|
||||
let mut out = self.b;
|
||||
for (axis, o) in out.iter_mut().enumerate() {
|
||||
let row = &self.w[axis * self.d_model..(axis + 1) * self.d_model];
|
||||
*o += row.iter().zip(z).map(|(a, b)| a * b).sum::<f64>();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Full-batch MSE training. Returns final mean squared error (m²).
|
||||
pub fn train(&mut self, zs: &[Vec<f64>], targets: &[[f64; 3]], lr: f64, epochs: usize) -> f64 {
|
||||
assert_eq!(zs.len(), targets.len());
|
||||
let n = zs.len() as f64;
|
||||
let mut final_mse = f64::INFINITY;
|
||||
for _ in 0..epochs {
|
||||
let mut gw = vec![0.0; self.w.len()];
|
||||
let mut gb = [0.0; 3];
|
||||
let mut mse = 0.0;
|
||||
for (z, t) in zs.iter().zip(targets) {
|
||||
let pred = self.predict(z);
|
||||
for axis in 0..3 {
|
||||
let err = pred[axis] - t[axis];
|
||||
mse += err * err;
|
||||
let scale = 2.0 * err / (3.0 * n);
|
||||
gb[axis] += scale;
|
||||
for (d, zv) in z.iter().enumerate() {
|
||||
gw[axis * self.d_model + d] += scale * zv;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (w, g) in self.w.iter_mut().zip(&gw) {
|
||||
*w -= lr * g;
|
||||
}
|
||||
for (axis, g) in gb.iter().enumerate() {
|
||||
self.b[axis] -= lr * g;
|
||||
}
|
||||
final_mse = mse / (3.0 * n);
|
||||
}
|
||||
final_mse
|
||||
}
|
||||
}
|
||||
|
||||
/// Anomaly head: z-scores the encoder's masked-reconstruction error against
|
||||
/// a calibration distribution of *normal* windows. No learned weights —
|
||||
/// two calibration statistics.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AnomalyHead {
|
||||
/// Calibration mean of reconstruction error.
|
||||
pub mean: f64,
|
||||
/// Calibration standard deviation.
|
||||
pub std: f64,
|
||||
}
|
||||
|
||||
impl AnomalyHead {
|
||||
/// Calibrates from reconstruction errors of known-normal windows.
|
||||
///
|
||||
/// # Panics
|
||||
/// If fewer than 2 calibration samples are provided.
|
||||
#[must_use]
|
||||
pub fn calibrate(normal_errors: &[f64]) -> Self {
|
||||
assert!(normal_errors.len() >= 2, "need >= 2 calibration errors");
|
||||
let n = normal_errors.len() as f64;
|
||||
let mean = normal_errors.iter().sum::<f64>() / n;
|
||||
let var = normal_errors.iter().map(|e| (e - mean).powi(2)).sum::<f64>() / (n - 1.0);
|
||||
Self { mean, std: var.sqrt().max(1e-12) }
|
||||
}
|
||||
|
||||
/// Parameter count (two statistics).
|
||||
#[must_use]
|
||||
pub fn param_count(&self) -> usize {
|
||||
2
|
||||
}
|
||||
|
||||
/// Anomaly z-score for a window's reconstruction error.
|
||||
#[must_use]
|
||||
pub fn score(&self, recon_error: f64) -> f64 {
|
||||
(recon_error - self.mean) / self.std
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::encoder::{EncoderConfig, RfEncoder};
|
||||
|
||||
#[test]
|
||||
fn every_head_fits_the_one_percent_budget_at_deployment_config() {
|
||||
let enc = RfEncoder::new(EncoderConfig::default(), 1);
|
||||
let backbone = enc.param_count();
|
||||
let d = EncoderConfig::default().d_model;
|
||||
|
||||
let presence = PresenceHead::new(d).param_count();
|
||||
let activity = ActivityHead::new(d, 4, 2).param_count();
|
||||
let localization = LocalizationHead::new(d).param_count();
|
||||
let anomaly = AnomalyHead { mean: 0.0, std: 1.0 }.param_count();
|
||||
|
||||
for (name, p) in [
|
||||
("presence", presence),
|
||||
("activity", activity),
|
||||
("localization", localization),
|
||||
("anomaly", anomaly),
|
||||
] {
|
||||
assert!(
|
||||
within_adapter_budget(backbone, p),
|
||||
"{name} head has {p} params, budget is <{}",
|
||||
backbone / 100
|
||||
);
|
||||
}
|
||||
println!(
|
||||
"backbone {backbone} params; heads: presence {presence}, activity {activity}, \
|
||||
localization {localization}, anomaly {anomaly} (budget < {})",
|
||||
backbone / 100
|
||||
);
|
||||
}
|
||||
|
||||
fn separable_data(n: usize, d: usize) -> (Vec<Vec<f64>>, Vec<bool>) {
|
||||
let mut zs = Vec::new();
|
||||
let mut ys = Vec::new();
|
||||
for i in 0..n {
|
||||
let y = i % 2 == 0;
|
||||
let offset = if y { 1.0 } else { -1.0 };
|
||||
let z: Vec<f64> =
|
||||
(0..d).map(|k| offset * (0.5 + (k as f64 / d as f64)) + 0.1 * ((i * k) % 3) as f64).collect();
|
||||
zs.push(z);
|
||||
ys.push(y);
|
||||
}
|
||||
(zs, ys)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presence_head_learns_separable_data() {
|
||||
let (zs, ys) = separable_data(60, 16);
|
||||
let mut head = PresenceHead::new(16);
|
||||
let ce = head.train(&zs, &ys, 0.5, 200);
|
||||
assert!(ce < 0.1, "cross-entropy should collapse on separable data, got {ce}");
|
||||
let correct = zs
|
||||
.iter()
|
||||
.zip(&ys)
|
||||
.filter(|(z, &y)| (head.predict_prob(z) > 0.5) == y)
|
||||
.count();
|
||||
assert_eq!(correct, zs.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn activity_head_learns_multiclass_toy() {
|
||||
let d = 16;
|
||||
let mut zs = Vec::new();
|
||||
let mut ys = Vec::new();
|
||||
for i in 0..90 {
|
||||
let c = i % 3;
|
||||
let z: Vec<f64> = (0..d)
|
||||
.map(|k| if k % 3 == c { 1.0 } else { 0.0 } + 0.05 * ((i + k) % 5) as f64)
|
||||
.collect();
|
||||
zs.push(z);
|
||||
ys.push(c);
|
||||
}
|
||||
let mut head = ActivityHead::new(d, 3, 2);
|
||||
let ce = head.train(&zs, &ys, 0.5, 400);
|
||||
assert!(ce < 0.3, "multiclass CE should drop, got {ce}");
|
||||
let acc = zs
|
||||
.iter()
|
||||
.zip(&ys)
|
||||
.filter(|(z, &y)| {
|
||||
let p = head.predict(z);
|
||||
p.iter().enumerate().max_by(|a, b| a.1.partial_cmp(b.1).unwrap()).unwrap().0 == y
|
||||
})
|
||||
.count() as f64
|
||||
/ zs.len() as f64;
|
||||
assert!(acc > 0.95, "accuracy {acc}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anomaly_head_zscores_against_calibration() {
|
||||
let normal: Vec<f64> = (0..50).map(|i| 0.10 + 0.001 * (i % 7) as f64).collect();
|
||||
let head = AnomalyHead::calibrate(&normal);
|
||||
assert!(head.score(0.103).abs() < 3.0, "in-distribution error must not alarm");
|
||||
assert!(head.score(0.5) > 10.0, "gross error must alarm loudly");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
//! # ruview-unified — Unified RF Spatial World Model (ADR-273)
|
||||
//!
|
||||
//! One shared representation where WiFi CSI, cellular SRS, FMCW radar, UWB
|
||||
//! CIR, geometry, semantics, uncertainty, and time all update the same
|
||||
//! persistent scene memory, instead of another isolated RF classifier.
|
||||
//!
|
||||
//! The crate implements the five ADR-273 pillars as bounded modules:
|
||||
//!
|
||||
//! | Pillar | Module | ADR |
|
||||
//! |--------|--------|-----|
|
||||
//! | Canonical RF tensor + hardware adapter registry | [`tensor`], [`adapters`] | ADR-274 |
|
||||
//! | Universal RF foundation encoder (masked-reconstruction pretraining, age/geometry/uncertainty fusion, ≤1 % task adapters) | [`tokenizer`], [`encoder`], [`pretrain`], [`heads`] | ADR-274 |
|
||||
//! | Anti-leakage evaluation: strict partitions, calibration, abstention | [`eval`] | ADR-273 §5 |
|
||||
//! | RF-aware Gaussian spatial memory + task-gated scene graph | [`gaussian`] | ADR-275 |
|
||||
//! | Physics-guided synthetic RF world generator | [`synth`] | ADR-276 |
|
||||
//! | Edge sensing control plane (802.11bf / ETSI ISAC-aligned policy) | [`policy`] | ADR-277 |
|
||||
//!
|
||||
//! ## Design commitments
|
||||
//!
|
||||
//! - **Deterministic**: every stochastic step (weight init, masking, domain
|
||||
//! randomization) is seeded ChaCha20; same seed ⇒ identical results across
|
||||
//! machines (the nvsim commitment).
|
||||
//! - **Proven, not asserted**: the encoder's backward pass is verified against
|
||||
//! finite differences; the ray tracer is verified against Friis, reciprocity,
|
||||
//! and image-method geometry; the Gaussian gain model degrades to exact
|
||||
//! free-space when the map is empty.
|
||||
//! - **Honest labeling**: every accuracy number produced by this crate's tests
|
||||
//! is SYNTHETIC (generated by [`synth`]) until validated on measured data.
|
||||
//! - **Privacy fail-closed**: raw RF never crosses the trust boundary; only
|
||||
//! [`policy::BoundedEvent`] (uncertainty + provenance + model version +
|
||||
//! purpose, all mandatory) is exportable, and unknown zones/purposes deny.
|
||||
|
||||
pub mod adapters;
|
||||
pub mod encoder;
|
||||
pub mod eval;
|
||||
pub mod gaussian;
|
||||
pub mod heads;
|
||||
pub mod math;
|
||||
pub mod policy;
|
||||
pub mod pretrain;
|
||||
pub mod synth;
|
||||
pub mod tensor;
|
||||
pub mod tokenizer;
|
||||
|
||||
pub use adapters::{AdapterRegistry, RawCapture, RfAdapter};
|
||||
pub use encoder::{EncoderConfig, RfEncoder, WindowContext};
|
||||
pub use tensor::{CalibrationMeta, LinkGeometry, RfModality, RfTensor};
|
||||
|
||||
/// Errors produced at the crate's system boundaries.
|
||||
///
|
||||
/// Input validation happens at construction ([`RfTensor::new`]) and at
|
||||
/// adapter normalization; downstream modules may assume validated tensors.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum UnifiedError {
|
||||
/// A numeric field was non-finite or out of its documented range.
|
||||
#[error("invalid input at boundary: {0}")]
|
||||
InvalidInput(String),
|
||||
/// Tensor shape does not match its declared link geometry.
|
||||
#[error("shape mismatch: {0}")]
|
||||
ShapeMismatch(String),
|
||||
/// An adapter was handed a capture of the wrong modality.
|
||||
#[error("modality mismatch: adapter {adapter} cannot normalize {got:?}")]
|
||||
ModalityMismatch {
|
||||
/// Hardware id of the adapter that rejected the capture.
|
||||
adapter: String,
|
||||
/// Modality of the capture that was offered.
|
||||
got: tensor::RfModality,
|
||||
},
|
||||
/// No adapter registered for the requested hardware id.
|
||||
#[error("no adapter registered for hardware id {0:?}")]
|
||||
UnknownHardware(String),
|
||||
/// Model/data dimension disagreement (programmer error surfaced safely).
|
||||
#[error("dimension mismatch: {0}")]
|
||||
DimensionMismatch(String),
|
||||
/// The sensing control plane denied the operation (fail-closed).
|
||||
#[error("policy denied: {0}")]
|
||||
PolicyDenied(String),
|
||||
}
|
||||
|
||||
/// Crate-wide result alias.
|
||||
pub type Result<T> = std::result::Result<T, UnifiedError>;
|
||||
@@ -0,0 +1,276 @@
|
||||
//! Small, dependency-free numeric kernels shared across the crate.
|
||||
//!
|
||||
//! Everything here is deterministic and exact enough to be tested against
|
||||
//! closed forms: `erf` is Abramowitz–Stegun 7.1.26 (|ε| ≤ 1.5e-7), the DFT is
|
||||
//! the O(n²) definition (n ≤ 64 throughout this crate, so an FFT dependency
|
||||
//! would buy nothing), and the resampler is linear interpolation on the
|
||||
//! complex plane (amplitude/phase-continuous for the small bin ratios the
|
||||
//! adapters use).
|
||||
|
||||
use num_complex::Complex64;
|
||||
use rand::Rng;
|
||||
use rand_chacha::rand_core::SeedableRng;
|
||||
use rand_chacha::ChaCha20Rng;
|
||||
|
||||
/// Error function, Abramowitz & Stegun 7.1.26 rational approximation.
|
||||
///
|
||||
/// Maximum absolute error 1.5e-7 — far below the opacity resolution the
|
||||
/// Gaussian gain model needs.
|
||||
#[must_use]
|
||||
pub fn erf(x: f64) -> f64 {
|
||||
let sign = if x < 0.0 { -1.0 } else { 1.0 };
|
||||
let x = x.abs();
|
||||
let t = 1.0 / (1.0 + 0.327_591_1 * x);
|
||||
let poly = t
|
||||
* (0.254_829_592
|
||||
+ t * (-0.284_496_736 + t * (1.421_413_741 + t * (-1.453_152_027 + t * 1.061_405_429))));
|
||||
sign * (1.0 - poly * (-x * x).exp())
|
||||
}
|
||||
|
||||
/// Numerically stable logistic sigmoid.
|
||||
#[must_use]
|
||||
pub fn sigmoid(x: f64) -> f64 {
|
||||
if x >= 0.0 {
|
||||
1.0 / (1.0 + (-x).exp())
|
||||
} else {
|
||||
let e = x.exp();
|
||||
e / (1.0 + e)
|
||||
}
|
||||
}
|
||||
|
||||
/// In-place stable softmax.
|
||||
pub fn softmax(v: &mut [f64]) {
|
||||
let max = v.iter().copied().fold(f64::NEG_INFINITY, f64::max);
|
||||
let mut sum = 0.0;
|
||||
for x in v.iter_mut() {
|
||||
*x = (*x - max).exp();
|
||||
sum += *x;
|
||||
}
|
||||
for x in v.iter_mut() {
|
||||
*x /= sum;
|
||||
}
|
||||
}
|
||||
|
||||
/// Magnitudes of the first `k` DFT coefficients of `x` (definition-form DFT).
|
||||
///
|
||||
/// Used for delay-domain (across subcarriers) and Doppler-domain (across
|
||||
/// snapshots) token features. `k ≤ x.len()` is enforced by the callers.
|
||||
#[must_use]
|
||||
pub fn dft_magnitudes(x: &[Complex64], k: usize) -> Vec<f64> {
|
||||
let n = x.len();
|
||||
let mut out = Vec::with_capacity(k);
|
||||
for bin in 0..k {
|
||||
let mut acc = Complex64::new(0.0, 0.0);
|
||||
for (t, v) in x.iter().enumerate() {
|
||||
let ang = -2.0 * std::f64::consts::PI * (bin as f64) * (t as f64) / (n as f64);
|
||||
acc += v * Complex64::new(ang.cos(), ang.sin());
|
||||
}
|
||||
out.push(acc.norm() / n as f64);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Precomputed twiddle table for repeated fixed-size DFTs.
|
||||
///
|
||||
/// The naive [`dft_magnitudes`] recomputes `cos`/`sin` per sample; the
|
||||
/// tokenizer calls the transform once per token, so the table amortizes the
|
||||
/// trig. The optimization is *proven equivalent* in `tokenizer::tests` and
|
||||
/// its speedup is measured in `benches/unified_bench.rs`.
|
||||
pub struct DftPlan {
|
||||
n: usize,
|
||||
k: usize,
|
||||
/// Row-major `k × n` twiddles: `exp(-2πi·bin·t/n)`.
|
||||
twiddles: Vec<Complex64>,
|
||||
}
|
||||
|
||||
impl DftPlan {
|
||||
/// Builds a plan for length-`n` inputs and `k` output bins.
|
||||
#[must_use]
|
||||
pub fn new(n: usize, k: usize) -> Self {
|
||||
let mut twiddles = Vec::with_capacity(k * n);
|
||||
for bin in 0..k {
|
||||
for t in 0..n {
|
||||
let ang = -2.0 * std::f64::consts::PI * (bin as f64) * (t as f64) / (n as f64);
|
||||
twiddles.push(Complex64::new(ang.cos(), ang.sin()));
|
||||
}
|
||||
}
|
||||
Self { n, k, twiddles }
|
||||
}
|
||||
|
||||
/// DFT magnitudes via the precomputed table; identical (to f64 rounding)
|
||||
/// to [`dft_magnitudes`] on the same input.
|
||||
///
|
||||
/// # Panics
|
||||
/// If `x.len()` differs from the planned length.
|
||||
#[must_use]
|
||||
pub fn magnitudes(&self, x: &[Complex64]) -> Vec<f64> {
|
||||
assert_eq!(x.len(), self.n, "DftPlan length mismatch");
|
||||
let mut out = Vec::with_capacity(self.k);
|
||||
for bin in 0..self.k {
|
||||
let row = &self.twiddles[bin * self.n..(bin + 1) * self.n];
|
||||
let mut acc = Complex64::new(0.0, 0.0);
|
||||
for (v, w) in x.iter().zip(row) {
|
||||
acc += v * w;
|
||||
}
|
||||
out.push(acc.norm() / self.n as f64);
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// Linear interpolation of a complex series onto `m` uniformly spaced points.
|
||||
///
|
||||
/// Interpolates real and imaginary parts independently — adequate for the
|
||||
/// small resampling ratios (≤ 2×) the adapters perform, and exactly identity
|
||||
/// when `m == x.len()`.
|
||||
#[must_use]
|
||||
pub fn resample_complex(x: &[Complex64], m: usize) -> Vec<Complex64> {
|
||||
let n = x.len();
|
||||
if n == m {
|
||||
return x.to_vec();
|
||||
}
|
||||
if n == 1 {
|
||||
return vec![x[0]; m];
|
||||
}
|
||||
let mut out = Vec::with_capacity(m);
|
||||
for j in 0..m {
|
||||
let pos = (j as f64) * ((n - 1) as f64) / ((m - 1) as f64);
|
||||
let i0 = pos.floor() as usize;
|
||||
let i1 = (i0 + 1).min(n - 1);
|
||||
let frac = pos - i0 as f64;
|
||||
out.push(x[i0] * (1.0 - frac) + x[i1] * frac);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Median of a slice (copies; slices here are ≤ a few hundred elements).
|
||||
#[must_use]
|
||||
pub fn median(values: &[f64]) -> f64 {
|
||||
if values.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
let mut v: Vec<f64> = values.to_vec();
|
||||
v.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
|
||||
let mid = v.len() / 2;
|
||||
if v.len() % 2 == 0 {
|
||||
(v[mid - 1] + v[mid]) / 2.0
|
||||
} else {
|
||||
v[mid]
|
||||
}
|
||||
}
|
||||
|
||||
/// Least-squares slope of `y` against index `0..n` (used to detrend the
|
||||
/// linear phase ramp that sampling-time offset imprints across subcarriers).
|
||||
#[must_use]
|
||||
pub fn linear_slope(y: &[f64]) -> f64 {
|
||||
let n = y.len();
|
||||
if n < 2 {
|
||||
return 0.0;
|
||||
}
|
||||
let nf = n as f64;
|
||||
let mean_x = (nf - 1.0) / 2.0;
|
||||
let mean_y = y.iter().sum::<f64>() / nf;
|
||||
let mut num = 0.0;
|
||||
let mut den = 0.0;
|
||||
for (i, v) in y.iter().enumerate() {
|
||||
let dx = i as f64 - mean_x;
|
||||
num += dx * (v - mean_y);
|
||||
den += dx * dx;
|
||||
}
|
||||
num / den
|
||||
}
|
||||
|
||||
/// Deterministic RNG from a u64 seed (ChaCha20, the nvsim convention).
|
||||
#[must_use]
|
||||
pub fn seeded_rng(seed: u64) -> ChaCha20Rng {
|
||||
ChaCha20Rng::seed_from_u64(seed)
|
||||
}
|
||||
|
||||
/// Xavier/Glorot-uniform init for a `rows × cols` weight matrix, flattened
|
||||
/// row-major. Deterministic given the RNG state.
|
||||
pub fn xavier_init(rng: &mut ChaCha20Rng, rows: usize, cols: usize) -> Vec<f64> {
|
||||
let limit = (6.0 / (rows + cols) as f64).sqrt();
|
||||
(0..rows * cols).map(|_| rng.gen_range(-limit..limit)).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn erf_matches_known_values() {
|
||||
// erf(0)=0, erf(∞)→1, erf(1)=0.8427007929 (tabulated).
|
||||
// Tolerances are the A&S 7.1.26 approximation bound (1.5e-7), not
|
||||
// machine epsilon — at x=0 the rational polynomial leaves ~1e-9.
|
||||
assert!(erf(0.0).abs() < 2e-7);
|
||||
assert!((erf(1.0) - 0.842_700_792_9).abs() < 2e-7);
|
||||
assert!((erf(-1.0) + 0.842_700_792_9).abs() < 2e-7);
|
||||
assert!((erf(3.0) - 0.999_977_909_5).abs() < 2e-7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sigmoid_is_stable_and_symmetric() {
|
||||
assert!((sigmoid(0.0) - 0.5).abs() < 1e-12);
|
||||
assert!((sigmoid(500.0) - 1.0).abs() < 1e-12);
|
||||
assert!(sigmoid(-500.0) >= 0.0);
|
||||
assert!((sigmoid(2.0) + sigmoid(-2.0) - 1.0).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dft_finds_pure_tone() {
|
||||
// x[t] = exp(2πi·3t/16) has all its energy in bin 3.
|
||||
let n = 16;
|
||||
let x: Vec<Complex64> = (0..n)
|
||||
.map(|t| {
|
||||
let ang = 2.0 * std::f64::consts::PI * 3.0 * t as f64 / n as f64;
|
||||
Complex64::new(ang.cos(), ang.sin())
|
||||
})
|
||||
.collect();
|
||||
let mags = dft_magnitudes(&x, 8);
|
||||
assert!((mags[3] - 1.0).abs() < 1e-9);
|
||||
for (i, m) in mags.iter().enumerate() {
|
||||
if i != 3 {
|
||||
assert!(*m < 1e-9, "leakage at bin {i}: {m}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dft_plan_matches_naive() {
|
||||
let mut rng = seeded_rng(7);
|
||||
let x: Vec<Complex64> = (0..24)
|
||||
.map(|_| Complex64::new(rng.gen_range(-1.0..1.0), rng.gen_range(-1.0..1.0)))
|
||||
.collect();
|
||||
let plan = DftPlan::new(24, 10);
|
||||
let a = dft_magnitudes(&x, 10);
|
||||
let b = plan.magnitudes(&x);
|
||||
for (u, v) in a.iter().zip(&b) {
|
||||
assert!((u - v).abs() < 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resample_identity_and_endpoints() {
|
||||
let x: Vec<Complex64> = (0..10).map(|i| Complex64::new(i as f64, -(i as f64))).collect();
|
||||
assert_eq!(resample_complex(&x, 10), x);
|
||||
let y = resample_complex(&x, 25);
|
||||
assert_eq!(y.len(), 25);
|
||||
assert!((y[0] - x[0]).norm() < 1e-12);
|
||||
assert!((y[24] - x[9]).norm() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slope_recovers_linear_ramp() {
|
||||
let y: Vec<f64> = (0..50).map(|i| 0.37 * i as f64 + 2.0).collect();
|
||||
assert!((linear_slope(&y) - 0.37).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seeded_rng_is_deterministic() {
|
||||
let mut a = seeded_rng(42);
|
||||
let mut b = seeded_rng(42);
|
||||
let va: Vec<f64> = (0..8).map(|_| a.gen_range(-1.0..1.0)).collect();
|
||||
let vb: Vec<f64> = (0..8).map(|_| b.gen_range(-1.0..1.0)).collect();
|
||||
assert_eq!(va, vb);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
//! Edge sensing control plane (ADR-277) — purposes, zones, retention,
|
||||
//! identity gating, and the export trust boundary.
|
||||
//!
|
||||
//! Aligned with the sensing-service vocabulary of IEEE 802.11bf-2025 and
|
||||
//! the ETSI ISAC architecture (sensing purpose + sensing zone as first-class
|
||||
//! authorization objects; the ETSI security report's issue classes motivate
|
||||
//! the fail-closed defaults). Three hard rules, all enforced structurally:
|
||||
//!
|
||||
//! 1. **Raw RF never leaves the trust boundary.** The only exportable type
|
||||
//! is [`BoundedEvent`] — it cannot carry a tensor, and
|
||||
//! [`TrustBoundary::export`] is the only egress. There is deliberately
|
||||
//! no API that serializes an [`crate::tensor::RfTensor`] outward.
|
||||
//! 2. **Fail closed.** Unknown zone ⇒ deny. Purpose not granted ⇒ deny.
|
||||
//! Identity inference ⇒ deny unless the zone *explicitly* enables it in
|
||||
//! addition to granting the purpose.
|
||||
//! 3. **Every output is accountable.** A [`BoundedEvent`] cannot be built
|
||||
//! without uncertainty, provenance, model version, and purpose
|
||||
//! (ADR-273 acceptance item 8).
|
||||
|
||||
use std::collections::{BTreeSet, HashMap};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::gaussian::primitive::Provenance;
|
||||
use crate::{Result, UnifiedError};
|
||||
|
||||
/// Sensing purposes (ETSI ISAC sensing-service classes, WLAN-sensing
|
||||
/// aligned). Ordering matters only for display.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
pub enum SensingPurpose {
|
||||
/// Someone is / is not present.
|
||||
Presence,
|
||||
/// Coarse activity class.
|
||||
Activity,
|
||||
/// Respiration / heart-rate class vitals.
|
||||
Vitals,
|
||||
/// Position estimation.
|
||||
Localization,
|
||||
/// Skeletal pose tracking.
|
||||
PoseTracking,
|
||||
/// Identity recognition — the high-risk purpose; doubly gated.
|
||||
IdentityRecognition,
|
||||
/// RF channel diagnostics (no human inference).
|
||||
ChannelDiagnostics,
|
||||
}
|
||||
|
||||
/// A spatial sensing zone and what it permits.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PrivacyZone {
|
||||
/// Zone identifier (maps to rooms/regions in the scene graph).
|
||||
pub id: String,
|
||||
/// Purposes granted in this zone.
|
||||
pub allowed_purposes: BTreeSet<SensingPurpose>,
|
||||
/// Maximum event age at export, seconds (retention bound).
|
||||
pub retention_s: u64,
|
||||
/// Second factor for identity: even if `IdentityRecognition` is in
|
||||
/// `allowed_purposes`, it is denied unless this is also true.
|
||||
pub identity_explicitly_enabled: bool,
|
||||
}
|
||||
|
||||
/// Payload of a bounded event — semantically typed results only; no
|
||||
/// variant can carry raw RF samples.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum EventValue {
|
||||
/// Presence verdict.
|
||||
Presence(bool),
|
||||
/// Activity class index.
|
||||
ActivityClass(u8),
|
||||
/// Respiration rate, breaths/minute.
|
||||
RespirationBpm(f64),
|
||||
/// Position estimate, metres, room frame.
|
||||
Location([f64; 3]),
|
||||
/// Anomaly z-score.
|
||||
AnomalyScore(f64),
|
||||
}
|
||||
|
||||
/// The only type allowed across the trust boundary. Construction validates
|
||||
/// that the accountability fields are present and sane.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BoundedEvent {
|
||||
/// Purpose under which this event was produced.
|
||||
pub purpose: SensingPurpose,
|
||||
/// Typed result.
|
||||
pub value: EventValue,
|
||||
/// Mandatory uncertainty in `[0, 1]` (1 = no information).
|
||||
pub uncertainty: f64,
|
||||
/// Evidence provenance (device, model, synthetic flag).
|
||||
pub provenance: Provenance,
|
||||
/// Model version that produced the inference.
|
||||
pub model_version: u32,
|
||||
/// Event timestamp, ns since epoch.
|
||||
pub timestamp_ns: u64,
|
||||
/// Zone the event was sensed in.
|
||||
pub zone_id: String,
|
||||
}
|
||||
|
||||
impl BoundedEvent {
|
||||
/// Validated constructor — the only way to build an exportable event.
|
||||
pub fn new(
|
||||
purpose: SensingPurpose,
|
||||
value: EventValue,
|
||||
uncertainty: f64,
|
||||
provenance: Provenance,
|
||||
model_version: u32,
|
||||
timestamp_ns: u64,
|
||||
zone_id: impl Into<String>,
|
||||
) -> Result<Self> {
|
||||
if !(0.0..=1.0).contains(&uncertainty) {
|
||||
return Err(UnifiedError::InvalidInput(format!(
|
||||
"uncertainty must be in [0,1], got {uncertainty}"
|
||||
)));
|
||||
}
|
||||
if model_version == 0 {
|
||||
return Err(UnifiedError::InvalidInput(
|
||||
"model_version 0 (unassigned) is not exportable".into(),
|
||||
));
|
||||
}
|
||||
Ok(Self {
|
||||
purpose,
|
||||
value,
|
||||
uncertainty,
|
||||
provenance,
|
||||
model_version,
|
||||
timestamp_ns,
|
||||
zone_id: zone_id.into(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The policy engine: zone registry + authorization checks.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct PolicyEngine {
|
||||
zones: HashMap<String, PrivacyZone>,
|
||||
}
|
||||
|
||||
impl PolicyEngine {
|
||||
/// Empty engine (denies everything until zones are configured).
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Registers or replaces a zone.
|
||||
pub fn upsert_zone(&mut self, zone: PrivacyZone) {
|
||||
self.zones.insert(zone.id.clone(), zone);
|
||||
}
|
||||
|
||||
/// Authorizes sensing for `purpose` in `zone_id`. Fail-closed on every
|
||||
/// branch: unknown zone, ungranted purpose, and the identity double
|
||||
/// gate all deny.
|
||||
pub fn authorize(&self, zone_id: &str, purpose: SensingPurpose) -> Result<()> {
|
||||
let zone = self
|
||||
.zones
|
||||
.get(zone_id)
|
||||
.ok_or_else(|| UnifiedError::PolicyDenied(format!("unknown zone {zone_id:?}")))?;
|
||||
if !zone.allowed_purposes.contains(&purpose) {
|
||||
return Err(UnifiedError::PolicyDenied(format!(
|
||||
"purpose {purpose:?} not granted in zone {zone_id:?}"
|
||||
)));
|
||||
}
|
||||
if purpose == SensingPurpose::IdentityRecognition && !zone.identity_explicitly_enabled {
|
||||
return Err(UnifiedError::PolicyDenied(format!(
|
||||
"identity recognition requires explicit enablement in zone {zone_id:?}"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// The egress point. Holds the policy engine and a monotonically supplied
|
||||
/// "now"; the **only** public method emits [`BoundedEvent`]s — raw RF has no
|
||||
/// path through here by construction.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct TrustBoundary {
|
||||
engine: PolicyEngine,
|
||||
}
|
||||
|
||||
impl TrustBoundary {
|
||||
/// New boundary over a configured engine.
|
||||
#[must_use]
|
||||
pub fn new(engine: PolicyEngine) -> Self {
|
||||
Self { engine }
|
||||
}
|
||||
|
||||
/// Zone-config passthrough.
|
||||
pub fn engine_mut(&mut self) -> &mut PolicyEngine {
|
||||
&mut self.engine
|
||||
}
|
||||
|
||||
/// Exports an event if — and only if — the zone grants its purpose and
|
||||
/// the event is inside the zone's retention window at `now_ns`.
|
||||
/// Returns the event back on success so callers can hand it to a
|
||||
/// transport; on denial the event is dropped with a typed error.
|
||||
pub fn export(&self, event: BoundedEvent, now_ns: u64) -> Result<BoundedEvent> {
|
||||
self.engine.authorize(&event.zone_id, event.purpose)?;
|
||||
let zone = self
|
||||
.engine
|
||||
.zones
|
||||
.get(&event.zone_id)
|
||||
.expect("authorize verified the zone exists");
|
||||
let age_s = now_ns.saturating_sub(event.timestamp_ns) / 1_000_000_000;
|
||||
if age_s > zone.retention_s {
|
||||
return Err(UnifiedError::PolicyDenied(format!(
|
||||
"event age {age_s}s exceeds zone retention {}s",
|
||||
zone.retention_s
|
||||
)));
|
||||
}
|
||||
Ok(event)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn prov() -> Provenance {
|
||||
Provenance { device_id: "esp32s3-a1".into(), model_version: 3, synthetic: false }
|
||||
}
|
||||
|
||||
fn zone(purposes: &[SensingPurpose], identity: bool) -> PrivacyZone {
|
||||
PrivacyZone {
|
||||
id: "living-room".into(),
|
||||
allowed_purposes: purposes.iter().copied().collect(),
|
||||
retention_s: 3600,
|
||||
identity_explicitly_enabled: identity,
|
||||
}
|
||||
}
|
||||
|
||||
fn event(purpose: SensingPurpose, ts: u64) -> BoundedEvent {
|
||||
BoundedEvent::new(
|
||||
purpose,
|
||||
EventValue::Presence(true),
|
||||
0.12,
|
||||
prov(),
|
||||
3,
|
||||
ts,
|
||||
"living-room",
|
||||
)
|
||||
.expect("valid event")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_zone_denies() {
|
||||
let boundary = TrustBoundary::new(PolicyEngine::new());
|
||||
let err = boundary.export(event(SensingPurpose::Presence, 0), 0).unwrap_err();
|
||||
assert!(matches!(err, UnifiedError::PolicyDenied(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ungranted_purpose_denies() {
|
||||
let mut engine = PolicyEngine::new();
|
||||
engine.upsert_zone(zone(&[SensingPurpose::Presence], false));
|
||||
let boundary = TrustBoundary::new(engine);
|
||||
assert!(boundary.export(event(SensingPurpose::Presence, 0), 0).is_ok());
|
||||
assert!(matches!(
|
||||
boundary.export(event(SensingPurpose::Localization, 0), 0),
|
||||
Err(UnifiedError::PolicyDenied(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_needs_both_grant_and_explicit_enable() {
|
||||
// Granted in purposes but NOT explicitly enabled ⇒ deny.
|
||||
let mut engine = PolicyEngine::new();
|
||||
engine.upsert_zone(zone(
|
||||
&[SensingPurpose::Presence, SensingPurpose::IdentityRecognition],
|
||||
false,
|
||||
));
|
||||
assert!(matches!(
|
||||
engine.authorize("living-room", SensingPurpose::IdentityRecognition),
|
||||
Err(UnifiedError::PolicyDenied(_))
|
||||
));
|
||||
// Both factors present ⇒ allow.
|
||||
engine.upsert_zone(zone(
|
||||
&[SensingPurpose::Presence, SensingPurpose::IdentityRecognition],
|
||||
true,
|
||||
));
|
||||
assert!(engine.authorize("living-room", SensingPurpose::IdentityRecognition).is_ok());
|
||||
// Explicit flag alone (purpose not granted) ⇒ still deny.
|
||||
engine.upsert_zone(zone(&[SensingPurpose::Presence], true));
|
||||
assert!(engine.authorize("living-room", SensingPurpose::IdentityRecognition).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retention_bound_is_enforced() {
|
||||
let mut engine = PolicyEngine::new();
|
||||
engine.upsert_zone(zone(&[SensingPurpose::Presence], false));
|
||||
let boundary = TrustBoundary::new(engine);
|
||||
let e = event(SensingPurpose::Presence, 0);
|
||||
// Within retention (1 h): fine.
|
||||
assert!(boundary.export(e.clone(), 3_500 * 1_000_000_000).is_ok());
|
||||
// Beyond retention: denied.
|
||||
assert!(matches!(
|
||||
boundary.export(e, 3_700 * 1_000_000_000),
|
||||
Err(UnifiedError::PolicyDenied(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accountability_fields_are_mandatory() {
|
||||
// Out-of-range uncertainty refuses construction.
|
||||
assert!(BoundedEvent::new(
|
||||
SensingPurpose::Presence,
|
||||
EventValue::Presence(true),
|
||||
1.5,
|
||||
prov(),
|
||||
3,
|
||||
0,
|
||||
"z"
|
||||
)
|
||||
.is_err());
|
||||
// Unassigned model version refuses construction.
|
||||
assert!(BoundedEvent::new(
|
||||
SensingPurpose::Presence,
|
||||
EventValue::Presence(true),
|
||||
0.1,
|
||||
prov(),
|
||||
0,
|
||||
0,
|
||||
"z"
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,435 @@
|
||||
//! Masked-reconstruction pretraining for the RF foundation encoder
|
||||
//! (ADR-274 §3.2), with an exact hand-derived backward pass.
|
||||
//!
|
||||
//! The correctness argument is not "the loss went down" alone: the analytic
|
||||
//! gradients of *every* parameter group are verified against central finite
|
||||
//! differences (`tests::gradients_match_finite_differences`), which pins the
|
||||
//! backward pass to the forward pass to ~1e-8 relative error. The training
|
||||
//! loop is then ordinary SGD.
|
||||
|
||||
use rand::seq::SliceRandom;
|
||||
use rand::Rng;
|
||||
|
||||
use crate::encoder::{ForwardCache, Linear, RfEncoder, WindowContext};
|
||||
use crate::math::seeded_rng;
|
||||
use crate::tokenizer::{position_encoding, RfToken, TokenizedWindow};
|
||||
|
||||
/// Gradient accumulator mirroring [`RfEncoder`]'s parameter groups.
|
||||
pub struct EncoderGrads {
|
||||
/// Token embedding grads.
|
||||
pub w1: Linear,
|
||||
/// Context mixing 1 grads.
|
||||
pub w2: Linear,
|
||||
/// Context mixing 2 grads.
|
||||
pub w2b: Linear,
|
||||
/// Age gate weight grads.
|
||||
pub age_w: Vec<f64>,
|
||||
/// Age gate bias grads.
|
||||
pub age_b: Vec<f64>,
|
||||
/// Geometry encoder grads.
|
||||
pub wg: Linear,
|
||||
/// Reconstruction head grads.
|
||||
pub w3: Linear,
|
||||
}
|
||||
|
||||
impl EncoderGrads {
|
||||
fn zeros(enc: &RfEncoder) -> Self {
|
||||
Self {
|
||||
w1: enc.w1.zeros_like(),
|
||||
w2: enc.w2.zeros_like(),
|
||||
w2b: enc.w2b.zeros_like(),
|
||||
age_w: vec![0.0; enc.age_w.len()],
|
||||
age_b: vec![0.0; enc.age_b.len()],
|
||||
wg: enc.wg.zeros_like(),
|
||||
w3: enc.w3.zeros_like(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Mean-squared masked-reconstruction loss for one window under a fixed mask.
|
||||
#[must_use]
|
||||
pub fn masked_loss(enc: &RfEncoder, tokens: &[RfToken], masked: &[usize], ctx: WindowContext) -> f64 {
|
||||
let cache = enc.forward(tokens, masked, ctx);
|
||||
loss_from_cache(enc, &cache, tokens, masked)
|
||||
}
|
||||
|
||||
fn loss_from_cache(
|
||||
enc: &RfEncoder,
|
||||
cache: &ForwardCache,
|
||||
tokens: &[RfToken],
|
||||
masked: &[usize],
|
||||
) -> f64 {
|
||||
let d = enc.cfg.d_in;
|
||||
let mut loss = 0.0;
|
||||
for &j in masked {
|
||||
let xhat = enc.reconstruct(cache, j);
|
||||
for k in 0..d {
|
||||
loss += (xhat[k] - tokens[j].features[k]).powi(2);
|
||||
}
|
||||
}
|
||||
loss / (masked.len() as f64 * d as f64)
|
||||
}
|
||||
|
||||
/// Loss and analytic gradients for one window under a fixed mask.
|
||||
///
|
||||
/// Derivation (matching the forward pass in [`RfEncoder::forward`]):
|
||||
/// `∂L/∂x̂_j = 2(x̂_j − x_j)/(|M|·D)`; the reconstruction input is
|
||||
/// `u_j = [z ; pos(j)]`, so `∂L/∂z = Σ_j W3[:, :H]ᵀ ∂L/∂x̂_j`; the fusion
|
||||
/// `z = g⊙gate + Wg·geo + bg` splits the gradient into the tanh chain
|
||||
/// (`g → m → c → h_i → W1`) and the gate/geometry paths.
|
||||
#[must_use]
|
||||
pub fn masked_loss_and_grads(
|
||||
enc: &RfEncoder,
|
||||
tokens: &[RfToken],
|
||||
masked: &[usize],
|
||||
ctx: WindowContext,
|
||||
) -> (f64, EncoderGrads) {
|
||||
let h_dim = enc.cfg.d_model;
|
||||
let d = enc.cfg.d_in;
|
||||
let cache = enc.forward(tokens, masked, ctx);
|
||||
let mut grads = EncoderGrads::zeros(enc);
|
||||
|
||||
let norm = 1.0 / (masked.len() as f64 * d as f64);
|
||||
let mut dz = vec![0.0; h_dim];
|
||||
let mut loss = 0.0;
|
||||
for &j in masked {
|
||||
let mut u = cache.z.clone();
|
||||
u.extend_from_slice(&position_encoding(j)[..enc.cfg.d_pos]);
|
||||
let xhat = enc.w3.forward(&u);
|
||||
let mut dxhat = vec![0.0; d];
|
||||
for k in 0..d {
|
||||
let err = xhat[k] - tokens[j].features[k];
|
||||
loss += err * err;
|
||||
dxhat[k] = 2.0 * err * norm;
|
||||
}
|
||||
enc.w3.accumulate_grad(&mut grads.w3, &dxhat, &u);
|
||||
let du = enc.w3.backward_input(&dxhat);
|
||||
for k in 0..h_dim {
|
||||
dz[k] += du[k];
|
||||
}
|
||||
}
|
||||
loss *= norm;
|
||||
|
||||
// Fusion: z = g ⊙ gate + Wg·geo + bg.
|
||||
let mut dg = vec![0.0; h_dim];
|
||||
for k in 0..h_dim {
|
||||
let dgate = dz[k] * cache.g[k];
|
||||
dg[k] = dz[k] * cache.gate[k];
|
||||
let dsig = cache.gate[k] * (1.0 - cache.gate[k]);
|
||||
grads.age_w[k] += dgate * dsig * ctx.age_s;
|
||||
grads.age_b[k] += dgate * dsig;
|
||||
}
|
||||
enc.wg.accumulate_grad(&mut grads.wg, &dz, &ctx.geometry);
|
||||
|
||||
// g = tanh(W2b·m + b2b).
|
||||
let dg_pre: Vec<f64> = (0..h_dim).map(|k| dg[k] * (1.0 - cache.g[k] * cache.g[k])).collect();
|
||||
enc.w2b.accumulate_grad(&mut grads.w2b, &dg_pre, &cache.m);
|
||||
let dm = enc.w2b.backward_input(&dg_pre);
|
||||
|
||||
// m = tanh(W2·c + b2).
|
||||
let dm_pre: Vec<f64> = (0..h_dim).map(|k| dm[k] * (1.0 - cache.m[k] * cache.m[k])).collect();
|
||||
enc.w2.accumulate_grad(&mut grads.w2, &dm_pre, &cache.c);
|
||||
let dc = enc.w2.backward_input(&dm_pre);
|
||||
|
||||
// c = mean of h_i; h_i = tanh(W1·x_i + b1).
|
||||
let inv_n = 1.0 / cache.unmasked.len() as f64;
|
||||
for (slot, &i) in cache.unmasked.iter().enumerate() {
|
||||
let hi = &cache.h[slot];
|
||||
let dh_pre: Vec<f64> =
|
||||
(0..h_dim).map(|k| dc[k] * inv_n * (1.0 - hi[k] * hi[k])).collect();
|
||||
enc.w1.accumulate_grad(&mut grads.w1, &dh_pre, &tokens[i].features);
|
||||
}
|
||||
|
||||
(loss, grads)
|
||||
}
|
||||
|
||||
/// Applies one SGD step.
|
||||
pub fn apply_grads(enc: &mut RfEncoder, grads: &EncoderGrads, lr: f64) {
|
||||
enc.w1.sgd(&grads.w1, lr);
|
||||
enc.w2.sgd(&grads.w2, lr);
|
||||
enc.w2b.sgd(&grads.w2b, lr);
|
||||
for (p, g) in enc.age_w.iter_mut().zip(&grads.age_w) {
|
||||
*p -= lr * g;
|
||||
}
|
||||
for (p, g) in enc.age_b.iter_mut().zip(&grads.age_b) {
|
||||
*p -= lr * g;
|
||||
}
|
||||
enc.wg.sgd(&grads.wg, lr);
|
||||
enc.w3.sgd(&grads.w3, lr);
|
||||
}
|
||||
|
||||
/// Pretraining hyper-parameters.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct PretrainConfig {
|
||||
/// Fraction of tokens masked per window (≥1 token is always masked and
|
||||
/// ≥1 always kept).
|
||||
pub mask_fraction: f64,
|
||||
/// SGD learning rate.
|
||||
pub lr: f64,
|
||||
/// Epochs over the window set.
|
||||
pub epochs: usize,
|
||||
/// Mask-sampling seed (weight init is seeded separately at
|
||||
/// [`RfEncoder::new`]).
|
||||
pub seed: u64,
|
||||
}
|
||||
|
||||
impl Default for PretrainConfig {
|
||||
fn default() -> Self {
|
||||
Self { mask_fraction: 0.25, lr: 0.05, epochs: 30, seed: 0x5EED }
|
||||
}
|
||||
}
|
||||
|
||||
/// What pretraining measured (reported honestly, not smoothed).
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct PretrainReport {
|
||||
/// Mean masked loss over the corpus before any update (fixed eval mask).
|
||||
pub initial_loss: f64,
|
||||
/// Mean masked loss after the final epoch (same fixed eval mask).
|
||||
pub final_loss: f64,
|
||||
/// Epochs run.
|
||||
pub epochs: usize,
|
||||
}
|
||||
|
||||
fn sample_mask(rng: &mut rand_chacha::ChaCha20Rng, n_tokens: usize, fraction: f64) -> Vec<usize> {
|
||||
let n_mask = ((n_tokens as f64 * fraction).round() as usize).clamp(1, n_tokens - 1);
|
||||
let mut idx: Vec<usize> = (0..n_tokens).collect();
|
||||
idx.shuffle(rng);
|
||||
idx.truncate(n_mask);
|
||||
idx.sort_unstable();
|
||||
idx
|
||||
}
|
||||
|
||||
/// Runs masked-reconstruction SGD over `windows`, mutating `enc` in place.
|
||||
pub fn pretrain(
|
||||
enc: &mut RfEncoder,
|
||||
windows: &[TokenizedWindow],
|
||||
cfg: &PretrainConfig,
|
||||
) -> PretrainReport {
|
||||
assert!(!windows.is_empty(), "pretrain needs at least one window");
|
||||
let mut rng = seeded_rng(cfg.seed);
|
||||
|
||||
// Fixed evaluation masks so initial/final losses are comparable.
|
||||
let eval_masks: Vec<Vec<usize>> = windows
|
||||
.iter()
|
||||
.map(|w| sample_mask(&mut rng, w.tokens.len(), cfg.mask_fraction))
|
||||
.collect();
|
||||
let eval = |e: &RfEncoder| {
|
||||
windows
|
||||
.iter()
|
||||
.zip(&eval_masks)
|
||||
.map(|(w, m)| masked_loss(e, &w.tokens, m, WindowContext::from(w)))
|
||||
.sum::<f64>()
|
||||
/ windows.len() as f64
|
||||
};
|
||||
|
||||
let initial_loss = eval(enc);
|
||||
let mut order: Vec<usize> = (0..windows.len()).collect();
|
||||
for _ in 0..cfg.epochs {
|
||||
order.shuffle(&mut rng);
|
||||
for &wi in &order {
|
||||
let w = &windows[wi];
|
||||
if w.tokens.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
let mask = sample_mask(&mut rng, w.tokens.len(), cfg.mask_fraction);
|
||||
let (_, grads) =
|
||||
masked_loss_and_grads(enc, &w.tokens, &mask, WindowContext::from(w));
|
||||
apply_grads(enc, &grads, cfg.lr);
|
||||
}
|
||||
}
|
||||
let final_loss = eval(enc);
|
||||
PretrainReport { initial_loss, final_loss, epochs: cfg.epochs }
|
||||
}
|
||||
|
||||
/// Deterministic pseudo-random corpus where tokens within a window share a
|
||||
/// latent factor — masked tokens are predictable from context, so a correct
|
||||
/// learner must beat the constant predictor. Used by tests and benches.
|
||||
#[must_use]
|
||||
pub fn correlated_toy_windows(n_windows: usize, tokens_per_window: usize, seed: u64) -> Vec<TokenizedWindow> {
|
||||
use crate::tokenizer::{RfToken, D_IN};
|
||||
let mut rng = seeded_rng(seed);
|
||||
(0..n_windows)
|
||||
.map(|_| {
|
||||
let latent: f64 = rng.gen_range(-1.0..1.0);
|
||||
let tokens = (0..tokens_per_window)
|
||||
.map(|k| {
|
||||
let mut f = [0.0f64; D_IN];
|
||||
for (d, v) in f.iter_mut().enumerate() {
|
||||
// Smooth deterministic function of (latent, token, dim)
|
||||
// plus small noise: reconstructable from context.
|
||||
*v = 0.6 * (latent * (1.0 + d as f64 / 8.0) + k as f64 * 0.3).sin()
|
||||
+ rng.gen_range(-0.05..0.05);
|
||||
}
|
||||
RfToken { features: f, link: 0, group: k }
|
||||
})
|
||||
.collect();
|
||||
TokenizedWindow {
|
||||
tokens,
|
||||
age_s: rng.gen_range(0.0..0.5),
|
||||
geometry: [0.1, 0.0, 0.2, 0.4, 0.0, 0.2],
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::encoder::EncoderConfig;
|
||||
|
||||
/// Central finite differences over EVERY parameter group. This is the
|
||||
/// crate's proof that the backward pass matches the forward pass.
|
||||
#[test]
|
||||
fn gradients_match_finite_differences() {
|
||||
let cfg = EncoderConfig { d_in: 24, d_pos: 6, d_model: 7 };
|
||||
let mut enc = RfEncoder::new(cfg, 11);
|
||||
let windows = correlated_toy_windows(1, 5, 21);
|
||||
let tokens = &windows[0].tokens;
|
||||
let ctx = WindowContext { age_s: 0.3, geometry: [0.1, -0.2, 0.3, 0.0, 0.2, -0.1] };
|
||||
let masked = vec![1, 3];
|
||||
|
||||
let (_, grads) = masked_loss_and_grads(&enc, tokens, &masked, ctx);
|
||||
|
||||
let eps = 1e-6;
|
||||
let mut checked = 0usize;
|
||||
let mut max_rel = 0.0f64;
|
||||
|
||||
// Closure-free param walker: (getter, analytic grad) pairs by index.
|
||||
// Group 0: w1.w, 1: w1.b, 2: w2.w, 3: w2.b, 4: w2b.w, 5: w2b.b,
|
||||
// 6: age_w, 7: age_b, 8: wg.w, 9: wg.b, 10: w3.w, 11: w3.b.
|
||||
for group in 0..12 {
|
||||
let len = match group {
|
||||
0 => enc.w1.w.len(),
|
||||
1 => enc.w1.b.len(),
|
||||
2 => enc.w2.w.len(),
|
||||
3 => enc.w2.b.len(),
|
||||
4 => enc.w2b.w.len(),
|
||||
5 => enc.w2b.b.len(),
|
||||
6 => enc.age_w.len(),
|
||||
7 => enc.age_b.len(),
|
||||
8 => enc.wg.w.len(),
|
||||
9 => enc.wg.b.len(),
|
||||
10 => enc.w3.w.len(),
|
||||
_ => enc.w3.b.len(),
|
||||
};
|
||||
// Sample a spread of indices per group to keep the test fast
|
||||
// while touching every group.
|
||||
let stride = (len / 17).max(1);
|
||||
for idx in (0..len).step_by(stride) {
|
||||
fn param_at(e: &mut RfEncoder, group: usize, idx: usize) -> &mut f64 {
|
||||
match group {
|
||||
0 => &mut e.w1.w[idx],
|
||||
1 => &mut e.w1.b[idx],
|
||||
2 => &mut e.w2.w[idx],
|
||||
3 => &mut e.w2.b[idx],
|
||||
4 => &mut e.w2b.w[idx],
|
||||
5 => &mut e.w2b.b[idx],
|
||||
6 => &mut e.age_w[idx],
|
||||
7 => &mut e.age_b[idx],
|
||||
8 => &mut e.wg.w[idx],
|
||||
9 => &mut e.wg.b[idx],
|
||||
10 => &mut e.w3.w[idx],
|
||||
_ => &mut e.w3.b[idx],
|
||||
}
|
||||
}
|
||||
let analytic = match group {
|
||||
0 => grads.w1.w[idx],
|
||||
1 => grads.w1.b[idx],
|
||||
2 => grads.w2.w[idx],
|
||||
3 => grads.w2.b[idx],
|
||||
4 => grads.w2b.w[idx],
|
||||
5 => grads.w2b.b[idx],
|
||||
6 => grads.age_w[idx],
|
||||
7 => grads.age_b[idx],
|
||||
8 => grads.wg.w[idx],
|
||||
9 => grads.wg.b[idx],
|
||||
10 => grads.w3.w[idx],
|
||||
_ => grads.w3.b[idx],
|
||||
};
|
||||
|
||||
let orig = *param_at(&mut enc, group, idx);
|
||||
*param_at(&mut enc, group, idx) = orig + eps;
|
||||
let lp = masked_loss(&enc, tokens, &masked, ctx);
|
||||
*param_at(&mut enc, group, idx) = orig - eps;
|
||||
let lm = masked_loss(&enc, tokens, &masked, ctx);
|
||||
*param_at(&mut enc, group, idx) = orig;
|
||||
|
||||
let numeric = (lp - lm) / (2.0 * eps);
|
||||
let denom = analytic.abs().max(numeric.abs()).max(1e-8);
|
||||
let rel = (analytic - numeric).abs() / denom;
|
||||
// Accept either a tight relative match or an absolute
|
||||
// difference at the central-difference roundoff floor
|
||||
// (ε_machine·|L|/ε ≈ 5e-11) — tiny gradients hit the floor.
|
||||
assert!(
|
||||
rel < 1e-5 || (analytic - numeric).abs() < 1e-9,
|
||||
"group {group} idx {idx}: analytic {analytic:.3e} vs numeric {numeric:.3e} (rel {rel:.3e})"
|
||||
);
|
||||
max_rel = max_rel.max(rel);
|
||||
checked += 1;
|
||||
}
|
||||
}
|
||||
assert!(checked > 150, "gradient check must cover a real sample, got {checked}");
|
||||
println!("gradient check: {checked} params, max relative error {max_rel:.3e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pretraining_reduces_masked_loss_and_beats_mean_baseline() {
|
||||
let windows = correlated_toy_windows(40, 8, 99);
|
||||
let mut enc = RfEncoder::new(EncoderConfig { d_in: 24, d_pos: 8, d_model: 32 }, 7);
|
||||
let report = pretrain(
|
||||
&mut enc,
|
||||
&windows,
|
||||
&PretrainConfig { mask_fraction: 0.25, lr: 0.05, epochs: 40, seed: 123 },
|
||||
);
|
||||
assert!(
|
||||
report.final_loss < 0.5 * report.initial_loss,
|
||||
"loss must at least halve: {report:?}"
|
||||
);
|
||||
|
||||
// Constant (global-mean) predictor baseline on the same corpus: the
|
||||
// per-dim variance of token features. The encoder must beat it —
|
||||
// otherwise it learned nothing about context.
|
||||
let mut all: Vec<[f64; 24]> = Vec::new();
|
||||
for w in &windows {
|
||||
for t in &w.tokens {
|
||||
all.push(t.features);
|
||||
}
|
||||
}
|
||||
let n = all.len() as f64;
|
||||
let mut mean = [0.0f64; 24];
|
||||
for f in &all {
|
||||
for (m, v) in mean.iter_mut().zip(f) {
|
||||
*m += v / n;
|
||||
}
|
||||
}
|
||||
let mut var = 0.0;
|
||||
for f in &all {
|
||||
for (m, v) in mean.iter().zip(f) {
|
||||
var += (v - m).powi(2);
|
||||
}
|
||||
}
|
||||
var /= n * 24.0;
|
||||
assert!(
|
||||
report.final_loss < 0.8 * var,
|
||||
"must beat constant predictor: final {} vs baseline variance {}",
|
||||
report.final_loss,
|
||||
var
|
||||
);
|
||||
println!(
|
||||
"pretrain: initial {:.4} → final {:.4} (baseline variance {:.4})",
|
||||
report.initial_loss, report.final_loss, var
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn training_is_deterministic() {
|
||||
let windows = correlated_toy_windows(10, 6, 5);
|
||||
let cfg = PretrainConfig { mask_fraction: 0.3, lr: 0.05, epochs: 5, seed: 77 };
|
||||
let mut a = RfEncoder::new(EncoderConfig { d_in: 24, d_pos: 8, d_model: 16 }, 2);
|
||||
let mut b = RfEncoder::new(EncoderConfig { d_in: 24, d_pos: 8, d_model: 16 }, 2);
|
||||
let ra = pretrain(&mut a, &windows, &cfg);
|
||||
let rb = pretrain(&mut b, &windows, &cfg);
|
||||
assert_eq!(a.w1.w, b.w1.w);
|
||||
assert!((ra.final_loss - rb.final_loss).abs() < 1e-15);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
//! Domain-randomized synthetic dataset generator (ADR-276 §4).
|
||||
//!
|
||||
//! Randomizes *physics parameters*, not textures: room geometry, wall
|
||||
//! permittivity/conductivity, antenna placement, person kinematics and RCS,
|
||||
//! plus the hardware nuisances that break naive models in the field —
|
||||
//! chipset gain and phase offsets, carrier-frequency-offset drift, phase
|
||||
//! noise, packet loss (snapshot duplication), and interference bursts.
|
||||
//! Every window carries a full [`PartitionKey`] so the ADR-273 strict
|
||||
//! anti-leakage splits (held-out rooms / days / people / chipsets /
|
||||
//! firmware / layouts) are possible by construction.
|
||||
|
||||
use ndarray::Array3;
|
||||
use num_complex::Complex64;
|
||||
use rand::Rng;
|
||||
|
||||
use crate::eval::PartitionKey;
|
||||
use crate::math::seeded_rng;
|
||||
use crate::synth::raytrace::synthesize_csi;
|
||||
use crate::synth::room::{Material, PersonSpec, RoomSpec};
|
||||
use crate::tensor::{
|
||||
CalibrationMeta, LinkGeometry, RfModality, RfTensor, CANONICAL_BINS, CANONICAL_SNAPSHOTS,
|
||||
};
|
||||
|
||||
/// Generator configuration.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct SynthConfig {
|
||||
/// Master seed (same seed ⇒ byte-identical corpus).
|
||||
pub seed: u64,
|
||||
/// Number of distinct rooms.
|
||||
pub n_rooms: usize,
|
||||
/// Windows per room (half with a person, half empty, interleaved).
|
||||
pub windows_per_room: usize,
|
||||
/// Links (TX→RX pairs) per room.
|
||||
pub links: usize,
|
||||
/// Snapshot spacing in seconds.
|
||||
pub snapshot_dt_s: f64,
|
||||
}
|
||||
|
||||
impl Default for SynthConfig {
|
||||
fn default() -> Self {
|
||||
Self { seed: 0xC0FFEE, n_rooms: 8, windows_per_room: 24, links: 3, snapshot_dt_s: 0.05 }
|
||||
}
|
||||
}
|
||||
|
||||
/// One labeled synthetic window.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LabeledWindow {
|
||||
/// Canonical tensor (modality [`RfModality::Synthetic`]).
|
||||
pub tensor: RfTensor,
|
||||
/// Whether a person is present in the room during this window.
|
||||
pub presence: bool,
|
||||
/// Person position at the window's mid-time, when present.
|
||||
pub person_pos: Option<[f64; 3]>,
|
||||
/// Full provenance key for strict splits.
|
||||
pub key: PartitionKey,
|
||||
}
|
||||
|
||||
/// Per-room randomized nuisance profile (the "chipset").
|
||||
#[derive(Debug, Clone)]
|
||||
struct HardwareProfile {
|
||||
chipset: String,
|
||||
firmware: String,
|
||||
layout: String,
|
||||
gain: f64,
|
||||
phase_offset: f64,
|
||||
cfo_rad_per_snap: f64,
|
||||
noise_sigma: f64,
|
||||
}
|
||||
|
||||
/// The generator.
|
||||
pub struct SynthGenerator {
|
||||
cfg: SynthConfig,
|
||||
}
|
||||
|
||||
impl SynthGenerator {
|
||||
/// New generator.
|
||||
#[must_use]
|
||||
pub fn new(cfg: SynthConfig) -> Self {
|
||||
Self { cfg }
|
||||
}
|
||||
|
||||
/// 56 subcarrier frequencies over 20 MHz around 2.437 GHz.
|
||||
#[must_use]
|
||||
pub fn subcarrier_freqs() -> Vec<f64> {
|
||||
(0..CANONICAL_BINS)
|
||||
.map(|k| 2.437e9 - 10e6 + 20e6 * k as f64 / (CANONICAL_BINS - 1) as f64)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Generates the full labeled corpus, deterministically from the seed.
|
||||
///
|
||||
/// # Panics
|
||||
/// Only on internal invariant violation (tensor construction from
|
||||
/// generated finite values cannot fail).
|
||||
#[must_use]
|
||||
pub fn generate(&self) -> Vec<LabeledWindow> {
|
||||
let mut rng = seeded_rng(self.cfg.seed);
|
||||
let freqs = Self::subcarrier_freqs();
|
||||
let mut out = Vec::with_capacity(self.cfg.n_rooms * self.cfg.windows_per_room);
|
||||
|
||||
for room_idx in 0..self.cfg.n_rooms {
|
||||
// --- Randomized physics for this room ---
|
||||
let size = [
|
||||
rng.gen_range(4.0..10.0),
|
||||
rng.gen_range(3.0..8.0),
|
||||
rng.gen_range(2.4..3.2),
|
||||
];
|
||||
let material = Material {
|
||||
rel_permittivity: rng.gen_range(2.0..7.0),
|
||||
conductivity_s_m: rng.gen_range(0.002..0.1),
|
||||
};
|
||||
let links: Vec<LinkGeometry> = (0..self.cfg.links)
|
||||
.map(|_| LinkGeometry {
|
||||
tx_pos: [
|
||||
rng.gen_range(0.3..size[0] - 0.3),
|
||||
rng.gen_range(0.3..size[1] - 0.3),
|
||||
rng.gen_range(1.0..2.0),
|
||||
],
|
||||
rx_pos: [
|
||||
rng.gen_range(0.3..size[0] - 0.3),
|
||||
rng.gen_range(0.3..size[1] - 0.3),
|
||||
rng.gen_range(1.0..2.0),
|
||||
],
|
||||
})
|
||||
.collect();
|
||||
let hw = HardwareProfile {
|
||||
chipset: format!("chip-{}", room_idx % 3),
|
||||
firmware: format!("fw-{}", room_idx % 2),
|
||||
layout: format!("layout-{}", (room_idx / 2) % 2),
|
||||
gain: rng.gen_range(0.5..2.0),
|
||||
phase_offset: rng.gen_range(-std::f64::consts::PI..std::f64::consts::PI),
|
||||
cfo_rad_per_snap: rng.gen_range(-0.3..0.3),
|
||||
noise_sigma: rng.gen_range(0.01..0.05),
|
||||
};
|
||||
let person_id = format!("p{}", room_idx % 4);
|
||||
// Person kinematics randomized per room; the person walks a
|
||||
// straight segment that stays inside the room for the corpus
|
||||
// duration (velocity kept small relative to room size).
|
||||
let person = PersonSpec {
|
||||
start: [
|
||||
rng.gen_range(size[0] * 0.25..size[0] * 0.75),
|
||||
rng.gen_range(size[1] * 0.25..size[1] * 0.75),
|
||||
rng.gen_range(1.0..1.6),
|
||||
],
|
||||
velocity: {
|
||||
let speed = rng.gen_range(0.3..1.0);
|
||||
let ang: f64 = rng.gen_range(0.0..std::f64::consts::TAU);
|
||||
[speed * ang.cos() * 0.2, speed * ang.sin() * 0.2, 0.0]
|
||||
},
|
||||
rcs_m2: rng.gen_range(0.3..0.8),
|
||||
};
|
||||
|
||||
let occupied = RoomSpec::new(size, material, vec![person]).expect("generated in range");
|
||||
let empty = RoomSpec::new(size, material, vec![]).expect("generated in range");
|
||||
|
||||
for w in 0..self.cfg.windows_per_room {
|
||||
let presence = w % 2 == 0;
|
||||
let room = if presence { &occupied } else { &empty };
|
||||
// Window start times cycle so the person oscillates within
|
||||
// the room instead of walking out of it.
|
||||
let t0 = (w % 6) as f64 * CANONICAL_SNAPSHOTS as f64 * self.cfg.snapshot_dt_s;
|
||||
|
||||
let mut data =
|
||||
Array3::zeros((self.cfg.links, CANONICAL_BINS, CANONICAL_SNAPSHOTS));
|
||||
for (l, link) in links.iter().enumerate() {
|
||||
let mut prev: Option<Vec<Complex64>> = None;
|
||||
for s in 0..CANONICAL_SNAPSHOTS {
|
||||
let t = t0 + s as f64 * self.cfg.snapshot_dt_s;
|
||||
// Packet loss: 5 % of snapshots re-deliver the
|
||||
// previous frame instead of a fresh capture.
|
||||
let lost = prev.is_some() && rng.gen_bool(0.05);
|
||||
let h: Vec<Complex64> = if lost {
|
||||
prev.clone().expect("guarded by prev.is_some()")
|
||||
} else {
|
||||
synthesize_csi(room, link.tx_pos, link.rx_pos, &freqs, t)
|
||||
};
|
||||
// Chipset gain + static phase + CFO drift.
|
||||
let rot = Complex64::from_polar(
|
||||
hw.gain,
|
||||
hw.phase_offset + hw.cfo_rad_per_snap * s as f64,
|
||||
);
|
||||
// Interference burst: 3 % of snapshots take a strong
|
||||
// wideband hit; otherwise thermal noise only.
|
||||
let burst = if rng.gen_bool(0.03) { 10.0 } else { 1.0 };
|
||||
for (b, hv) in h.iter().enumerate() {
|
||||
let noise = Complex64::new(
|
||||
rng.gen_range(-1.0..1.0) * hw.noise_sigma * burst * 1e-4,
|
||||
rng.gen_range(-1.0..1.0) * hw.noise_sigma * burst * 1e-4,
|
||||
);
|
||||
data[[l, b, s]] = hv * rot + noise;
|
||||
}
|
||||
prev = Some(h);
|
||||
}
|
||||
}
|
||||
|
||||
let mid_t = t0 + 0.5 * CANONICAL_SNAPSHOTS as f64 * self.cfg.snapshot_dt_s;
|
||||
let tensor = RfTensor::new(
|
||||
RfModality::Synthetic,
|
||||
2.437e9,
|
||||
20e6,
|
||||
data,
|
||||
links.clone(),
|
||||
rng.gen_range(0.0..0.2),
|
||||
(room_idx as u64) << 32 | w as u64,
|
||||
format!("synth-{}", hw.chipset),
|
||||
rng.gen_range(0.6..0.95),
|
||||
(hw.noise_sigma / 0.05).clamp(0.0, 1.0) * 0.5,
|
||||
CalibrationMeta::default(),
|
||||
)
|
||||
.expect("generated tensor is finite and in range");
|
||||
|
||||
out.push(LabeledWindow {
|
||||
tensor,
|
||||
presence,
|
||||
person_pos: presence.then(|| occupied.people[0].position_at(mid_t)),
|
||||
key: PartitionKey {
|
||||
room: format!("room-{room_idx}"),
|
||||
day: format!("day-{}", w / (self.cfg.windows_per_room / 2).max(1)),
|
||||
person: if presence { person_id.clone() } else { "none".into() },
|
||||
chipset: hw.chipset.clone(),
|
||||
firmware: hw.firmware.clone(),
|
||||
layout: hw.layout.clone(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn small_cfg(seed: u64) -> SynthConfig {
|
||||
SynthConfig { seed, n_rooms: 3, windows_per_room: 6, links: 2, snapshot_dt_s: 0.05 }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corpus_is_byte_deterministic_per_seed() {
|
||||
let a = SynthGenerator::new(small_cfg(7)).generate();
|
||||
let b = SynthGenerator::new(small_cfg(7)).generate();
|
||||
assert_eq!(a.len(), b.len());
|
||||
for (x, y) in a.iter().zip(&b) {
|
||||
assert_eq!(x.presence, y.presence);
|
||||
assert_eq!(x.key, y.key);
|
||||
for (u, v) in x.tensor.data.iter().zip(y.tensor.data.iter()) {
|
||||
assert!(u == v, "same seed must give identical complex samples");
|
||||
}
|
||||
}
|
||||
// And a different seed gives a different corpus.
|
||||
let c = SynthGenerator::new(small_cfg(8)).generate();
|
||||
assert!(a
|
||||
.iter()
|
||||
.zip(&c)
|
||||
.any(|(x, y)| x.tensor.data.iter().zip(y.tensor.data.iter()).any(|(u, v)| u != v)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presence_windows_carry_more_temporal_energy() {
|
||||
let corpus = SynthGenerator::new(small_cfg(42)).generate();
|
||||
let temporal_energy = |w: &LabeledWindow| {
|
||||
// Mean per-bin variance across snapshots.
|
||||
let (links, bins, snaps) = w.tensor.dims();
|
||||
let mut acc = 0.0;
|
||||
for l in 0..links {
|
||||
for b in 0..bins {
|
||||
let vals: Vec<f64> =
|
||||
(0..snaps).map(|s| w.tensor.data[[l, b, s]].norm()).collect();
|
||||
let m = vals.iter().sum::<f64>() / snaps as f64;
|
||||
acc += vals.iter().map(|v| (v - m).powi(2)).sum::<f64>() / snaps as f64;
|
||||
}
|
||||
}
|
||||
acc / (links * bins) as f64
|
||||
};
|
||||
let present: Vec<f64> =
|
||||
corpus.iter().filter(|w| w.presence).map(temporal_energy).collect();
|
||||
let absent: Vec<f64> =
|
||||
corpus.iter().filter(|w| !w.presence).map(temporal_energy).collect();
|
||||
let mean = |v: &[f64]| v.iter().sum::<f64>() / v.len() as f64;
|
||||
assert!(
|
||||
mean(&present) > 5.0 * mean(&absent),
|
||||
"a moving person must dominate temporal variance: present {} vs absent {}",
|
||||
mean(&present),
|
||||
mean(&absent)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn labels_and_partition_keys_are_complete() {
|
||||
let corpus = SynthGenerator::new(small_cfg(1)).generate();
|
||||
assert_eq!(corpus.len(), 18);
|
||||
for w in &corpus {
|
||||
assert_eq!(w.tensor.modality, RfModality::Synthetic, "honest labeling");
|
||||
assert_eq!(w.presence, w.person_pos.is_some());
|
||||
assert!(!w.key.room.is_empty());
|
||||
assert!(!w.key.chipset.is_empty());
|
||||
if let Some(p) = w.person_pos {
|
||||
assert!(p.iter().all(|v| v.is_finite()));
|
||||
}
|
||||
}
|
||||
// Multiple rooms exist so strict room-holdout splits are possible.
|
||||
let rooms: std::collections::BTreeSet<&str> =
|
||||
corpus.iter().map(|w| w.key.room.as_str()).collect();
|
||||
assert_eq!(rooms.len(), 3);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
//! Physics-guided synthetic RF world generator (ADR-276).
|
||||
//!
|
||||
//! Generates labeled CSI windows from first-principles multipath physics —
|
||||
//! shoebox rooms via the Allen–Berkley image method (reflection order ≤ 2),
|
||||
//! Fresnel wall materials with complex permittivity, moving people as
|
||||
//! bistatic point scatterers (Doppler emerges from path-length change, it is
|
||||
//! never injected), plus domain randomization of the *physics* parameters
|
||||
//! (materials, geometry, chipset gain/phase/noise, CFO, packet loss,
|
||||
//! interference) rather than cosmetic noise.
|
||||
//!
|
||||
//! Everything is seeded ChaCha20-deterministic, and every tensor produced
|
||||
//! here is stamped [`crate::tensor::RfModality::Synthetic`] — the honest
|
||||
//! label ADR-276 requires until results are validated on measured data.
|
||||
//!
|
||||
//! Physics anchors proven in tests:
|
||||
//! - direct path amplitude ≡ Friis (`raytrace::tests::direct_path_is_exact_friis`)
|
||||
//! - reciprocity `H(a→b) = H(b→a)`
|
||||
//! - first-order reflection delay ≡ mirror-image geometry
|
||||
//! - a walking person produces the analytically expected Doppler phase rate
|
||||
|
||||
pub mod generator;
|
||||
pub mod raytrace;
|
||||
pub mod room;
|
||||
|
||||
pub use generator::{LabeledWindow, SynthConfig, SynthGenerator};
|
||||
pub use raytrace::{enumerate_paths, synthesize_csi, PathContribution};
|
||||
pub use room::{Material, PersonSpec, RoomSpec};
|
||||
@@ -0,0 +1,273 @@
|
||||
//! Image-method multipath ray tracer for shoebox rooms (ADR-276 §3).
|
||||
//!
|
||||
//! Allen–Berkley mirror images up to reflection order 2 plus single-bounce
|
||||
//! bistatic scattering off each person. The channel at frequency `f` is
|
||||
//!
|
||||
//! ```text
|
||||
//! H(f) = Σ_paths Γ_p · (c/f)/(4π) · s_p · e^{-j·2πf·d_p/c}
|
||||
//! ```
|
||||
//!
|
||||
//! with `s_p = 1/d` for wall paths and `s_p = √(σ/4π)/(d₁·d₂)` for person
|
||||
//! scattering (bistatic radar equation, amplitude form). Doppler is never
|
||||
//! injected: it emerges from the person's path length changing between
|
||||
//! snapshots.
|
||||
|
||||
use num_complex::Complex64;
|
||||
|
||||
use super::room::RoomSpec;
|
||||
|
||||
const C: f64 = 299_792_458.0;
|
||||
|
||||
/// One propagation path.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct PathContribution {
|
||||
/// Total geometric path length (metres) — sets delay and phase.
|
||||
pub distance_m: f64,
|
||||
/// Amplitude scale multiplying `λ/(4π)`: `1/d` for wall paths,
|
||||
/// `√(σ/4π)/(d₁·d₂)` for scatterers.
|
||||
pub amp_scale: f64,
|
||||
/// Product of complex reflection coefficients along the path
|
||||
/// (`Γ^order`, evaluated at the carrier).
|
||||
pub reflection: Complex64,
|
||||
/// Number of wall bounces (0 = direct or scatterer path).
|
||||
pub order: usize,
|
||||
}
|
||||
|
||||
/// Enumerates all wall-image paths of order ≤ `max_order` plus person
|
||||
/// scattering paths, for the room state at time `t` seconds.
|
||||
#[must_use]
|
||||
pub fn enumerate_paths(
|
||||
room: &RoomSpec,
|
||||
tx: [f64; 3],
|
||||
rx: [f64; 3],
|
||||
carrier_hz: f64,
|
||||
t: f64,
|
||||
max_order: usize,
|
||||
) -> Vec<PathContribution> {
|
||||
let gamma = room.wall_material.reflection_coefficient(carrier_hz);
|
||||
let mut paths = Vec::new();
|
||||
|
||||
// Allen–Berkley images: per axis, sign ∈ {+, −} and lattice shift
|
||||
// n ∈ {−1, 0, 1}; bounce count is |2n| for +, |2n−1| for −.
|
||||
for sx in [1i64, -1] {
|
||||
for nx in -1i64..=1 {
|
||||
let bx = if sx == 1 { (2 * nx).unsigned_abs() } else { (2 * nx - 1).unsigned_abs() };
|
||||
if bx as usize > max_order {
|
||||
continue;
|
||||
}
|
||||
for sy in [1i64, -1] {
|
||||
for ny in -1i64..=1 {
|
||||
let by =
|
||||
if sy == 1 { (2 * ny).unsigned_abs() } else { (2 * ny - 1).unsigned_abs() };
|
||||
if (bx + by) as usize > max_order {
|
||||
continue;
|
||||
}
|
||||
for sz in [1i64, -1] {
|
||||
for nz in -1i64..=1 {
|
||||
let bz = if sz == 1 {
|
||||
(2 * nz).unsigned_abs()
|
||||
} else {
|
||||
(2 * nz - 1).unsigned_abs()
|
||||
};
|
||||
let order = (bx + by + bz) as usize;
|
||||
if order > max_order {
|
||||
continue;
|
||||
}
|
||||
let img = [
|
||||
sx as f64 * tx[0] + 2.0 * nx as f64 * room.size[0],
|
||||
sy as f64 * tx[1] + 2.0 * ny as f64 * room.size[1],
|
||||
sz as f64 * tx[2] + 2.0 * nz as f64 * room.size[2],
|
||||
];
|
||||
let d = ((img[0] - rx[0]).powi(2)
|
||||
+ (img[1] - rx[1]).powi(2)
|
||||
+ (img[2] - rx[2]).powi(2))
|
||||
.sqrt();
|
||||
if d < 1e-9 {
|
||||
continue;
|
||||
}
|
||||
let reflection = if order == 0 {
|
||||
Complex64::new(1.0, 0.0)
|
||||
} else {
|
||||
gamma.powu(order as u32)
|
||||
};
|
||||
// Reflections with |Γ|=0 contribute nothing.
|
||||
if order > 0 && reflection.norm() < 1e-15 {
|
||||
continue;
|
||||
}
|
||||
paths.push(PathContribution {
|
||||
distance_m: d,
|
||||
amp_scale: 1.0 / d,
|
||||
reflection,
|
||||
order,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Person scatterers (single bounce TX → person → RX).
|
||||
for person in &room.people {
|
||||
let p = person.position_at(t);
|
||||
let d1 = ((p[0] - tx[0]).powi(2) + (p[1] - tx[1]).powi(2) + (p[2] - tx[2]).powi(2)).sqrt();
|
||||
let d2 = ((p[0] - rx[0]).powi(2) + (p[1] - rx[1]).powi(2) + (p[2] - rx[2]).powi(2)).sqrt();
|
||||
if d1 < 1e-9 || d2 < 1e-9 {
|
||||
continue;
|
||||
}
|
||||
paths.push(PathContribution {
|
||||
distance_m: d1 + d2,
|
||||
amp_scale: (person.rcs_m2 / (4.0 * std::f64::consts::PI)).sqrt() / (d1 * d2),
|
||||
reflection: Complex64::new(1.0, 0.0),
|
||||
order: 0,
|
||||
});
|
||||
}
|
||||
|
||||
paths
|
||||
}
|
||||
|
||||
/// Synthesizes the channel frequency response at each `freqs_hz` for the
|
||||
/// room state at time `t`.
|
||||
#[must_use]
|
||||
pub fn synthesize_csi(
|
||||
room: &RoomSpec,
|
||||
tx: [f64; 3],
|
||||
rx: [f64; 3],
|
||||
freqs_hz: &[f64],
|
||||
t: f64,
|
||||
) -> Vec<Complex64> {
|
||||
let carrier = freqs_hz[freqs_hz.len() / 2];
|
||||
let paths = enumerate_paths(room, tx, rx, carrier, t, 2);
|
||||
freqs_hz
|
||||
.iter()
|
||||
.map(|&f| {
|
||||
let mut h = Complex64::new(0.0, 0.0);
|
||||
for p in &paths {
|
||||
let amp = (C / f) / (4.0 * std::f64::consts::PI) * p.amp_scale;
|
||||
let phase = -2.0 * std::f64::consts::PI * f * p.distance_m / C;
|
||||
h += p.reflection * Complex64::from_polar(amp, phase);
|
||||
}
|
||||
h
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::synth::room::{Material, PersonSpec};
|
||||
|
||||
fn freqs() -> Vec<f64> {
|
||||
// 56 subcarriers over 20 MHz around 2.437 GHz.
|
||||
(0..56).map(|k| 2.437e9 - 10e6 + 20e6 * k as f64 / 55.0).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_path_is_exact_friis() {
|
||||
// Absorber walls ⇒ only the direct path survives.
|
||||
let room = RoomSpec::new([8.0, 6.0, 3.0], Material::absorber(), vec![]).unwrap();
|
||||
let tx = [1.0, 1.0, 1.5];
|
||||
let rx = [5.0, 4.0, 1.5]; // d = 5
|
||||
let freqs = freqs();
|
||||
let h = synthesize_csi(&room, tx, rx, &freqs, 0.0);
|
||||
for (f, hv) in freqs.iter().zip(&h) {
|
||||
let expected = (C / f) / (4.0 * std::f64::consts::PI * 5.0);
|
||||
assert!(
|
||||
(hv.norm() - expected).abs() < 1e-15,
|
||||
"at {f} Hz: |H| = {} vs Friis {expected}",
|
||||
hv.norm()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_is_reciprocal() {
|
||||
let people = vec![PersonSpec { start: [3.0, 2.0, 1.2], velocity: [0.4, 0.1, 0.0], rcs_m2: 0.5 }];
|
||||
let room = RoomSpec::new([8.0, 6.0, 3.0], Material::concrete(), people).unwrap();
|
||||
let a = [1.0, 1.0, 1.5];
|
||||
let b = [6.5, 4.2, 1.1];
|
||||
let freqs = freqs();
|
||||
let fwd = synthesize_csi(&room, a, b, &freqs, 0.35);
|
||||
let rev = synthesize_csi(&room, b, a, &freqs, 0.35);
|
||||
for (x, y) in fwd.iter().zip(&rev) {
|
||||
assert!((x - y).norm() < 1e-12, "H(a→b) must equal H(b→a): {x} vs {y}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_order_reflection_matches_mirror_geometry() {
|
||||
let room = RoomSpec::new([8.0, 6.0, 3.0], Material::concrete(), vec![]).unwrap();
|
||||
let tx = [2.0, 3.0, 1.5];
|
||||
let rx = [6.0, 3.0, 1.5];
|
||||
let paths = enumerate_paths(&room, tx, rx, 2.437e9, 0.0, 2);
|
||||
|
||||
// Floor bounce (z = 0): mirror TX to z = −1.5; d = √(4² + 3²) = 5.
|
||||
let floor = ((tx[0] - rx[0]).powi(2)
|
||||
+ (tx[1] - rx[1]).powi(2)
|
||||
+ (-tx[2] - rx[2]).powi(2))
|
||||
.sqrt();
|
||||
assert!((floor - 5.0).abs() < 1e-12, "test geometry sanity");
|
||||
assert!(
|
||||
paths.iter().any(|p| p.order == 1 && (p.distance_m - 5.0).abs() < 1e-12),
|
||||
"floor-bounce image path at exactly 5 m must exist"
|
||||
);
|
||||
|
||||
// Ceiling bounce (z = 3): mirror TX to z = 4.5; d = √(16 + 9) = 5.
|
||||
assert!(
|
||||
paths
|
||||
.iter()
|
||||
.any(|p| p.order == 1 && (p.distance_m - 5.0).abs() < 1e-12
|
||||
&& p.distance_m > 0.0),
|
||||
"ceiling-bounce path must exist"
|
||||
);
|
||||
|
||||
// Path count sanity: direct + 6 first-order + second-order set, all
|
||||
// with |Γ| > 0 for concrete.
|
||||
assert!(paths.iter().filter(|p| p.order == 1).count() == 6, "6 first-order walls");
|
||||
assert!(paths.iter().any(|p| p.order == 2));
|
||||
assert_eq!(paths.iter().filter(|p| p.order == 0).count(), 1, "one direct path");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moving_person_produces_the_analytic_doppler_phase_rate() {
|
||||
// Person walking radially outward along the TX–RX bisector normal;
|
||||
// compare the residual (person-only) phase rotation between
|
||||
// snapshots against −2πf·Δd/c.
|
||||
let person = PersonSpec { start: [4.0, 2.0, 1.2], velocity: [0.0, 0.8, 0.0], rcs_m2: 0.6 };
|
||||
let with_person =
|
||||
RoomSpec::new([8.0, 6.0, 3.0], Material::drywall(), vec![person]).unwrap();
|
||||
let empty = RoomSpec::new([8.0, 6.0, 3.0], Material::drywall(), vec![]).unwrap();
|
||||
let tx = [1.0, 2.0, 1.5];
|
||||
let rx = [7.0, 2.0, 1.5];
|
||||
let f = [2.437e9];
|
||||
let dt = 0.05;
|
||||
|
||||
for step in 0..4 {
|
||||
let t0 = step as f64 * dt;
|
||||
let t1 = t0 + dt;
|
||||
let resid0 = synthesize_csi(&with_person, tx, rx, &f, t0)[0]
|
||||
- synthesize_csi(&empty, tx, rx, &f, t0)[0];
|
||||
let resid1 = synthesize_csi(&with_person, tx, rx, &f, t1)[0]
|
||||
- synthesize_csi(&empty, tx, rx, &f, t1)[0];
|
||||
let measured_dphi = (resid1 * resid0.conj()).arg();
|
||||
|
||||
let path_len = |t: f64| {
|
||||
let p = person.position_at(t);
|
||||
let d1 = ((p[0] - tx[0]).powi(2) + (p[1] - tx[1]).powi(2) + (p[2] - tx[2]).powi(2))
|
||||
.sqrt();
|
||||
let d2 = ((p[0] - rx[0]).powi(2) + (p[1] - rx[1]).powi(2) + (p[2] - rx[2]).powi(2))
|
||||
.sqrt();
|
||||
d1 + d2
|
||||
};
|
||||
let expected_dphi = -2.0 * std::f64::consts::PI * f[0] * (path_len(t1) - path_len(t0)) / C;
|
||||
// Compare on the unit circle (phases are mod 2π).
|
||||
let diff = (Complex64::from_polar(1.0, measured_dphi)
|
||||
* Complex64::from_polar(1.0, -expected_dphi))
|
||||
.arg();
|
||||
assert!(
|
||||
diff.abs() < 1e-6,
|
||||
"step {step}: measured Δφ {measured_dphi} vs analytic {expected_dphi}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
//! Room, material, and person specifications for the synthetic world
|
||||
//! (ADR-276 §2).
|
||||
|
||||
use num_complex::Complex64;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{Result, UnifiedError};
|
||||
|
||||
/// Vacuum permittivity (F/m).
|
||||
const EPS0: f64 = 8.854_187_812_8e-12;
|
||||
|
||||
/// Wall material with complex permittivity — the quantity domain
|
||||
/// randomization varies (randomize physics, not textures).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Material {
|
||||
/// Relative permittivity ε_r (> 1 for solids).
|
||||
pub rel_permittivity: f64,
|
||||
/// Conductivity σ in S/m (loss term).
|
||||
pub conductivity_s_m: f64,
|
||||
}
|
||||
|
||||
impl Material {
|
||||
/// Typical poured concrete (ITU-R P.2040 ballpark).
|
||||
#[must_use]
|
||||
pub fn concrete() -> Self {
|
||||
Self { rel_permittivity: 5.3, conductivity_s_m: 0.073 }
|
||||
}
|
||||
|
||||
/// Gypsum drywall.
|
||||
#[must_use]
|
||||
pub fn drywall() -> Self {
|
||||
Self { rel_permittivity: 2.9, conductivity_s_m: 0.016 }
|
||||
}
|
||||
|
||||
/// Window glass.
|
||||
#[must_use]
|
||||
pub fn glass() -> Self {
|
||||
Self { rel_permittivity: 6.3, conductivity_s_m: 0.004 }
|
||||
}
|
||||
|
||||
/// Perfect absorber (anechoic) — kills all reflections; used by tests to
|
||||
/// isolate the direct path.
|
||||
#[must_use]
|
||||
pub fn absorber() -> Self {
|
||||
Self { rel_permittivity: 1.0, conductivity_s_m: 0.0 }
|
||||
}
|
||||
|
||||
/// Complex relative permittivity at frequency `f`:
|
||||
/// `ε = ε_r − j·σ/(ω·ε₀)`.
|
||||
#[must_use]
|
||||
pub fn complex_permittivity(&self, freq_hz: f64) -> Complex64 {
|
||||
let omega = 2.0 * std::f64::consts::PI * freq_hz;
|
||||
Complex64::new(self.rel_permittivity, -self.conductivity_s_m / (omega * EPS0))
|
||||
}
|
||||
|
||||
/// Normal-incidence Fresnel amplitude reflection coefficient
|
||||
/// `Γ = (1 − √ε)/(1 + √ε)` against air. (Normal incidence is the
|
||||
/// standard image-method simplification; angle dependence is a
|
||||
/// randomizable refinement, not a correctness requirement.)
|
||||
#[must_use]
|
||||
pub fn reflection_coefficient(&self, freq_hz: f64) -> Complex64 {
|
||||
let sqrt_eps = self.complex_permittivity(freq_hz).sqrt();
|
||||
(Complex64::new(1.0, 0.0) - sqrt_eps) / (Complex64::new(1.0, 0.0) + sqrt_eps)
|
||||
}
|
||||
}
|
||||
|
||||
/// A person modeled as a moving bistatic point scatterer.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub struct PersonSpec {
|
||||
/// Position at t = 0, metres.
|
||||
pub start: [f64; 3],
|
||||
/// Constant velocity, m/s.
|
||||
pub velocity: [f64; 3],
|
||||
/// Radar cross-section, m² (torso ≈ 0.3–1.0 at WiFi bands).
|
||||
pub rcs_m2: f64,
|
||||
}
|
||||
|
||||
impl PersonSpec {
|
||||
/// Position at time `t` seconds.
|
||||
#[must_use]
|
||||
pub fn position_at(&self, t: f64) -> [f64; 3] {
|
||||
[
|
||||
self.start[0] + self.velocity[0] * t,
|
||||
self.start[1] + self.velocity[1] * t,
|
||||
self.start[2] + self.velocity[2] * t,
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
/// A shoebox room: `[0, Lx] × [0, Ly] × [0, Lz]` with one wall material and
|
||||
/// zero or more people.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RoomSpec {
|
||||
/// Interior dimensions `[Lx, Ly, Lz]`, metres.
|
||||
pub size: [f64; 3],
|
||||
/// Wall/floor/ceiling material.
|
||||
pub wall_material: Material,
|
||||
/// Occupants.
|
||||
pub people: Vec<PersonSpec>,
|
||||
}
|
||||
|
||||
impl RoomSpec {
|
||||
/// Validated constructor: positive dimensions, finite fields, people
|
||||
/// starting inside the room.
|
||||
pub fn new(size: [f64; 3], wall_material: Material, people: Vec<PersonSpec>) -> Result<Self> {
|
||||
if size.iter().any(|s| !s.is_finite() || *s <= 0.0) {
|
||||
return Err(UnifiedError::InvalidInput(format!(
|
||||
"room dimensions must be finite and positive, got {size:?}"
|
||||
)));
|
||||
}
|
||||
for p in &people {
|
||||
for (axis, v) in p.start.iter().enumerate() {
|
||||
if !v.is_finite() || *v < 0.0 || *v > size[axis] {
|
||||
return Err(UnifiedError::InvalidInput(format!(
|
||||
"person start {:?} outside room {size:?}",
|
||||
p.start
|
||||
)));
|
||||
}
|
||||
}
|
||||
if p.rcs_m2 <= 0.0 || !p.rcs_m2.is_finite() {
|
||||
return Err(UnifiedError::InvalidInput(format!(
|
||||
"rcs_m2 must be positive, got {}",
|
||||
p.rcs_m2
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(Self { size, wall_material, people })
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn fresnel_coefficient_sane_for_concrete() {
|
||||
let g = Material::concrete().reflection_coefficient(2.437e9);
|
||||
// Concrete at 2.4 GHz: |Γ| ≈ 0.39–0.45, negative real part
|
||||
// (denser medium ⇒ phase inversion).
|
||||
assert!(g.norm() > 0.3 && g.norm() < 0.5, "|Γ| = {}", g.norm());
|
||||
assert!(g.re < 0.0);
|
||||
// Physical bound: |Γ| < 1 for any passive material.
|
||||
for m in [Material::drywall(), Material::glass(), Material::concrete()] {
|
||||
assert!(m.reflection_coefficient(5.18e9).norm() < 1.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absorber_reflects_nothing() {
|
||||
let g = Material::absorber().reflection_coefficient(2.437e9);
|
||||
assert!(g.norm() < 1e-12, "ε_r = 1, σ = 0 must give Γ = 0, got {g}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn room_validation_rejects_bad_specs() {
|
||||
assert!(RoomSpec::new([4.0, -3.0, 2.5], Material::drywall(), vec![]).is_err());
|
||||
let outside = PersonSpec { start: [9.0, 1.0, 1.0], velocity: [0.0; 3], rcs_m2: 0.5 };
|
||||
assert!(RoomSpec::new([4.0, 3.0, 2.5], Material::drywall(), vec![outside]).is_err());
|
||||
let ok = PersonSpec { start: [2.0, 1.0, 1.0], velocity: [0.5, 0.0, 0.0], rcs_m2: 0.5 };
|
||||
let room = RoomSpec::new([4.0, 3.0, 2.5], Material::drywall(), vec![ok]).expect("valid");
|
||||
assert_eq!(room.people.len(), 1);
|
||||
assert_eq!(room.people[0].position_at(2.0), [3.0, 1.0, 1.0]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
//! Canonical RF tensor — the single normalization target of every hardware
|
||||
//! adapter (ADR-274 §2).
|
||||
//!
|
||||
//! Every modality (WiFi CSI, cellular SRS, FMCW radar range profiles, UWB
|
||||
//! CIR) is normalized into the same `(links × bins × snapshots)` complex
|
||||
//! tensor plus calibration metadata. Downstream code (tokenizer, encoder,
|
||||
//! Gaussian memory) never sees vendor formats.
|
||||
|
||||
use ndarray::Array3;
|
||||
use num_complex::Complex64;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{Result, UnifiedError};
|
||||
|
||||
/// Canonical number of frequency/delay bins after adapter resampling.
|
||||
///
|
||||
/// 56 matches the usable-subcarrier count of 20 MHz 802.11n CSI after guard
|
||||
/// removal (and the 114→56 interpolation already used by
|
||||
/// `wifi-densepose-train::subcarrier`), so the most common source needs no
|
||||
/// resampling at all.
|
||||
pub const CANONICAL_BINS: usize = 56;
|
||||
|
||||
/// Canonical number of temporal snapshots per tensor window.
|
||||
pub const CANONICAL_SNAPSHOTS: usize = 8;
|
||||
|
||||
/// RF sensing modality of a capture or tensor.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum RfModality {
|
||||
/// 802.11 Channel State Information (per-subcarrier frequency response).
|
||||
WifiCsi,
|
||||
/// 5G NR uplink Sounding Reference Signal frequency response (O-RAN ISAC path).
|
||||
CellularSrs,
|
||||
/// FMCW radar range profile (post range-FFT complex bins).
|
||||
FmcwRadar,
|
||||
/// Ultra-wideband channel impulse response taps.
|
||||
UwbCir,
|
||||
/// Bluetooth Channel Sounding tones.
|
||||
BleCs,
|
||||
/// Output of the ADR-276 synthetic world generator (honest labeling:
|
||||
/// tensors of this modality must never be reported as measured).
|
||||
Synthetic,
|
||||
}
|
||||
|
||||
/// Transmitter/receiver placement for one link, metres, room frame.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub struct LinkGeometry {
|
||||
/// Transmit antenna position `[x, y, z]` in metres.
|
||||
pub tx_pos: [f64; 3],
|
||||
/// Receive antenna position `[x, y, z]` in metres.
|
||||
pub rx_pos: [f64; 3],
|
||||
}
|
||||
|
||||
impl LinkGeometry {
|
||||
/// Euclidean TX→RX distance in metres.
|
||||
#[must_use]
|
||||
pub fn distance_m(&self) -> f64 {
|
||||
let d: f64 = (0..3).map(|i| (self.tx_pos[i] - self.rx_pos[i]).powi(2)).sum();
|
||||
d.sqrt()
|
||||
}
|
||||
}
|
||||
|
||||
/// Calibration contract carried alongside every canonical tensor (ADR-274 §2.2).
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct CalibrationMeta {
|
||||
/// Oscillator quality in parts-per-million drift (lower is better).
|
||||
pub clock_ppm: f64,
|
||||
/// Whether per-link phase offsets have been calibrated out upstream.
|
||||
pub phase_calibrated: bool,
|
||||
/// Gain offset (dB) applied during normalization, for provenance.
|
||||
pub gain_offset_db: f64,
|
||||
/// Identifier of the empty-room baseline applied, if any (ADR-135).
|
||||
pub baseline_id: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for CalibrationMeta {
|
||||
fn default() -> Self {
|
||||
Self { clock_ppm: 20.0, phase_calibrated: false, gain_offset_db: 0.0, baseline_id: None }
|
||||
}
|
||||
}
|
||||
|
||||
/// The canonical complex RF tensor: `(links, bins, snapshots)` plus the
|
||||
/// metadata every downstream consumer needs (freshness, geometry, clock
|
||||
/// quality, uncertainty, provenance).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RfTensor {
|
||||
/// Source modality.
|
||||
pub modality: RfModality,
|
||||
/// Carrier centre frequency in Hz.
|
||||
pub center_freq_hz: f64,
|
||||
/// Occupied bandwidth in Hz (span of the bin axis).
|
||||
pub bandwidth_hz: f64,
|
||||
/// Complex samples, shape `(links, bins, snapshots)`.
|
||||
pub data: Array3<Complex64>,
|
||||
/// Per-link antenna geometry; `links.len() == data.dim().0`.
|
||||
pub links: Vec<LinkGeometry>,
|
||||
/// Age of the *oldest* snapshot in seconds at tensor construction time
|
||||
/// (the freshness signal the encoder fuses multiplicatively, ADR-274 §3).
|
||||
pub sample_age_s: f64,
|
||||
/// Capture timestamp, nanoseconds since epoch.
|
||||
pub timestamp_ns: u64,
|
||||
/// Source device identifier (chipset/firmware provenance key).
|
||||
pub device_id: String,
|
||||
/// Clock quality in `[0, 1]` (1 = disciplined reference, 0 = free-running).
|
||||
pub clock_quality: f64,
|
||||
/// Front-end uncertainty proxy in `[0, 1]` (0 = clean, 1 = at noise floor).
|
||||
pub uncertainty: f64,
|
||||
/// Calibration contract.
|
||||
pub calibration: CalibrationMeta,
|
||||
}
|
||||
|
||||
impl RfTensor {
|
||||
/// Validated constructor — the only way to build an `RfTensor`.
|
||||
///
|
||||
/// Boundary rules enforced here (so downstream modules may assume them):
|
||||
/// non-empty dims, geometry length matches the link axis, all samples
|
||||
/// finite, frequencies positive, `clock_quality`/`uncertainty` ∈ [0, 1],
|
||||
/// `sample_age_s` finite and non-negative.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
modality: RfModality,
|
||||
center_freq_hz: f64,
|
||||
bandwidth_hz: f64,
|
||||
data: Array3<Complex64>,
|
||||
links: Vec<LinkGeometry>,
|
||||
sample_age_s: f64,
|
||||
timestamp_ns: u64,
|
||||
device_id: String,
|
||||
clock_quality: f64,
|
||||
uncertainty: f64,
|
||||
calibration: CalibrationMeta,
|
||||
) -> Result<Self> {
|
||||
let (n_links, n_bins, n_snaps) = data.dim();
|
||||
if n_links == 0 || n_bins == 0 || n_snaps == 0 {
|
||||
return Err(UnifiedError::ShapeMismatch(format!(
|
||||
"empty tensor axis: ({n_links}, {n_bins}, {n_snaps})"
|
||||
)));
|
||||
}
|
||||
if links.len() != n_links {
|
||||
return Err(UnifiedError::ShapeMismatch(format!(
|
||||
"geometry describes {} links, data has {n_links}",
|
||||
links.len()
|
||||
)));
|
||||
}
|
||||
if !(center_freq_hz.is_finite() && center_freq_hz > 0.0) {
|
||||
return Err(UnifiedError::InvalidInput(format!(
|
||||
"center_freq_hz must be finite and positive, got {center_freq_hz}"
|
||||
)));
|
||||
}
|
||||
if !(bandwidth_hz.is_finite() && bandwidth_hz > 0.0) {
|
||||
return Err(UnifiedError::InvalidInput(format!(
|
||||
"bandwidth_hz must be finite and positive, got {bandwidth_hz}"
|
||||
)));
|
||||
}
|
||||
if !(0.0..=1.0).contains(&clock_quality) {
|
||||
return Err(UnifiedError::InvalidInput(format!(
|
||||
"clock_quality must be in [0,1], got {clock_quality}"
|
||||
)));
|
||||
}
|
||||
if !(0.0..=1.0).contains(&uncertainty) {
|
||||
return Err(UnifiedError::InvalidInput(format!(
|
||||
"uncertainty must be in [0,1], got {uncertainty}"
|
||||
)));
|
||||
}
|
||||
if !(sample_age_s.is_finite() && sample_age_s >= 0.0) {
|
||||
return Err(UnifiedError::InvalidInput(format!(
|
||||
"sample_age_s must be finite and >= 0, got {sample_age_s}"
|
||||
)));
|
||||
}
|
||||
for link in &links {
|
||||
for c in link.tx_pos.iter().chain(link.rx_pos.iter()) {
|
||||
if !c.is_finite() {
|
||||
return Err(UnifiedError::InvalidInput(
|
||||
"non-finite antenna coordinate".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
if data.iter().any(|z| !z.re.is_finite() || !z.im.is_finite()) {
|
||||
return Err(UnifiedError::InvalidInput("non-finite complex sample".into()));
|
||||
}
|
||||
Ok(Self {
|
||||
modality,
|
||||
center_freq_hz,
|
||||
bandwidth_hz,
|
||||
data,
|
||||
links,
|
||||
sample_age_s,
|
||||
timestamp_ns,
|
||||
device_id,
|
||||
clock_quality,
|
||||
uncertainty,
|
||||
calibration,
|
||||
})
|
||||
}
|
||||
|
||||
/// `(links, bins, snapshots)`.
|
||||
#[must_use]
|
||||
pub fn dims(&self) -> (usize, usize, usize) {
|
||||
self.data.dim()
|
||||
}
|
||||
|
||||
/// Carrier wavelength in metres.
|
||||
#[must_use]
|
||||
pub fn wavelength_m(&self) -> f64 {
|
||||
299_792_458.0 / self.center_freq_hz
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn link() -> LinkGeometry {
|
||||
LinkGeometry { tx_pos: [0.0, 0.0, 1.0], rx_pos: [3.0, 4.0, 1.0] }
|
||||
}
|
||||
|
||||
fn valid_data(links: usize) -> Array3<Complex64> {
|
||||
Array3::from_elem((links, CANONICAL_BINS, CANONICAL_SNAPSHOTS), Complex64::new(1.0, 0.0))
|
||||
}
|
||||
|
||||
fn build(data: Array3<Complex64>, links: Vec<LinkGeometry>) -> Result<RfTensor> {
|
||||
RfTensor::new(
|
||||
RfModality::WifiCsi,
|
||||
2.437e9,
|
||||
20e6,
|
||||
data,
|
||||
links,
|
||||
0.05,
|
||||
1_700_000_000_000_000_000,
|
||||
"test-device".into(),
|
||||
0.8,
|
||||
0.1,
|
||||
CalibrationMeta::default(),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_valid_tensor_and_reports_dims() {
|
||||
let t = build(valid_data(2), vec![link(), link()]).expect("valid tensor");
|
||||
assert_eq!(t.dims(), (2, CANONICAL_BINS, CANONICAL_SNAPSHOTS));
|
||||
// 2.437 GHz → λ ≈ 0.1230 m.
|
||||
assert!((t.wavelength_m() - 0.123_017).abs() < 1e-4);
|
||||
assert!((t.links[0].distance_m() - 5.0).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_geometry_link_mismatch() {
|
||||
assert!(matches!(
|
||||
build(valid_data(2), vec![link()]),
|
||||
Err(UnifiedError::ShapeMismatch(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_non_finite_sample() {
|
||||
let mut data = valid_data(1);
|
||||
data[[0, 3, 2]] = Complex64::new(f64::NAN, 0.0);
|
||||
assert!(matches!(build(data, vec![link()]), Err(UnifiedError::InvalidInput(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_out_of_range_scalars() {
|
||||
let t = RfTensor::new(
|
||||
RfModality::WifiCsi,
|
||||
2.437e9,
|
||||
20e6,
|
||||
valid_data(1),
|
||||
vec![link()],
|
||||
-1.0, // negative age
|
||||
0,
|
||||
"d".into(),
|
||||
0.8,
|
||||
0.1,
|
||||
CalibrationMeta::default(),
|
||||
);
|
||||
assert!(matches!(t, Err(UnifiedError::InvalidInput(_))));
|
||||
|
||||
let t = RfTensor::new(
|
||||
RfModality::WifiCsi,
|
||||
2.437e9,
|
||||
20e6,
|
||||
valid_data(1),
|
||||
vec![link()],
|
||||
0.0,
|
||||
0,
|
||||
"d".into(),
|
||||
1.5, // clock quality out of range
|
||||
0.1,
|
||||
CalibrationMeta::default(),
|
||||
);
|
||||
assert!(matches!(t, Err(UnifiedError::InvalidInput(_))));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
//! RF tokenizer — canonical tensor in, encoder-ready tokens out
|
||||
//! (ADR-274 §3.1).
|
||||
//!
|
||||
//! One token per `(link, subcarrier-group)`; each token carries amplitude,
|
||||
//! delay-spectrum, Doppler-spectrum, phase-dynamics, freshness, geometry,
|
||||
//! clock-quality, and uncertainty features. The exact 24-dimensional layout
|
||||
//! is documented on [`RfToken`]; the encoder treats it as an opaque vector,
|
||||
//! so new feature dims only require bumping [`D_IN`].
|
||||
|
||||
use num_complex::Complex64;
|
||||
|
||||
use crate::math::DftPlan;
|
||||
use crate::tensor::{RfTensor, CANONICAL_SNAPSHOTS};
|
||||
|
||||
/// Subcarrier-group width: 56 bins / 8 = 7 tokens per link.
|
||||
pub const GROUP_BINS: usize = 8;
|
||||
|
||||
/// Token feature dimension.
|
||||
pub const D_IN: usize = 24;
|
||||
|
||||
/// Sinusoidal position-encoding dimension (used only by masked
|
||||
/// reconstruction so the decoder knows *which* token it is predicting).
|
||||
pub const D_POS: usize = 16;
|
||||
|
||||
/// One tokenized `(link, group)` cell.
|
||||
///
|
||||
/// All amplitude-derived features are computed on window-median-normalized,
|
||||
/// CFO-aligned samples (see [`RfTokenizer::tokenize`]), so they are
|
||||
/// invariant to front-end gain and common phase drift.
|
||||
///
|
||||
/// Feature layout (all values finite, roughly unit-scale):
|
||||
///
|
||||
/// | idx | feature |
|
||||
/// |-----|---------|
|
||||
/// | 0–7 | `ln(1+amp)` per bin, averaged over snapshots |
|
||||
/// | 8–11 | delay-domain DFT magnitudes (bins 0–3) of the snapshot-mean group |
|
||||
/// | 12–15 | `ln(1+100·mag)` Doppler DFT magnitudes (bins 1–4, DC skipped) across snapshots |
|
||||
/// | 16 | `ln(1+20·std)` temporal amplitude deviation (motion energy) |
|
||||
/// | 17 | mean inter-snapshot phase velocity (rad/snapshot) |
|
||||
/// | 18 | sample age (seconds, clipped to 10) |
|
||||
/// | 19 | link distance / 10 m |
|
||||
/// | 20 | link midpoint height / 3 m |
|
||||
/// | 21 | link azimuth / π |
|
||||
/// | 22 | clock quality |
|
||||
/// | 23 | uncertainty |
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RfToken {
|
||||
/// Feature vector, layout above.
|
||||
pub features: [f64; D_IN],
|
||||
/// Link index within the source tensor.
|
||||
pub link: usize,
|
||||
/// Subcarrier-group index within the link.
|
||||
pub group: usize,
|
||||
}
|
||||
|
||||
/// A tokenized tensor window plus the window-level context the encoder's
|
||||
/// age/geometry paths consume.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TokenizedWindow {
|
||||
/// Tokens, link-major then group order.
|
||||
pub tokens: Vec<RfToken>,
|
||||
/// Window age in seconds (drives the multiplicative freshness gate).
|
||||
pub age_s: f64,
|
||||
/// Window-level geometry summary: mean TX xyz then mean RX xyz, in
|
||||
/// decametres (÷10) to keep unit scale.
|
||||
pub geometry: [f64; 6],
|
||||
}
|
||||
|
||||
/// Tokenizer with precomputed DFT plans (delay + Doppler transforms are the
|
||||
/// hot path; see `benches/unified_bench.rs` for the measured speedup over
|
||||
/// planless DFTs).
|
||||
pub struct RfTokenizer {
|
||||
delay_plan: DftPlan,
|
||||
doppler_plan: DftPlan,
|
||||
}
|
||||
|
||||
impl Default for RfTokenizer {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl RfTokenizer {
|
||||
/// Builds the tokenizer (allocates the two DFT twiddle tables once).
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
delay_plan: DftPlan::new(GROUP_BINS, 4),
|
||||
doppler_plan: DftPlan::new(CANONICAL_SNAPSHOTS, 5),
|
||||
}
|
||||
}
|
||||
|
||||
/// Tokenizes a canonical tensor. Panics never: the tensor's validated
|
||||
/// invariants (canonical dims after adapter normalization) are assumed;
|
||||
/// non-canonical bin counts simply produce fewer/more groups.
|
||||
///
|
||||
/// Two hardware-invariance steps happen before feature extraction
|
||||
/// (ADR-274 §3.1 — without them, chipset gain and CFO drift dominate
|
||||
/// every downstream feature):
|
||||
///
|
||||
/// 1. **Scale**: all samples are divided by the window's median
|
||||
/// amplitude, so front-end gain and absolute path loss cancel.
|
||||
/// 2. **CFO alignment**: per link, each snapshot is de-rotated by the
|
||||
/// common phase between it and snapshot 0
|
||||
/// (`arg Σ_b H[b,s]·H̄[b,0]`) — carrier-frequency-offset drift is a
|
||||
/// *common* rotation and cancels, while a moving scatterer's
|
||||
/// frequency-selective perturbation survives.
|
||||
#[must_use]
|
||||
pub fn tokenize(&self, tensor: &RfTensor) -> TokenizedWindow {
|
||||
let (n_links, n_bins, n_snaps) = tensor.dims();
|
||||
let n_groups = n_bins / GROUP_BINS;
|
||||
let mut tokens = Vec::with_capacity(n_links * n_groups);
|
||||
|
||||
// Window-level robust amplitude scale.
|
||||
let amps: Vec<f64> = tensor.data.iter().map(|z| z.norm()).collect();
|
||||
let scale = crate::math::median(&s).max(1e-12);
|
||||
|
||||
// Per-(link, snapshot) CFO-alignment rotations against snapshot 0.
|
||||
let mut align = vec![vec![Complex64::new(1.0, 0.0); n_snaps]; n_links];
|
||||
for l in 0..n_links {
|
||||
for s in 1..n_snaps {
|
||||
let mut acc = Complex64::new(0.0, 0.0);
|
||||
for b in 0..n_bins {
|
||||
acc += tensor.data[[l, b, s]] * tensor.data[[l, b, 0]].conj();
|
||||
}
|
||||
if acc.norm() > 1e-18 {
|
||||
align[l][s] = (acc / acc.norm()).conj();
|
||||
}
|
||||
}
|
||||
}
|
||||
let sample = |l: usize, b: usize, s: usize| tensor.data[[l, b, s]] * align[l][s] / scale;
|
||||
|
||||
for l in 0..n_links {
|
||||
let geo = &tensor.links[l];
|
||||
let dx = geo.rx_pos[0] - geo.tx_pos[0];
|
||||
let dy = geo.rx_pos[1] - geo.tx_pos[1];
|
||||
let dist = geo.distance_m().max(1e-6);
|
||||
let mid_z = (geo.tx_pos[2] + geo.rx_pos[2]) / 2.0;
|
||||
let azimuth = dy.atan2(dx);
|
||||
|
||||
for g in 0..n_groups {
|
||||
let b0 = g * GROUP_BINS;
|
||||
let mut f = [0.0f64; D_IN];
|
||||
|
||||
// Snapshot-mean complex value per bin (delay features) and
|
||||
// group-mean complex value per snapshot (Doppler features).
|
||||
let mut bin_means = [Complex64::new(0.0, 0.0); GROUP_BINS];
|
||||
let mut snap_means = vec![Complex64::new(0.0, 0.0); n_snaps];
|
||||
let mut amp_sum = [0.0f64; GROUP_BINS];
|
||||
let mut amp_all = Vec::with_capacity(GROUP_BINS * n_snaps);
|
||||
for (bi, bin) in (b0..b0 + GROUP_BINS).enumerate() {
|
||||
for (s, sm) in snap_means.iter_mut().enumerate() {
|
||||
let z = sample(l, bin, s);
|
||||
bin_means[bi] += z;
|
||||
*sm += z;
|
||||
amp_sum[bi] += z.norm();
|
||||
amp_all.push(z.norm());
|
||||
}
|
||||
}
|
||||
for b in &mut bin_means {
|
||||
*b /= n_snaps as f64;
|
||||
}
|
||||
for s in &mut snap_means {
|
||||
*s /= GROUP_BINS as f64;
|
||||
}
|
||||
|
||||
// 0–7: log-amplitudes.
|
||||
for bi in 0..GROUP_BINS {
|
||||
f[bi] = (1.0 + amp_sum[bi] / n_snaps as f64).ln();
|
||||
}
|
||||
// 8–11: delay spectrum of the group.
|
||||
for (k, m) in self.delay_plan.magnitudes(&bin_means).iter().enumerate() {
|
||||
f[8 + k] = *m;
|
||||
}
|
||||
// 12–15: Doppler spectrum across snapshots (skip DC bin 0),
|
||||
// log-compressed to O(1): motion magnitudes live at 1e-2 of
|
||||
// the static field, and leaving them 20× smaller than the
|
||||
// amplitude dims stalls every downstream linear adapter
|
||||
// (feature design, not adapter parameters).
|
||||
let dop_scale = |m: f64| (1.0 + 100.0 * m).ln();
|
||||
if n_snaps == CANONICAL_SNAPSHOTS {
|
||||
let dop = self.doppler_plan.magnitudes(&snap_means);
|
||||
for k in 0..4 {
|
||||
f[12 + k] = dop_scale(dop[1 + k]);
|
||||
}
|
||||
} else {
|
||||
for (k, m) in
|
||||
crate::math::dft_magnitudes(&snap_means, 5).iter().skip(1).enumerate()
|
||||
{
|
||||
f[12 + k] = dop_scale(*m);
|
||||
}
|
||||
}
|
||||
// 16: temporal amplitude std (motion energy), same treatment.
|
||||
let mean_amp = amp_all.iter().sum::<f64>() / amp_all.len() as f64;
|
||||
let var = amp_all.iter().map(|a| (a - mean_amp).powi(2)).sum::<f64>()
|
||||
/ amp_all.len() as f64;
|
||||
f[16] = (1.0 + 20.0 * var.sqrt()).ln();
|
||||
// 17: mean inter-snapshot phase velocity of the group mean.
|
||||
let mut dphi = 0.0;
|
||||
for s in 1..n_snaps {
|
||||
dphi += (snap_means[s] * snap_means[s - 1].conj()).arg();
|
||||
}
|
||||
f[17] = dphi / (n_snaps.max(2) - 1) as f64;
|
||||
// 18–23: freshness, geometry, clock, uncertainty.
|
||||
f[18] = tensor.sample_age_s.min(10.0);
|
||||
f[19] = dist / 10.0;
|
||||
f[20] = mid_z / 3.0;
|
||||
f[21] = azimuth / std::f64::consts::PI;
|
||||
f[22] = tensor.clock_quality;
|
||||
f[23] = tensor.uncertainty;
|
||||
|
||||
tokens.push(RfToken { features: f, link: l, group: g });
|
||||
}
|
||||
}
|
||||
|
||||
let mut geometry = [0.0f64; 6];
|
||||
for geo in &tensor.links {
|
||||
for i in 0..3 {
|
||||
geometry[i] += geo.tx_pos[i];
|
||||
geometry[3 + i] += geo.rx_pos[i];
|
||||
}
|
||||
}
|
||||
for v in &mut geometry {
|
||||
*v /= 10.0 * n_links as f64;
|
||||
}
|
||||
|
||||
TokenizedWindow { tokens, age_s: tensor.sample_age_s, geometry }
|
||||
}
|
||||
}
|
||||
|
||||
/// Fixed sinusoidal position encoding for token index `idx` (masked
|
||||
/// reconstruction target addressing; not a learned parameter).
|
||||
#[must_use]
|
||||
pub fn position_encoding(idx: usize) -> [f64; D_POS] {
|
||||
let mut p = [0.0f64; D_POS];
|
||||
for k in 0..D_POS / 2 {
|
||||
let freq = 1.0 / 10_000f64.powf(2.0 * k as f64 / D_POS as f64);
|
||||
p[2 * k] = (idx as f64 * freq).sin();
|
||||
p[2 * k + 1] = (idx as f64 * freq).cos();
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::tensor::{CalibrationMeta, LinkGeometry, RfModality, CANONICAL_BINS};
|
||||
use ndarray::Array3;
|
||||
|
||||
fn tensor_with(motion: bool) -> RfTensor {
|
||||
let data = Array3::from_shape_fn((2, CANONICAL_BINS, CANONICAL_SNAPSHOTS), |(l, b, s)| {
|
||||
let base = 1.0 + 0.1 * (b as f64 / 10.0).sin() + 0.05 * l as f64;
|
||||
let wobble = if motion {
|
||||
// Snapshot-varying, frequency-selective perturbation — a
|
||||
// moving scatterer (bin-dependent so CFO alignment, which
|
||||
// only removes *common* rotations, must not cancel it).
|
||||
0.3 * (2.0 * std::f64::consts::PI * 2.0 * s as f64 / 8.0).sin()
|
||||
* (1.0 + b as f64 / 56.0)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
Complex64::new(0.0, wobble).exp() * (base + wobble.abs())
|
||||
});
|
||||
RfTensor::new(
|
||||
RfModality::WifiCsi,
|
||||
2.437e9,
|
||||
20e6,
|
||||
data,
|
||||
vec![
|
||||
LinkGeometry { tx_pos: [0.0, 0.0, 2.0], rx_pos: [4.0, 0.0, 2.0] },
|
||||
LinkGeometry { tx_pos: [0.0, 0.0, 2.0], rx_pos: [4.0, 0.3, 2.0] },
|
||||
],
|
||||
0.05,
|
||||
0,
|
||||
"tok-test".into(),
|
||||
0.8,
|
||||
0.1,
|
||||
CalibrationMeta::default(),
|
||||
)
|
||||
.expect("valid tensor")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn produces_expected_token_grid() {
|
||||
let w = RfTokenizer::new().tokenize(&tensor_with(false));
|
||||
assert_eq!(w.tokens.len(), 2 * (CANONICAL_BINS / GROUP_BINS));
|
||||
for t in &w.tokens {
|
||||
assert!(t.features.iter().all(|v| v.is_finite()), "non-finite feature");
|
||||
}
|
||||
// Context passthrough.
|
||||
assert!((w.age_s - 0.05).abs() < 1e-12);
|
||||
assert!(w.tokens[0].features[22] > 0.79 && w.tokens[0].features[22] < 0.81);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn motion_raises_doppler_and_variance_features() {
|
||||
let tok = RfTokenizer::new();
|
||||
let still = tok.tokenize(&tensor_with(false));
|
||||
let moving = tok.tokenize(&tensor_with(true));
|
||||
let dop = |w: &TokenizedWindow| {
|
||||
w.tokens.iter().map(|t| t.features[12..16].iter().sum::<f64>()).sum::<f64>()
|
||||
};
|
||||
let var = |w: &TokenizedWindow| w.tokens.iter().map(|t| t.features[16]).sum::<f64>();
|
||||
assert!(
|
||||
dop(&moving) > 10.0 * dop(&still) + 1e-9,
|
||||
"Doppler features must respond to motion: moving={} still={}",
|
||||
dop(&moving),
|
||||
dop(&still)
|
||||
);
|
||||
assert!(var(&moving) > var(&still));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn position_encoding_is_unique_and_bounded() {
|
||||
let a = position_encoding(0);
|
||||
let b = position_encoding(7);
|
||||
assert_ne!(a, b);
|
||||
for v in a.iter().chain(b.iter()) {
|
||||
assert!(v.abs() <= 1.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
//! End-to-end acceptance pipeline for the unified RF spatial world model
|
||||
//! (ADR-273 §6), on SYNTHETIC data (ADR-276 generator — every number below
|
||||
//! is a synthetic-world result until validated on measured captures).
|
||||
//!
|
||||
//! The pipeline exercised is the deployment pipeline, not a shortcut:
|
||||
//! physics-simulated CSI → canonical tensor → tokenizer → self-supervised
|
||||
//! masked-reconstruction pretraining (labels never touch the encoder) →
|
||||
//! frozen encoder → ≤1 %-budget presence adapter → strict anti-leakage
|
||||
//! evaluation → policy-wrapped export.
|
||||
//!
|
||||
//! Acceptance gates checked here (synthetic analogues of ADR-273 §6):
|
||||
//! 1. presence F1 ≥ 0.90 on completely held-out rooms;
|
||||
//! 2. the same gate under a held-out *chipset* split;
|
||||
//! 3. known→unknown relative degradation < 20 %;
|
||||
//! 4. adapters < 1 % of backbone parameters;
|
||||
//! 5. p95 tokenize+encode latency < 50 ms;
|
||||
//! 6. every exported output carries uncertainty, provenance, model version,
|
||||
//! and purpose, and leaves only through the policy trust boundary.
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
use ruview_unified::encoder::{EncoderConfig, RfEncoder};
|
||||
use ruview_unified::eval::{
|
||||
expected_calibration_error, f1_score, relative_degradation, selective_metrics, PartitionDim,
|
||||
StrictSplit,
|
||||
};
|
||||
use ruview_unified::gaussian::primitive::Provenance;
|
||||
use ruview_unified::heads::{within_adapter_budget, PresenceHead};
|
||||
use ruview_unified::policy::{
|
||||
BoundedEvent, EventValue, PolicyEngine, PrivacyZone, SensingPurpose, TrustBoundary,
|
||||
};
|
||||
use ruview_unified::pretrain::{pretrain, PretrainConfig};
|
||||
use ruview_unified::synth::{LabeledWindow, SynthConfig, SynthGenerator};
|
||||
use ruview_unified::tokenizer::{RfTokenizer, TokenizedWindow};
|
||||
|
||||
/// Shared fixture: corpus, tokenized windows, encoder config.
|
||||
struct Fixture {
|
||||
corpus: Vec<LabeledWindow>,
|
||||
windows: Vec<TokenizedWindow>,
|
||||
cfg: EncoderConfig,
|
||||
}
|
||||
|
||||
fn build_fixture() -> Fixture {
|
||||
let corpus = SynthGenerator::new(SynthConfig {
|
||||
seed: 273_273,
|
||||
n_rooms: 8,
|
||||
windows_per_room: 20,
|
||||
links: 3,
|
||||
snapshot_dt_s: 0.05,
|
||||
})
|
||||
.generate();
|
||||
let tokenizer = RfTokenizer::new();
|
||||
let windows: Vec<TokenizedWindow> =
|
||||
corpus.iter().map(|w| tokenizer.tokenize(&w.tensor)).collect();
|
||||
// d_model = 64 keeps the debug-profile test fast; the ≤1 % adapter
|
||||
// budget is checked against THIS backbone, not a larger one.
|
||||
let cfg = EncoderConfig { d_model: 64, ..EncoderConfig::default() };
|
||||
Fixture { corpus, windows, cfg }
|
||||
}
|
||||
|
||||
/// Pretrains on the training side only, trains a presence head on frozen
|
||||
/// representations, and returns (known-condition F1, held-out F1, held-out
|
||||
/// probabilities and labels) for a given strict split.
|
||||
fn run_split(fx: &Fixture, split: &StrictSplit) -> (f64, f64, Vec<f64>, Vec<bool>) {
|
||||
// Self-supervised pretraining: training windows only, no labels.
|
||||
let train_windows: Vec<TokenizedWindow> =
|
||||
split.train.iter().map(|&i| fx.windows[i].clone()).collect();
|
||||
let mut encoder = RfEncoder::new(fx.cfg, 7);
|
||||
let report = pretrain(
|
||||
&mut encoder,
|
||||
&train_windows,
|
||||
&PretrainConfig { mask_fraction: 0.25, lr: 0.03, epochs: 8, seed: 0x5EED },
|
||||
);
|
||||
assert!(
|
||||
report.final_loss < report.initial_loss,
|
||||
"pretraining must reduce masked loss: {report:?}"
|
||||
);
|
||||
|
||||
// Frozen encoder → environment-invariant content representation for
|
||||
// every window (presence is a cross-room task; the geometry-conditioned
|
||||
// `encode()` view is for localization-style heads).
|
||||
let zs: Vec<Vec<f64>> = fx.windows.iter().map(|w| encoder.encode_content(w)).collect();
|
||||
|
||||
// ≤1 % adapter on the frozen backbone.
|
||||
let mut head = PresenceHead::new(encoder.content_dim());
|
||||
assert!(
|
||||
within_adapter_budget(encoder.param_count(), head.param_count()),
|
||||
"presence adapter {} params vs backbone {}",
|
||||
head.param_count(),
|
||||
encoder.param_count()
|
||||
);
|
||||
let train_z: Vec<Vec<f64>> = split.train.iter().map(|&i| zs[i].clone()).collect();
|
||||
let train_y: Vec<bool> = split.train.iter().map(|&i| fx.corpus[i].presence).collect();
|
||||
head.train(&train_z, &train_y, 1.0, 800);
|
||||
|
||||
let eval = |idx: &[usize]| -> (Vec<f64>, Vec<bool>) {
|
||||
let probs: Vec<f64> = idx.iter().map(|&i| head.predict_prob(&zs[i])).collect();
|
||||
let labels: Vec<bool> = idx.iter().map(|&i| fx.corpus[i].presence).collect();
|
||||
(probs, labels)
|
||||
};
|
||||
let (train_p, train_l) = eval(&split.train);
|
||||
let (test_p, test_l) = eval(&split.test);
|
||||
let known_f1 = f1_score(&train_p.iter().map(|p| *p > 0.5).collect::<Vec<_>>(), &train_l);
|
||||
let held_f1 = f1_score(&test_p.iter().map(|p| *p > 0.5).collect::<Vec<_>>(), &test_l);
|
||||
(known_f1, held_f1, test_p, test_l)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acceptance_pipeline_on_synthetic_worlds() {
|
||||
let fx = build_fixture();
|
||||
let keys: Vec<_> = fx.corpus.iter().map(|w| w.key.clone()).collect();
|
||||
|
||||
// ---- Gate 1: held-out ROOMS (rooms 6 and 7 never seen in any stage).
|
||||
let room_split = StrictSplit::holdout(&keys, PartitionDim::Room, &["room-6", "room-7"]);
|
||||
assert!(room_split.verify(&keys), "room split must be leak-free");
|
||||
assert!(room_split.test.len() >= 30, "held-out set must be substantial");
|
||||
let (known_f1, room_f1, test_p, test_l) = run_split(&fx, &room_split);
|
||||
println!("SYNTHETIC room-holdout: known F1 {known_f1:.4}, held-out F1 {room_f1:.4}");
|
||||
assert!(
|
||||
room_f1 >= 0.90,
|
||||
"ADR-273 gate: presence F1 on unseen rooms must be >= 0.90, got {room_f1:.4} (SYNTHETIC)"
|
||||
);
|
||||
|
||||
// ---- Gate 3: known → unknown degradation < 20 %.
|
||||
let degradation = relative_degradation(known_f1, room_f1);
|
||||
println!("SYNTHETIC degradation known→unknown: {degradation:.4}");
|
||||
assert!(
|
||||
degradation < 0.20,
|
||||
"cross-environment degradation must stay under 20 %, got {degradation:.4}"
|
||||
);
|
||||
|
||||
// ---- Calibration + abstention on the held-out side: raising the
|
||||
// confidence threshold must never raise selective risk, and full-
|
||||
// coverage risk is bounded by the F1 gate above.
|
||||
let ece = expected_calibration_error(&test_p, &test_l, 10);
|
||||
println!("SYNTHETIC held-out ECE: {ece:.4}");
|
||||
let full = selective_metrics(&test_p, &test_l, 0.5);
|
||||
let strict = selective_metrics(&test_p, &test_l, 0.9);
|
||||
println!(
|
||||
"SYNTHETIC abstention: coverage {:.2}→{:.2}, risk {:.4}→{:.4}",
|
||||
full.coverage, strict.coverage, full.selective_risk, strict.selective_risk
|
||||
);
|
||||
assert!(strict.selective_risk <= full.selective_risk + 1e-12);
|
||||
|
||||
// ---- Gate 2: held-out CHIPSET (every chip-2 room excluded from
|
||||
// training; per-room gain/phase/CFO/noise profiles are random, so the
|
||||
// held-out hardware profile is genuinely unseen).
|
||||
let chip_split = StrictSplit::holdout(&keys, PartitionDim::Chipset, &["chip-2"]);
|
||||
assert!(chip_split.verify(&keys), "chipset split must be leak-free");
|
||||
let (chip_known, chip_f1, _, _) = run_split(&fx, &chip_split);
|
||||
println!("SYNTHETIC chipset-holdout: known F1 {chip_known:.4}, held-out F1 {chip_f1:.4}");
|
||||
assert!(
|
||||
chip_f1 >= 0.90,
|
||||
"presence F1 on unseen chipset must be >= 0.90, got {chip_f1:.4} (SYNTHETIC)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edge_latency_budget_tokenize_plus_encode() {
|
||||
let fx = build_fixture();
|
||||
let tokenizer = RfTokenizer::new();
|
||||
let encoder = RfEncoder::new(fx.cfg, 7);
|
||||
|
||||
// Warm up, then measure 100 full tokenize+encode passes.
|
||||
let mut latencies_us: Vec<u128> = Vec::with_capacity(100);
|
||||
for i in 0..110 {
|
||||
let idx = i % fx.corpus.len();
|
||||
let start = Instant::now();
|
||||
let w = tokenizer.tokenize(&fx.corpus[idx].tensor);
|
||||
let z = encoder.encode(&w);
|
||||
std::hint::black_box(z);
|
||||
if i >= 10 {
|
||||
latencies_us.push(start.elapsed().as_micros());
|
||||
}
|
||||
}
|
||||
latencies_us.sort_unstable();
|
||||
let p50 = latencies_us[latencies_us.len() / 2];
|
||||
let p95 = latencies_us[latencies_us.len() * 95 / 100];
|
||||
println!("edge latency tokenize+encode: p50 {p50} µs, p95 {p95} µs (debug profile)");
|
||||
// ADR-273 gate is 50 ms at p95 on edge hardware; even the unoptimized
|
||||
// debug profile must clear it with margin on a dev machine.
|
||||
assert!(p95 < 50_000, "p95 latency {p95} µs exceeds the 50 ms budget");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outputs_leave_only_through_the_policy_boundary_fully_attributed() {
|
||||
let fx = build_fixture();
|
||||
let tokenizer = RfTokenizer::new();
|
||||
let encoder = RfEncoder::new(fx.cfg, 7);
|
||||
let mut head = PresenceHead::new(encoder.content_dim());
|
||||
// Small supervised fit so probabilities are meaningful.
|
||||
let zs: Vec<Vec<f64>> = fx
|
||||
.corpus
|
||||
.iter()
|
||||
.take(60)
|
||||
.map(|w| encoder.encode_content(&tokenizer.tokenize(&w.tensor)))
|
||||
.collect();
|
||||
let ys: Vec<bool> = fx.corpus.iter().take(60).map(|w| w.presence).collect();
|
||||
head.train(&zs, &ys, 0.5, 100);
|
||||
|
||||
let mut engine = PolicyEngine::new();
|
||||
engine.upsert_zone(PrivacyZone {
|
||||
id: "room-0".into(),
|
||||
allowed_purposes: [SensingPurpose::Presence].into_iter().collect(),
|
||||
retention_s: 600,
|
||||
identity_explicitly_enabled: false,
|
||||
});
|
||||
let boundary = TrustBoundary::new(engine);
|
||||
|
||||
let p = head.predict_prob(&zs[0]);
|
||||
let event = BoundedEvent::new(
|
||||
SensingPurpose::Presence,
|
||||
EventValue::Presence(p > 0.5),
|
||||
1.0 - (2.0 * p - 1.0).abs(), // confidence → uncertainty
|
||||
Provenance {
|
||||
device_id: fx.corpus[0].tensor.device_id.clone(),
|
||||
model_version: 1,
|
||||
synthetic: true, // honest labeling: synthetic evidence
|
||||
},
|
||||
1,
|
||||
fx.corpus[0].tensor.timestamp_ns,
|
||||
"room-0",
|
||||
)
|
||||
.expect("attributed event");
|
||||
|
||||
// Compliant export passes and the payload is a typed verdict — the
|
||||
// BoundedEvent type has no variant that can carry RF samples, so raw
|
||||
// CSI export is unrepresentable, not merely forbidden.
|
||||
let exported =
|
||||
boundary.export(event.clone(), fx.corpus[0].tensor.timestamp_ns).expect("export ok");
|
||||
assert!(exported.uncertainty >= 0.0 && exported.uncertainty <= 1.0);
|
||||
assert!(exported.provenance.synthetic, "synthetic provenance must survive export");
|
||||
|
||||
// An ungranted purpose in the same zone is denied (fail-closed).
|
||||
let denied = BoundedEvent { purpose: SensingPurpose::IdentityRecognition, ..event };
|
||||
assert!(boundary.export(denied, fx.corpus[0].tensor.timestamp_ns).is_err());
|
||||
}
|
||||
Reference in New Issue
Block a user