From a1a59baf72cc8428c2f5ee0ca2c1d1396a2df84b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Jul 2026 19:03:39 +0000 Subject: [PATCH] feat(ruview-unified): unified RF spatial world model P1 (ADR-273..278) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01Q1R5zhz6sSfXGRXpgBwpFX --- .gitignore | 4 + CHANGELOG.md | 3 + CLAUDE.md | 9 +- README.md | 3 +- .../ADR-273-unified-rf-spatial-world-model.md | 123 +++ ...4-universal-rf-encoder-adapter-registry.md | 95 +++ ...DR-275-rf-aware-gaussian-spatial-memory.md | 78 ++ ...-276-physics-guided-synthetic-rf-worlds.md | 68 ++ .../adr/ADR-277-edge-sensing-control-plane.md | 52 ++ ...adar-inverse-rendering-research-program.md | 46 ++ docs/adr/README.md | 6 + v2/Cargo.lock | 15 + v2/Cargo.toml | 5 + v2/crates/ruview-unified/Cargo.toml | 44 ++ .../ruview-unified/benches/unified_bench.rs | 167 +++++ v2/crates/ruview-unified/src/adapters.rs | 703 ++++++++++++++++++ v2/crates/ruview-unified/src/encoder.rs | 393 ++++++++++ v2/crates/ruview-unified/src/eval.rs | 309 ++++++++ v2/crates/ruview-unified/src/gaussian/gain.rs | 307 ++++++++ .../ruview-unified/src/gaussian/graph.rs | 245 ++++++ v2/crates/ruview-unified/src/gaussian/map.rs | 467 ++++++++++++ v2/crates/ruview-unified/src/gaussian/mod.rs | 30 + .../ruview-unified/src/gaussian/primitive.rs | 324 ++++++++ v2/crates/ruview-unified/src/heads.rs | 392 ++++++++++ v2/crates/ruview-unified/src/lib.rs | 81 ++ v2/crates/ruview-unified/src/math.rs | 276 +++++++ v2/crates/ruview-unified/src/policy.rs | 324 ++++++++ v2/crates/ruview-unified/src/pretrain.rs | 435 +++++++++++ .../ruview-unified/src/synth/generator.rs | 307 ++++++++ v2/crates/ruview-unified/src/synth/mod.rs | 27 + .../ruview-unified/src/synth/raytrace.rs | 273 +++++++ v2/crates/ruview-unified/src/synth/room.rs | 164 ++++ v2/crates/ruview-unified/src/tensor.rs | 293 ++++++++ v2/crates/ruview-unified/src/tokenizer.rs | 322 ++++++++ .../ruview-unified/tests/e2e_acceptance.rs | 237 ++++++ 35 files changed, 6625 insertions(+), 2 deletions(-) create mode 100644 docs/adr/ADR-273-unified-rf-spatial-world-model.md create mode 100644 docs/adr/ADR-274-universal-rf-encoder-adapter-registry.md create mode 100644 docs/adr/ADR-275-rf-aware-gaussian-spatial-memory.md create mode 100644 docs/adr/ADR-276-physics-guided-synthetic-rf-worlds.md create mode 100644 docs/adr/ADR-277-edge-sensing-control-plane.md create mode 100644 docs/adr/ADR-278-radar-inverse-rendering-research-program.md create mode 100644 v2/crates/ruview-unified/Cargo.toml create mode 100644 v2/crates/ruview-unified/benches/unified_bench.rs create mode 100644 v2/crates/ruview-unified/src/adapters.rs create mode 100644 v2/crates/ruview-unified/src/encoder.rs create mode 100644 v2/crates/ruview-unified/src/eval.rs create mode 100644 v2/crates/ruview-unified/src/gaussian/gain.rs create mode 100644 v2/crates/ruview-unified/src/gaussian/graph.rs create mode 100644 v2/crates/ruview-unified/src/gaussian/map.rs create mode 100644 v2/crates/ruview-unified/src/gaussian/mod.rs create mode 100644 v2/crates/ruview-unified/src/gaussian/primitive.rs create mode 100644 v2/crates/ruview-unified/src/heads.rs create mode 100644 v2/crates/ruview-unified/src/lib.rs create mode 100644 v2/crates/ruview-unified/src/math.rs create mode 100644 v2/crates/ruview-unified/src/policy.rs create mode 100644 v2/crates/ruview-unified/src/pretrain.rs create mode 100644 v2/crates/ruview-unified/src/synth/generator.rs create mode 100644 v2/crates/ruview-unified/src/synth/mod.rs create mode 100644 v2/crates/ruview-unified/src/synth/raytrace.rs create mode 100644 v2/crates/ruview-unified/src/synth/room.rs create mode 100644 v2/crates/ruview-unified/src/tensor.rs create mode 100644 v2/crates/ruview-unified/src/tokenizer.rs create mode 100644 v2/crates/ruview-unified/tests/e2e_acceptance.rs diff --git a/.gitignore b/.gitignore index 770a0cd7..5c5aa76c 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 1468ebfb..fe9e1a0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/CLAUDE.md b/CLAUDE.md index 06461a8e..b5da014c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/README.md b/README.md index 95cbfe41..e2518561 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/docs/adr/ADR-273-unified-rf-spatial-world-model.md b/docs/adr/ADR-273-unified-rf-spatial-world-model.md new file mode 100644 index 00000000..90dd96f0 --- /dev/null +++ b/docs/adr/ADR-273-unified-rf-spatial-world-model.md @@ -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) | diff --git a/docs/adr/ADR-274-universal-rf-encoder-adapter-registry.md b/docs/adr/ADR-274-universal-rf-encoder-adapter-registry.md new file mode 100644 index 00000000..048f7ce6 --- /dev/null +++ b/docs/adr/ADR-274-universal-rf-encoder-adapter-registry.md @@ -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`). diff --git a/docs/adr/ADR-275-rf-aware-gaussian-spatial-memory.md b/docs/adr/ADR-275-rf-aware-gaussian-spatial-memory.md new file mode 100644 index 00000000..e3f025b2 --- /dev/null +++ b/docs/adr/ADR-275-rf-aware-gaussian-spatial-memory.md @@ -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. diff --git a/docs/adr/ADR-276-physics-guided-synthetic-rf-worlds.md b/docs/adr/ADR-276-physics-guided-synthetic-rf-worlds.md new file mode 100644 index 00000000..3bbd33eb --- /dev/null +++ b/docs/adr/ADR-276-physics-guided-synthetic-rf-worlds.md @@ -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. diff --git a/docs/adr/ADR-277-edge-sensing-control-plane.md b/docs/adr/ADR-277-edge-sensing-control-plane.md new file mode 100644 index 00000000..8ab74622 --- /dev/null +++ b/docs/adr/ADR-277-edge-sensing-control-plane.md @@ -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. diff --git a/docs/adr/ADR-278-radar-inverse-rendering-research-program.md b/docs/adr/ADR-278-radar-inverse-rendering-research-program.md new file mode 100644 index 00000000..06ba2c52 --- /dev/null +++ b/docs/adr/ADR-278-radar-inverse-rendering-research-program.md @@ -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. diff --git a/docs/adr/README.md b/docs/adr/README.md index d43a4d70..e979428d 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -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 | --- diff --git a/v2/Cargo.lock b/v2/Cargo.lock index e4d23aa5..97b8d821 100644 --- a/v2/Cargo.lock +++ b/v2/Cargo.lock @@ -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" diff --git a/v2/Cargo.toml b/v2/Cargo.toml index 22b701d6..ca831f02 100644 --- a/v2/Cargo.toml +++ b/v2/Cargo.toml @@ -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 diff --git a/v2/crates/ruview-unified/Cargo.toml b/v2/crates/ruview-unified/Cargo.toml new file mode 100644 index 00000000..0b11324c --- /dev/null +++ b/v2/crates/ruview-unified/Cargo.toml @@ -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" diff --git a/v2/crates/ruview-unified/benches/unified_bench.rs b/v2/crates/ruview-unified/benches/unified_bench.rs new file mode 100644 index 00000000..856bf7e1 --- /dev/null +++ b/v2/crates/ruview-unified/benches/unified_bench.rs @@ -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 { + 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 = + (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); diff --git a/v2/crates/ruview-unified/src/adapters.rs b/v2/crates/ruview-unified/src/adapters.rs new file mode 100644 index 00000000..1511ff35 --- /dev/null +++ b/v2/crates/ruview-unified/src/adapters.rs @@ -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, + /// One entry per spatial stream (link). + links: Vec, + /// 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, + /// Per-RX-channel geometry. + links: Vec, + /// 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, + /// Per-link geometry. + links: Vec, + /// 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, + /// SRS comb factor (2 or 4). + comb: usize, + /// Per-link geometry. + links: Vec, + /// 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; +} + +/// 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, // (links, bins, snapshots) at source resolution + links: Vec, + 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 { + 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 = (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 = (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 = 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::() / 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) -> 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 { + 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::() / 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) -> 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 { + 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) -> 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 { + 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) -> 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 { + 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 = (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>, +} + +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) { + 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 { + 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 { + (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 { + (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 = + 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 = + (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()); + } +} diff --git a/v2/crates/ruview-unified/src/encoder.rs b/v2/crates/ruview-unified/src/encoder.rs new file mode 100644 index 00000000..21583b1b --- /dev/null +++ b/v2/crates/ruview-unified/src/encoder.rs @@ -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, + /// Bias, length `rows`. + pub b: Vec, +} + +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 { + 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 { + 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, + /// Embeddings `h_i` for unmasked tokens (parallel to `unmasked`). + pub h: Vec>, + /// Pooled context `c`. + pub c: Vec, + /// Mixing activations `m`, `g`. + pub m: Vec, + /// Second mixing output. + pub g: Vec, + /// Freshness gate `σ(age_w·age + age_b)`. + pub gate: Vec, + /// Fused representation `z`. + pub z: Vec, + /// 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, + /// Age gate bias. + pub age_b: Vec, + /// 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 = + (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 = 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 = + (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 { + 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 { + let cache = self.forward(&window.tokens, &[], WindowContext::from(window)); + let mut out: Vec = + (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 { + 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 { + (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); + } +} diff --git a/v2/crates/ruview-unified/src/eval.rs b/v2/crates/ruview-unified/src/eval.rs new file mode 100644 index 00000000..26b3f12f --- /dev/null +++ b/v2/crates/ruview-unified/src/eval.rs @@ -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, + /// Held-out test sample indices. + pub test: Vec, + /// 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 { + 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); + } +} diff --git a/v2/crates/ruview-unified/src/gaussian/gain.rs b/v2/crates/ruview-unified/src/gaussian/gain.rs new file mode 100644 index 00000000..cd21027b --- /dev/null +++ b/v2/crates/ruview-unified/src/gaussian/gain.rs @@ -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" + ); + } +} diff --git a/v2/crates/ruview-unified/src/gaussian/graph.rs b/v2/crates/ruview-unified/src/gaussian/graph.rs new file mode 100644 index 00000000..fb1491a9 --- /dev/null +++ b/v2/crates/ruview-unified/src/gaussian/graph.rs @@ -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, +} + +/// 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, + edges: Vec, +} + +/// 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, + /// Edges with both endpoints activated. + pub edges: Vec, + /// 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, to: impl Into, 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 = relevant_kinds.iter().copied().collect(); + let mut visited: BTreeSet<&str> = BTreeSet::new(); + let mut queue: VecDeque<&str> = VecDeque::new(); + let mut activated: Vec = 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()); + } +} diff --git a/v2/crates/ruview-unified/src/gaussian/map.rs b/v2/crates/ruview-unified/src/gaussian/map.rs new file mode 100644 index 00000000..032c41b2 --- /dev/null +++ b/v2/crates/ruview-unified/src/gaussian/map.rs @@ -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, + /// Cell → indices. Rebuilt on decay/prune, updated on insert. + grid: HashMap<(i64, i64, i64), Vec>, + 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 { + &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 = 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 { + 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 { + let r2 = radius * radius; + let mut out: Vec = 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 { + let lo = |axis: usize| a[axis].min(b[axis]) - margin; + let hi = |axis: usize| a[axis].max(b[axis]) + margin; + let c_lo: Vec = (0..3).map(|k| (lo(k) / self.cell_size).floor() as i64).collect(); + let c_hi: Vec = (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 = 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 { + (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 { + 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 { + let norm_q: f64 = embedding.iter().map(|v| f64::from(*v).powi(2)).sum::().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::().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"); + } +} diff --git a/v2/crates/ruview-unified/src/gaussian/mod.rs b/v2/crates/ruview-unified/src/gaussian/mod.rs new file mode 100644 index 00000000..f2fca116 --- /dev/null +++ b/v2/crates/ruview-unified/src/gaussian/mod.rs @@ -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}; diff --git a/v2/crates/ruview-unified/src/gaussian/primitive.rs b/v2/crates/ruview-unified/src/gaussian/primitive.rs new file mode 100644 index 00000000..9bee761e --- /dev/null +++ b/v2/crates/ruview-unified/src/gaussian/primitive.rs @@ -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, +} + +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 { + 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::().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); + } +} diff --git a/v2/crates/ruview-unified/src/heads.rs b/v2/crates/ruview-unified/src/heads.rs new file mode 100644 index 00000000..6a409661 --- /dev/null +++ b/v2/crates/ruview-unified/src/heads.rs @@ -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, + /// 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::() + self.b) + } + + /// Full-batch gradient-descent training (convex ⇒ deterministic + /// convergence). Returns the final mean cross-entropy. + pub fn train(&mut self, zs: &[Vec], 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, + /// `rank × d_model`, row-major. + pub v: Vec, + /// Per-class bias. + pub b: Vec, + 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 { + let mut probs = self.logits(z); + softmax(&mut probs); + probs + } + + fn logits(&self, z: &[f64]) -> Vec { + 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::(); + } + logits + } + + /// Full-batch softmax cross-entropy training. Returns final mean CE. + pub fn train(&mut self, zs: &[Vec], 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::(); + } + 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, + /// 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::(); + } + out + } + + /// Full-batch MSE training. Returns final mean squared error (m²). + pub fn train(&mut self, zs: &[Vec], 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::() / n; + let var = normal_errors.iter().map(|e| (e - mean).powi(2)).sum::() / (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) { + 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 = + (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 = (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 = (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"); + } +} diff --git a/v2/crates/ruview-unified/src/lib.rs b/v2/crates/ruview-unified/src/lib.rs new file mode 100644 index 00000000..3920f440 --- /dev/null +++ b/v2/crates/ruview-unified/src/lib.rs @@ -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 = std::result::Result; diff --git a/v2/crates/ruview-unified/src/math.rs b/v2/crates/ruview-unified/src/math.rs new file mode 100644 index 00000000..41e6b2fa --- /dev/null +++ b/v2/crates/ruview-unified/src/math.rs @@ -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 { + 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, +} + +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 { + 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 { + 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 = 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::() / 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 { + 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 = (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 = (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 = (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 = (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 = (0..8).map(|_| a.gen_range(-1.0..1.0)).collect(); + let vb: Vec = (0..8).map(|_| b.gen_range(-1.0..1.0)).collect(); + assert_eq!(va, vb); + } +} diff --git a/v2/crates/ruview-unified/src/policy.rs b/v2/crates/ruview-unified/src/policy.rs new file mode 100644 index 00000000..6c3f64b5 --- /dev/null +++ b/v2/crates/ruview-unified/src/policy.rs @@ -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, + /// 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, + ) -> Result { + 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, +} + +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 { + 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()); + } +} diff --git a/v2/crates/ruview-unified/src/pretrain.rs b/v2/crates/ruview-unified/src/pretrain.rs new file mode 100644 index 00000000..698be629 --- /dev/null +++ b/v2/crates/ruview-unified/src/pretrain.rs @@ -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, + /// Age gate bias grads. + pub age_b: Vec, + /// 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 = (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 = (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 = + (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 { + let n_mask = ((n_tokens as f64 * fraction).round() as usize).clamp(1, n_tokens - 1); + let mut idx: Vec = (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> = 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::() + / windows.len() as f64 + }; + + let initial_loss = eval(enc); + let mut order: Vec = (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 { + 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); + } +} diff --git a/v2/crates/ruview-unified/src/synth/generator.rs b/v2/crates/ruview-unified/src/synth/generator.rs new file mode 100644 index 00000000..2153e1f7 --- /dev/null +++ b/v2/crates/ruview-unified/src/synth/generator.rs @@ -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 { + (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 { + 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 = (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> = 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 = 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 = + (0..snaps).map(|s| w.tensor.data[[l, b, s]].norm()).collect(); + let m = vals.iter().sum::() / snaps as f64; + acc += vals.iter().map(|v| (v - m).powi(2)).sum::() / snaps as f64; + } + } + acc / (links * bins) as f64 + }; + let present: Vec = + corpus.iter().filter(|w| w.presence).map(temporal_energy).collect(); + let absent: Vec = + corpus.iter().filter(|w| !w.presence).map(temporal_energy).collect(); + let mean = |v: &[f64]| v.iter().sum::() / 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); + } +} diff --git a/v2/crates/ruview-unified/src/synth/mod.rs b/v2/crates/ruview-unified/src/synth/mod.rs new file mode 100644 index 00000000..2257c2c7 --- /dev/null +++ b/v2/crates/ruview-unified/src/synth/mod.rs @@ -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}; diff --git a/v2/crates/ruview-unified/src/synth/raytrace.rs b/v2/crates/ruview-unified/src/synth/raytrace.rs new file mode 100644 index 00000000..21dec9a5 --- /dev/null +++ b/v2/crates/ruview-unified/src/synth/raytrace.rs @@ -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 { + 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 { + 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 { + // 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}" + ); + } + } +} diff --git a/v2/crates/ruview-unified/src/synth/room.rs b/v2/crates/ruview-unified/src/synth/room.rs new file mode 100644 index 00000000..2bd5e356 --- /dev/null +++ b/v2/crates/ruview-unified/src/synth/room.rs @@ -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, +} + +impl RoomSpec { + /// Validated constructor: positive dimensions, finite fields, people + /// starting inside the room. + pub fn new(size: [f64; 3], wall_material: Material, people: Vec) -> Result { + 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]); + } +} diff --git a/v2/crates/ruview-unified/src/tensor.rs b/v2/crates/ruview-unified/src/tensor.rs new file mode 100644 index 00000000..feb0d2b0 --- /dev/null +++ b/v2/crates/ruview-unified/src/tensor.rs @@ -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, +} + +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, + /// Per-link antenna geometry; `links.len() == data.dim().0`. + pub links: Vec, + /// 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, + links: Vec, + sample_age_s: f64, + timestamp_ns: u64, + device_id: String, + clock_quality: f64, + uncertainty: f64, + calibration: CalibrationMeta, + ) -> Result { + 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 { + Array3::from_elem((links, CANONICAL_BINS, CANONICAL_SNAPSHOTS), Complex64::new(1.0, 0.0)) + } + + fn build(data: Array3, links: Vec) -> Result { + 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(_)))); + } +} diff --git a/v2/crates/ruview-unified/src/tokenizer.rs b/v2/crates/ruview-unified/src/tokenizer.rs new file mode 100644 index 00000000..a4232392 --- /dev/null +++ b/v2/crates/ruview-unified/src/tokenizer.rs @@ -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, + /// 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 = 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::() / amp_all.len() as f64; + let var = amp_all.iter().map(|a| (a - mean_amp).powi(2)).sum::() + / 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::()).sum::() + }; + let var = |w: &TokenizedWindow| w.tokens.iter().map(|t| t.features[16]).sum::(); + 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); + } + } +} diff --git a/v2/crates/ruview-unified/tests/e2e_acceptance.rs b/v2/crates/ruview-unified/tests/e2e_acceptance.rs new file mode 100644 index 00000000..bcedde96 --- /dev/null +++ b/v2/crates/ruview-unified/tests/e2e_acceptance.rs @@ -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, + windows: Vec, + 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 = + 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, Vec) { + // Self-supervised pretraining: training windows only, no labels. + let train_windows: Vec = + 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> = 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> = split.train.iter().map(|&i| zs[i].clone()).collect(); + let train_y: Vec = split.train.iter().map(|&i| fx.corpus[i].presence).collect(); + head.train(&train_z, &train_y, 1.0, 800); + + let eval = |idx: &[usize]| -> (Vec, Vec) { + let probs: Vec = idx.iter().map(|&i| head.predict_prob(&zs[i])).collect(); + let labels: Vec = 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::>(), &train_l); + let held_f1 = f1_score(&test_p.iter().map(|p| *p > 0.5).collect::>(), &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 = 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> = fx + .corpus + .iter() + .take(60) + .map(|w| encoder.encode_content(&tokenizer.tokenize(&w.tensor))) + .collect(); + let ys: Vec = 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()); +}