diff --git a/CHANGELOG.md b/CHANGELOG.md index fe9e1a0a..1313a10f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **`ruview-unified` increment 2 — native frame contract + programmable perception (ADR-279..282).** (1) `RfFrameV2` becomes the authoritative RF record: native complex IQ with explicit validity masks, declared `PhaseState`, TX/RX poses + antenna geometry in one building frame, calibration/quality state, and a provenance rule enforced at construction — `Synthetic ⇒ L0Simulation` and `Measured ⇒ ≥ L1CapturedReplay` can never alias (the public L0–L5 evidence ladder is now a type); the 56-bin canonical tensor is demoted to a derived compatibility view (`to_canonical`, mask-aware gap-filling through the same normalization path as every adapter; native samples proven byte-untouched). (2) Active sensing control plane (`control.rs`): ETSI-ISAC-vocabulary `SensingTask` admission (raw export always refused; identity requires consent), `SensingAction`/`InformationGoal`, an age-of-information `ActiveSensingPlanner` (priority = uncertainty × change rate × criticality ÷ cost; **measured 95% sensing-traffic reduction** vs uniform refresh on a 20-region scenario), fail-closed `CoherentSensorGroup` fusion gates (time/phase/geometry bounds; five denial paths tested), policy-authorized RIS/movable-antenna actuation receipts, and purpose-scoped `TaskSufficientRepresentation` leakage validation. (3) New modality surfaces: BLE Channel Sounding adapter + `ble_cs_range` treating phase-slope and RTT as **separate cross-validated evidence** (exact distance recovery on synthetic tones; relay-style divergence flagged, never averaged), delay-Doppler-native `FieldAxis` + `delay_doppler_map` (unit-peak tone test), IEEE P3162 synthetic-aperture import profile. (4) RePos-factorized pose head (relative skeleton on the content representation, root on the geometry-conditioned one, calibrated per-joint uncertainties): held-out-room MPJPE 0.0003 m vs 0.2534 m for the monolithic baseline in the room-shortcut leakage experiment; ≤2% structured-adapter budget (740 params). (5) Age gate input now `log(1+age_ms)` per the age-aware-CSI recipe (gradient check re-proven); Gaussian primitives gained `first_seen_ns`/`doppler_variance`/bounded `source_receipts` lineage; `PartitionKey` gained a `session` dimension and `SplitManifest` certifies disjointness across all seven dimensions. 87 tests, 0 failed; crate clippy-clean. Docker images unaffected (no shipped binary consumes the crate yet); Python proof re-verified PASS. - **`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 diff --git a/CLAUDE.md b/CLAUDE.md index b5da014c..3281e56a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,7 +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. | +| `ruview-unified` | ADR-273..282 **unified RF spatial world model**: authoritative native `RfFrameV2` frame contract (native IQ never overwritten, phase-state/evidence-ladder/provenance invariants) with the canonical `RfTensor` as a derived view; fail-closed hardware adapter registry (WiFi CSI / FMCW cube / UWB CIR / 5G SRS / BLE Channel Sounding with phase-vs-RTT cross-validated ranging); universal RF foundation encoder (masked-reconstruction pretraining with finite-difference-verified backprop, `z = Enc ⊙ σ(AgeEnc(log age)) + Geom` fusion, ≤1% scalar / <2% structured task adapters incl. RePos-factorized pose); RF-aware Gaussian spatial memory (fusion/decay/channel-gain queries + inverse updates, lineage receipts, task-gated scene graph); physics-guided synthetic RF world generator (image-method multipath, Fresnel materials, emergent Doppler, seeded domain randomization); edge sensing control plane (802.11bf/ETSI-ISAC purposes/zones/tasks, AoI active-sensing planner, fail-closed coherent-aperture fusion, governed RIS actuation; raw RF structurally unexportable); delay-Doppler-native transforms. Pure Rust leaf; all accuracy numbers SYNTHETIC (evidence level L0) until real-data validation. | ### RuvSense Modules (`signal/src/ruvsense/`) | Module | Purpose | @@ -63,7 +63,7 @@ All 5 ruvector crates integrated in workspace: - `ruvector-attention` → `model.rs` (apply_spatial_attention) + `bvp.rs` ### Architecture Decisions -201 ADRs in `docs/adr/` (numbered ADR-001 through ADR-278, with gaps). Key ones: +205 ADRs in `docs/adr/` (numbered ADR-001 through ADR-282, 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) @@ -88,6 +88,10 @@ All 5 ruvector crates integrated in workspace: - 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) +- ADR-279: Native RF frame contract — `RfFrameV2` authoritative, canonical tensor demoted to derived view; 7 invariants; split manifest with session dimension (Accepted — implemented) +- ADR-280: Active sensing & programmable perception — sensing tasks/actions, AoI freshness scheduler (95% traffic reduction measured), fail-closed coherent-aperture fusion, governed RIS actuation, task-sufficient representations (Accepted — implemented) +- ADR-281: BLE Channel Sounding (phase vs RTT cross-validated ranging), delay-Doppler-native tensors, IEEE P3162 import profile, RePos factorized pose (Accepted — implemented) +- ADR-282: Ecosystem positioning — RuView as edge RF perception runtime; RuField/RuVector/MetaHarness layering; mandatory L0–L5 evidence ladder (Accepted) ### Supported Hardware diff --git a/README.md b/README.md index e2518561..3566ca2d 100644 --- a/README.md +++ b/README.md @@ -638,7 +638,7 @@ 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) | 201 ADRs — why each technical choice was made, organized by domain (hardware, signal processing, ML, platform, infrastructure) | +| [Architecture Decisions](docs/adr/README.md) | 205 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 | diff --git a/docs/adr/ADR-279-native-rf-frame-contract.md b/docs/adr/ADR-279-native-rf-frame-contract.md new file mode 100644 index 00000000..d891d269 --- /dev/null +++ b/docs/adr/ADR-279-native-rf-frame-contract.md @@ -0,0 +1,54 @@ +# ADR-279: Native RF frame contract — `RfFrameV2` is authoritative, the canonical tensor is a derived view + +| Field | Value | +|-------|-------| +| **Status** | Accepted — **implemented** (`ruview-unified/src/frame.rs`; 5 invariant tests) | +| **Date** | 2026-07-26 | +| **Parent** | ADR-273 (amends ADR-274 §2) | +| **Relates to** | ADR-136 (`CanonicalFrame` — extended, not replaced), ADR-262 (provenance discipline), ADR-282 (evidence ladder policy) | + +## 0. PROOF discipline + +Grades per ADR-273 §0. This ADR is a **correction** to ADR-274 §2, adopted before any measured-data debt accumulates. + +## 1. Context — the architectural correction + +ADR-274 made the 56-bin × 8-snapshot canonical `RfTensor` the adapter output, which is right for *compatibility* but wrong as the *authoritative* format: resampling every device into one fixed tensor discards bandwidth (a 320 MHz 802.11bk capture and a 20 MHz 802.11n capture become indistinguishable), antenna structure, phase state, and hardware-specific information a foundation encoder should learn from (the WiLLM lesson: lightweight per-device adapters into a shared *latent*, not a shared *tensor*). RuView's own history proves the cost of premature canonicalization — MERIDIAN's normalizer is useful precisely because the native data was still around. + +## 2. Decision — `RfFrameV2` + +The authoritative record preserves the native capture. Fields per the implementation: schema version, frame id, timestamp, modality (now including `WifiCir`, `WifiBfReport`, `FmcwRangeAzimuth`, `FmcwDopplerAzimuth` alongside CSI/SRS/FMCW/UWB/BLE-CS), **declared native axes** (`FieldAxis`: time/frequency/delay/Doppler/range/azimuth/elevation/antenna/polarization), centre frequency, bandwidth, sample rate, arbitrary-rank `native_shape` + `native_iq` + explicit `valid_mask`, TX/RX `Pose3` in one building frame, `AntennaElement` geometry, `sample_age_ns`, `CalibrationState` with a **declared `PhaseState`** (`Raw | Sanitized | Calibrated | Unavailable`), `SignalQuality`, and `FrameProvenance`. + +Seven required invariants, each enforced in the validated constructor or proven by a test: + +1. **Native samples are never overwritten** — `to_canonical(&self)` is read-only; `canonical_view_is_derived_and_native_is_untouched` asserts byte-identical native IQ + mask after derivation. +2. Subcarrier/antenna masks are explicit (`valid_mask`, arity-checked). +3. Phase declares its state — consumers branch on `PhaseState` instead of guessing whether detrending happened. +4. TX/RX geometry uses one building coordinate system (`Pose3`). +5. Results retain source identity via `receipt_id` (consumed by the Gaussian memory's `source_receipts` lineage, ADR-275). +6. **Synthetic and measured frames can never share a provenance class**, strengthened to an evidence rule: `Synthetic ⇒ exactly L0Simulation`, `Measured ⇒ ≥ L1CapturedReplay` — both directions rejected at construction (`synthetic_and_measured_provenance_can_never_alias`). +7. Sample age is carried through the whole path (frame → tensor → age gate → `BoundedEvent`). + +## 3. The canonical tensor is demoted to a compatibility view + +`RfFrameV2::to_canonical()` derives the ADR-274 tensor **through the exact same normalization code path as every adapter** (`adapters::normalize_grid` — one normalization, many entry points), after mask-aware gap-filling (invalid bins interpolated from nearest valid neighbors on the complex plane). Rank ≠ 3 frames have no canonical projection and say so with a typed error. The existing ESP32/Intel/Atheros 114→56 projections stay as-is; they simply stop being the storage format. + +## 4. The mandatory split manifest + +The brief's leakage rule is now code: `PartitionKey` gains a `session` dimension (packet-session leakage is as real as room leakage) and `eval::SplitManifest` certifies per-dimension disjointness across **all seven** dimensions (room/day/person/chipset/firmware/layout/session): + +```text +train_rooms ∩ test_rooms = ∅ … train_sessions ∩ test_sessions = ∅ +``` + +`fully_disjoint()` is the bar for reporting a result as leakage-resistant; a room-holdout split that still shares people *says so* in its manifest instead of masquerading (test `split_manifest_certifies_per_dimension_disjointness`). The hidden real-world test set requirement (never accessible to synthetic generation/calibration) is process, recorded in ADR-282 §4. + +## 5. Consequences + +- New hardware (PicoScenes, Intel, Atheros, Realtek radar, 320 MHz 802.11bk) lands as an `RfFrameV2` producer + latent adapter; nothing is lost at ingest. Vendor conformance receipt = the constructor's invariants (native shape preserved, phase state declared, timestamps monotonic, geometry present, loss measured, synthetic flag correct). +- The encoder input contract (ADR-274) is unchanged *today* (it consumes the derived view); migrating the tokenizer to native-resolution tokens is the flagged follow-up once real multi-bandwidth data exists (P2). +- Storage cost rises (native + derived); accepted — the derived view can always be recomputed, the native never can be. + +## 6. Verification + +`cargo test -p ruview-unified frame::` — 5 tests: provenance aliasing, shape/mask/axes arity, derived-view purity + gap-filling, rank/geometry rejection, P3162 import-profile validation (`SyntheticApertureSoundingDataset`, ADR-281 §5). All MEASURED-CODE. diff --git a/docs/adr/ADR-280-active-sensing-programmable-perception.md b/docs/adr/ADR-280-active-sensing-programmable-perception.md new file mode 100644 index 00000000..7e8157a2 --- /dev/null +++ b/docs/adr/ADR-280-active-sensing-programmable-perception.md @@ -0,0 +1,59 @@ +# ADR-280: Active sensing and programmable perception — tasks, freshness, coherence, and governed actuation + +| Field | Value | +|-------|-------| +| **Status** | Accepted — **implemented** (`ruview-unified/src/control.rs`; 6 test suites incl. a measured ≥70 % traffic-reduction gate) | +| **Date** | 2026-07-26 | +| **Parent** | ADR-273; extends ADR-277 | +| **Relates to** | ADR-277 (policy engine — every contract here composes with it), ADR-262 (P0–P5 privacy classes, reused verbatim), ADR-148 (`ruview-swarm` — the mobile-agent consumer of sensing actions) | + +## 0. PROOF discipline + +Grades per ADR-273 §0. External motivators — ESI-Bench's act-to-uncover formalization, LuLIS's 256-coherent-RF-chain distributed aperture, ETSI's cooperative-ISAC and AI/data-handling work items, age-of-information digital-twin scheduling, semantic/task-sufficient communication architectures — are all EXTERNAL-UNVERIFIED. Everything asserted about *our* behavior is a named test. + +## 1. Context + +The important shift is from **passive sensing** (accept whatever measurements arrive) to **programmable perception**: the system chooses where, when, how, and at what fidelity to sense, then changes the radio environment or moves sensing agents to resolve uncertainty. Simultaneously, the dominant failure mode across the emerging systems is **hidden synchronization and calibration dependence** — shared clocks, known antenna poses, stable phase silently assumed, confidently wrong when violated. Both belong in the control plane, fail-closed, before capture begins. + +## 2. Decision — the evidence-aware sensing task (`SensingTask`) + +ETSI-ISAC-vocabulary contract: purpose, target zone, modalities, requested resolution, latency bound, minimum confidence (below which results become *no decision*), raw + result retention, authorized consumers, consent reference. `admit_task` composes with the ADR-277 engine and is fail-closed on every branch; two rules deserve record: + +- `raw_export_allowed` **exists in the contract** (ISAC vocabulary compatibility) but is **always refused** (`task_admission_is_fail_closed`): ADR-277 §2.1 made raw export unrepresentable, and a config flag does not reopen it. +- Identity-purpose tasks without a consent reference are refused before the zone check even runs. + +## 3. Decision — sensing actions (`SensingAction` + `InformationGoal`) + +An action is a deliberate act of evidence-gathering against a stated hypothesis ("the east corridor holds one stationary person or two closely spaced people"), bounded by latency, energy, and a **privacy ceiling** (`PrivacyClass` P0–P5, the ADR-262 ladder). Actions are what the planner (§4), a MetaHarness agent, or a swarm drone consume. + +## 4. Decision — age-of-information scheduler (`ActiveSensingPlanner`) + +A spatial twin is only useful when it knows which parts are stale. Per region: `SpatialStateFreshness` (last observation, expected change rate, uncertainty growth, business criticality, sensing cost), with + +```text +priority = uncertainty(age) × change_rate × criticality ÷ cost +``` + +The planner emits at most the highest-priority action above threshold per cycle. **Measured** (`planner_reduces_sensing_traffic_versus_uniform_refresh`): 20 regions / 100 ticks, one hot region — 100 observations vs 2,000 under uniform refresh = **95 % sensing-traffic reduction** while the hot region stays observed. (The brief's "50–90 %" was an architectural estimate; this is a synthetic-scenario measurement, sensitive to how concentrated change is.) Priority ordering is proven separately (`planner_prioritizes_stale_critical_regions`: emergency-exit > server-room > storage). + +## 5. Decision — coherent distributed apertures fail closed (`CoherentSensorGroup`) + +No coherent fusion unless the group can *prove* compatibility: every member must report sync state, be within the group's time-error and phase-error bounds, and match the calibrated baseline geometry hash; unknown reporters are rejected too. Five denial paths, each tested (`coherent_fusion_fails_closed`): missing member, clock drift, phase drift, geometry change since calibration, non-member injection. This is the antidote to the hidden-synchronization failure mode — a building-scale WiFi aperture (the LuLIS direction) degrades to incoherent processing rather than producing confident nonsense. + +## 6. Decision — programmable radio environments are governed actuators + +RIS / movable / fluid antennas change **which rooms and people are observable**, so actuation is governed like sensing: `request_actuation` is the only way to obtain an `ActuationReceipt`, it verifies the state is supported *and* that the affected zone grants the purpose under the ADR-277 engine (`actuation_requires_policy_authorization`: steering a beam for an ungranted purpose is denied). Receipts carry requested/applied state, time, controller, purpose — the audit trail the RIS governance requirement demands. + +## 7. Decision — task-sufficient representations are leakage-checked + +Semantic compression ("transmit occupancy uncertainty, not CSI") must remain **task-scoped**: a representation sufficient for anonymous occupancy may not retain identity. `TaskSufficientRepresentation` carries source lineage, an information bound, an explicit `excluded_information` list, and a privacy class; `validate_representation` enforces per-purpose ceilings (Presence/Diagnostics ≤ P2 excluding identity+vitals; Activity/Localization ≤ P3 excluding identity; Vitals/Pose ≤ P4; Identity = P5) and refuses lineage-free orphans (`task_sufficient_representation_is_leakage_checked`). + +## 8. Standards alignment (the strongest strategic seam) + +The vocabulary here — sensing task/service/entity, measurement configuration, sensing data/result/consumer/purpose, retention, result exposure — is deliberately the emerging ETSI ISAC data-plane vocabulary, positioning this crate as an open reference implementation candidate for ISAC data handling rather than a parallel dialect. Charging/mobility management are explicitly out of scope until a cellular deployment exists. + +## 9. Consequences + +- MetaHarness/OaK-style agents get a typed surface: read freshness, plan actions, receive receipts — spatial memory meets agentic planning without touching raw RF. +- Distributed-aperture work (P4+) inherits a fusion gate that already fails closed. +- Not implemented (honest scope): information-gain *estimation* is caller-supplied (the planner uses staleness heuristics, not mutual information); RIS drivers, actual multi-AP coherence measurement, and OTFS waveform control are hardware-dependent roadmap items. diff --git a/docs/adr/ADR-281-ble-cs-delay-doppler-pose-factorization.md b/docs/adr/ADR-281-ble-cs-delay-doppler-pose-factorization.md new file mode 100644 index 00000000..28360319 --- /dev/null +++ b/docs/adr/ADR-281-ble-cs-delay-doppler-pose-factorization.md @@ -0,0 +1,49 @@ +# ADR-281: New modality surfaces — BLE Channel Sounding, delay-Doppler-native tensors, P3162 import, and factorized pose + +| Field | Value | +|-------|-------| +| **Status** | Accepted — **implemented** (`adapters.rs` BLE CS + ranging evidence, `tensor.rs::delay_doppler_map`, `frame.rs` P3162 import profile, `heads.rs` factorized pose; 8 new test suites) | +| **Date** | 2026-07-26 | +| **Parent** | ADR-273; extends ADR-274 | +| **Relates to** | ADR-279 (`FieldAxis` native axes), ADR-152 (geometry conditioning intake), ADR-021/263 (radar hardware) | + +## 0. PROOF discipline + +Grades per ADR-273 §0. Bluetooth SIG cm-level claims vs the ~20–50 cm practical review, OTFS ISAC field trials, IEEE P3162, PerceptAlign's >60 % cross-domain error reduction, and RePos's 10–21 % MPJPE gains are EXTERNAL-UNVERIFIED design inputs. Our numbers below are MEASURED-CODE / MEASURED-SYNTHETIC. + +## 1. BLE Channel Sounding (§2) — likely the fastest path to consumer-scale spatial anchoring + +`BleCsFrame` carries per-frequency-step round-trip tone phases plus optional RTT. Two rules: + +- **Phase-based ranging and RTT are separate evidence sources.** `ble_cs_range` computes both — `d_phase = |dθ/df|·c/4π` from the unwrapped phase-vs-frequency slope, `d_rtt = rtt·c/2` — and *cross-validates* instead of averaging. Agreement raises confidence; divergence beyond 0.5 m yields `RangingAnomaly::Divergent` (multipath bias, relay attack, timing fault, or calibration problem) with confidence capped ≤ 0.2. Measured: exact recovery at 1.5/5/12 m (< 1 µm error on clean synthetic phases, 40 steps × 1 MHz); a relay-style RTT inflation to ~51 m against a 5 m phase estimate is flagged, not blended (`ble_cs_flags_relay_style_divergence_instead_of_averaging`). +- The tensor view (`BleCsAdapter`, `nrf54-cs` in the registry) **never detrends phase** — the ranging ramp *is* the measurement; the preserved ramp is asserted in test. + +Single-source evidence (no RTT) is capped at confidence 0.5 — one mechanism alone is never high-trust ranging. + +## 2. Delay-Doppler-native support (§3) + +`FieldAxis` (ADR-279) makes delay/Doppler first-class native axes so OTFS-style captures are stored natively, and `RfTensor::delay_doppler_map` provides the standard transform for frequency-time tensors: IDFT over bins (→ delay) × DFT over snapshots (→ Doppler). Measured: a synthetic scatterer at (delay 7, Doppler 3) produces a unit peak with < 1e-9 leakage everywhere else. Rule: derived features may be small, but delay-Doppler maps are not collapsed into scalar motion energy before provenance and local storage. + +## 3. IEEE P3162 synthetic-aperture import (§5) + +`SyntheticApertureSoundingDataset` (frequency range, aperture poses, directional PDP, coordinate system, processing-manifest hash) is the validated import profile — the calibration bridge between measured environments, Sionna-class simulators, and learned RF scene models. Schema + validation only; parsers arrive with the first real dataset. + +## 4. Factorized pose (RePos) + log-age gating + +`FactorizedPoseHead` separates what generalizes from what conditions: + +- **relative skeleton** branch reads the environment-invariant content representation (cannot learn room-position shortcuts); +- **root localization** branch reads the geometry-conditioned representation (sensor pose is signal there — the PerceptAlign lesson); +- `absolute = root + relative` (`PoseOutput::absolute_joints_m`), with **calibrated per-joint and root residual σ** so every pose output carries uncertainty (ADR-273 item 8). + +**The leakage experiment** (`factorized_pose_resists_room_shortcut_leakage`): training rooms where room position *correlates* with body scale (the trap real deployments set), held-out room breaking the correlation — factorized MPJPE **0.0003 m** vs monolithic absolute-head **0.2534 m** (845× worse), on a toy that isolates the mechanism. MEASURED-CODE for the mechanism; not a pose-accuracy claim. + +Budget: the structured pose head is the largest adapter at **740 params vs the 40,856-param backbone (1.8 %)** — documented ceiling for structured heads is **< 2 %** (scalar heads keep the 1 % gate), both asserted in `every_head_fits_the_one_percent_budget_at_deployment_config`. + +Age gating now matches the age-aware-CSI recipe exactly: the freshness gate input is `log(1 + sample_age_ms)` (`encoder::age_feature`), giving millisecond and multi-second staleness comparable input scale; the finite-difference gradient check re-proves the backward pass through the changed input. + +## 5. Consequences + +- Bluetooth/UWB anchors slot in as *geometric* evidence while WiFi carries ambient activity — the complement strategy, in code. +- The Gaussian primitive gained the lifecycle fields the update-loop spec requires (`first_seen_ns`, `doppler_variance`, bounded `source_receipts` lineage merged on fusion) — static structure is distinguishable from transients by lifetime, and every primitive traces to source frames. +- Roadmap, explicitly not done: real nRF54 CS capture path, OTFS waveform generation, P3162 file parsing, pose heads on real MM-Fi-style data. diff --git a/docs/adr/ADR-282-ruview-ecosystem-positioning.md b/docs/adr/ADR-282-ruview-ecosystem-positioning.md new file mode 100644 index 00000000..3189f2c4 --- /dev/null +++ b/docs/adr/ADR-282-ruview-ecosystem-positioning.md @@ -0,0 +1,62 @@ +# ADR-282: Ecosystem positioning — RuView is the camera-free RF perception runtime, not the whole spatial OS + +| Field | Value | +|-------|-------| +| **Status** | Accepted (positioning + evidence-ladder policy; ladder implemented as `frame::EvidenceLevel`) | +| **Date** | 2026-07-26 | +| **Parent** | ADR-273 | +| **Relates to** | ADR-260/262 (RuField), ADR-261 (RuVector), ADR-182 (MetaHarness-minted harness), ADR-279 (provenance/evidence types), ADR-187 (honest labeling precedent) | + +## 1. Context + +RuView currently occupies a valuable but ambiguous position: the README's breadth invites reading every capability as field-validated, and the platform sometimes speaks as if it were the complete spatial intelligence operating system. The defensible identity is narrower and stronger. + +## 2. Decision — the layered identity + +> **RuView is an open, edge-native RF perception runtime that turns heterogeneous radio measurements into governed spatial observations.** + +It is *not* positioned as a complete world model, robotics platform, digital twin, or universal spatial OS. The stack divides: + +| Layer | Responsibility | Owner | +|---|---|---| +| Applications | healthcare, buildings, robotics, security, retail, industrial | application systems | +| Agent & decision | query planning, active sensing, automation, policy | **MetaHarness** | +| Spatial memory & reasoning | persistent objects, Gaussian fields, scene graphs, temporal memory | **RuVector** (fed by `ruview-unified::gaussian`) | +| Governed sensing plane | evidence, privacy, calibration, lineage, sensing tasks | **RuField** (bridged per ADR-262; contracts in ADR-277/279/280) | +| Perception & edge inference | native capture, adapters, shared encoder, task heads, uncertainty, P0 containment | **RuView** | +| Radio & physical sensors | WiFi CSI/CIR/BF, radar, UWB, BLE CS, cellular SRS | hardware | + +Competitive posture follows from the layer: **complement vision platforms** (coverage where cameras are unavailable, unwanted, or ineffective — never "replaces cameras universally"); one shared encoder + spatial field across CSI and radar; BLE/UWB as geometric anchors with WiFi for ambient sensing; and against 6G ISAC, be the practical open implementation of the sensing data plane on hardware that exists today. + +## 3. Decision — strengths to invest, weaknesses to fix + +Invest (already differentiated): low-cost ambient perception on commodity radios; camera-free coverage (with the explicit caveat that camera-free ≠ privacy-preserving — that is what ADR-277/280 gates are for); edge-first execution; existing application surfaces (HA/Matter/HomeKit), to be extended toward ROS 2, OpenUSD, MQTT Sparkplug, OPC UA, BIM/digital-twin connectors as demand proves out. + +Fix (each has a concrete ADR): platform/world-model claim mixing → this ADR's ladder; no persistent spatial representation → ADR-275 (feed RuVector, don't contain everything in the sensing server); ESP32-specific pipeline risk → ADR-279 adapters; stream-only operation → ADR-280 sensing tasks. + +## 4. Decision — the public evidence ladder (mandatory) + +`frame::EvidenceLevel` is now a type, and its use is policy: + +| Level | Meaning | +|---|---| +| L0 | Simulation only | +| L1 | Captured replay | +| L2 | Controlled laboratory | +| L3 | Held-out room + subject validation | +| L4 | Multi-site field pilot | +| L5 | Production operational evidence | + +Rules: (a) every capability row in README/registry carries exactly one level; (b) `ProvenanceClass::Synthetic` frames are L0 *by type* and measured frames are ≥ L1 — the constructor rejects both aliasing directions (ADR-279 invariant 6); (c) a level upgrade requires the corresponding artifact (a replay corpus, a lab protocol, a strict-split manifest per ADR-279 §4, a pilot report); (d) the hidden real-world test set used for L3+ claims is never accessible to synthetic generation, augmentation, or calibration. Everything shipped in ADR-273..281 is **L0** except the adapter/contract layers, which are code-level (no accuracy claim to grade). + +## 5. Commercial focus (bounded claims per vertical) + +Elder care (decision support and anomaly escalation, **not** diagnosis); smart buildings (occupancy/utilization; value = energy + space + safety − cost); industrial safety (works in dust/darkness/occlusion; **not** a certified safety system until field-validated); security (through-wall occupancy with the surveillance-governance gates of ADR-277/280 as a feature, not friction); robotics (RuView is probabilistic exteroception, never ground truth). + +## 6. The moat + +Not any single detector: the *combination* of broad hardware support (ADR-279 adapters), heterogeneous data with provenance, cross-environment pretrained encoders under anti-leakage evaluation (ADR-273 §4), calibration/uncertainty discipline, privacy-preserving edge execution (ADR-277/280), cryptographic evidence (RuField bridge), persistent spatial memory (ADR-275 → RuVector), and open integration. Harder to reproduce than any model. + +## 7. Acceptance test (ecosystem-fit) + +RuView fits the mature stack when a **frozen** encoder ingests WiFi CSI, radar, and Bluetooth measurements from previously unseen hardware, emits RuField-compliant observations, updates a persistent RuVector spatial model, and supports an agent query with: ≤ 0.5 m p90 localization; < 20 % degradation across unseen rooms; explicit uncertainty on every result; complete calibration + provenance lineage; no P0 RF leaving the edge; replay/lab/live evidence clearly separated; successful fusion with a standard robotics or digital-twin platform. Tracked as the L4 gate; the synthetic analogue machinery already exists (`tests/e2e_acceptance.rs`). diff --git a/docs/adr/README.md b/docs/adr/README.md index e979428d..f64537e6 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -138,6 +138,10 @@ Statuses: **Proposed** (under discussion), **Accepted** (approved and/or impleme | [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 | +| [ADR-279](ADR-279-native-rf-frame-contract.md) | Native RF frame contract — `RfFrameV2` authoritative, canonical tensor derived | Accepted (implemented) | +| [ADR-280](ADR-280-active-sensing-programmable-perception.md) | Active sensing & programmable perception control plane | Accepted (implemented) | +| [ADR-281](ADR-281-ble-cs-delay-doppler-pose-factorization.md) | BLE Channel Sounding, delay-Doppler tensors, P3162 import, factorized pose | Accepted (implemented) | +| [ADR-282](ADR-282-ruview-ecosystem-positioning.md) | Ecosystem positioning + mandatory L0–L5 evidence ladder | Accepted | --- diff --git a/v2/crates/ruview-unified/src/adapters.rs b/v2/crates/ruview-unified/src/adapters.rs index 1511ff35..610eb2e7 100644 --- a/v2/crates/ruview-unified/src/adapters.rs +++ b/v2/crates/ruview-unified/src/adapters.rs @@ -73,6 +73,20 @@ pub enum RawCapture { /// Device identifier. device_id: String, }, + /// Bluetooth 6.0 Channel Sounding capture: per-frequency-step phase + /// samples plus optional round-trip timing (ADR-281 §2). Phase-based + /// ranging and RTT are **separate evidence sources** — see + /// [`ble_cs_range`] for the agreement/divergence contract. + BleCs { + /// The channel-sounding frame. + frame: BleCsFrame, + /// Initiator→reflector link geometry (single link). + links: Vec, + /// 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 { @@ -101,11 +115,105 @@ impl RawCapture { Self::WifiCsi { .. } => RfModality::WifiCsi, Self::FmcwRadarCube { .. } => RfModality::FmcwRadar, Self::UwbCir { .. } => RfModality::UwbCir, + Self::BleCs { .. } => RfModality::BleCs, Self::CellularSrs { .. } => RfModality::CellularSrs, } } } +/// Bluetooth Channel Sounding frame (ADR-281 §2): tone phases across +/// frequency steps plus optional round-trip timing. +#[derive(Debug, Clone)] +pub struct BleCsFrame { + /// Sounded frequencies, Hz (uniformly spaced, ascending). + pub frequency_steps_hz: Vec, + /// Measured round-trip tone phase at each step, radians (wrapped). + pub phase_samples_rad: Vec, + /// Round-trip time measurement, ns, when the mode includes RTT. + pub round_trip_time_ns: Option, +} + +/// Anomaly classes when the two CS evidence sources disagree. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RangingAnomaly { + /// Phase-based and RTT distances diverge beyond tolerance — multipath + /// bias, a relay attack, a timing fault, or a calibration problem. + Divergent, +} + +/// Distance evidence from one CS exchange. Phase-based ranging and RTT +/// are deliberately separate: agreement raises confidence, disagreement +/// is surfaced as an anomaly instead of silently averaged away. +#[derive(Debug, Clone)] +pub struct RangingEvidence { + /// Distance from the unwrapped phase-vs-frequency slope, metres. + pub phase_distance_m: f64, + /// Distance from round-trip timing, metres (when measured). + pub rtt_distance_m: Option, + /// Whether the two sources agree within tolerance. + pub agreement: bool, + /// Confidence in `[0, 1]` (decays with divergence). + pub confidence: f64, + /// Set when the sources diverge. + pub anomaly: Option, +} + +/// Agreement tolerance between phase and RTT distances, metres. +const CS_AGREEMENT_TOLERANCE_M: f64 = 0.5; + +/// Extracts ranging evidence from a CS frame. +/// +/// Physics: the round-trip tone phase is `θ(f) = −4π·f·d/c (mod 2π)`, so +/// the unwrapped slope gives `d = |dθ/df|·c/(4π)`. RTT gives +/// `d = rtt·c/2` independently. Cross-validation is the security value: +/// a relay attack that defeats one mechanism generally cannot fake both +/// consistently. +pub fn ble_cs_range(frame: &BleCsFrame) -> Result { + let n = frame.frequency_steps_hz.len(); + if n < 2 || frame.phase_samples_rad.len() != n { + return Err(UnifiedError::ShapeMismatch(format!( + "CS frame needs >= 2 steps with matching phases, got {n} steps / {} phases", + frame.phase_samples_rad.len() + ))); + } + let df = frame.frequency_steps_hz[1] - frame.frequency_steps_hz[0]; + if !(df.is_finite() && df > 0.0) { + return Err(UnifiedError::InvalidInput("frequency steps must ascend uniformly".into())); + } + // Unwrap phases across steps, then least-squares slope per Hz. + let mut unwrapped = Vec::with_capacity(n); + let mut prev = frame.phase_samples_rad[0]; + unwrapped.push(prev); + for &p in &frame.phase_samples_rad[1..] { + let mut v = p; + while v - prev > std::f64::consts::PI { + v -= 2.0 * std::f64::consts::PI; + } + while v - prev < -std::f64::consts::PI { + v += 2.0 * std::f64::consts::PI; + } + unwrapped.push(v); + prev = v; + } + let slope_per_step = crate::math::linear_slope(&unwrapped); + let c = 299_792_458.0; + let phase_distance_m = (slope_per_step / df).abs() * c / (4.0 * std::f64::consts::PI); + + let rtt_distance_m = frame.round_trip_time_ns.map(|rtt| rtt * 1e-9 * c / 2.0); + let (agreement, confidence, anomaly) = match rtt_distance_m { + None => (true, 0.5, None), // single-source evidence: capped confidence + Some(d_rtt) => { + let divergence = (phase_distance_m - d_rtt).abs(); + if divergence <= CS_AGREEMENT_TOLERANCE_M { + (true, (1.0 - divergence / CS_AGREEMENT_TOLERANCE_M).mul_add(0.5, 0.5), None) + } else { + (false, (-divergence).exp().min(0.2), Some(RangingAnomaly::Divergent)) + } + } + }; + Ok(RangingEvidence { phase_distance_m, rtt_distance_m, agreement, confidence, anomaly }) +} + /// 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. @@ -120,8 +228,12 @@ pub trait RfAdapter: Send + Sync { /// Shared stage 1–3 pipeline: resample to canonical dims, per-link median /// amplitude normalization, per-(link, snapshot) phase detrend. +/// +/// Crate-visible so [`crate::frame::RfFrameV2::to_canonical`] derives the +/// compatibility view through the exact same code path as every adapter +/// (ADR-279 §3 — one normalization, many entry points). #[allow(clippy::too_many_arguments)] -fn finish_normalization( +pub(crate) fn normalize_grid( modality: RfModality, mut grid: Array3, // (links, bins, snapshots) at source resolution links: Vec, @@ -288,7 +400,7 @@ impl RfAdapter for WifiCsiAdapter { 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( + normalize_grid( RfModality::WifiCsi, grid, links.clone(), @@ -359,7 +471,7 @@ impl RfAdapter for FmcwRadarAdapter { // 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( + normalize_grid( RfModality::FmcwRadar, grid, links.clone(), @@ -405,7 +517,7 @@ impl RfAdapter for UwbCirAdapter { got: raw.modality(), }); }; - finish_normalization( + normalize_grid( RfModality::UwbCir, taps.clone(), links.clone(), @@ -479,7 +591,7 @@ impl RfAdapter for CellularSrsAdapter { } } } - finish_normalization( + normalize_grid( RfModality::CellularSrs, grid, links.clone(), @@ -495,6 +607,63 @@ impl RfAdapter for CellularSrsAdapter { } } +/// Bluetooth Channel Sounding adapter: tone phasors over frequency steps +/// become the canonical bin axis (delay structure preserved — the ranging +/// ramp *is* the signal, so phase detrending is skipped). +pub struct BleCsAdapter { + hardware_id: String, +} + +impl BleCsAdapter { + /// New adapter for the given CS radio id (e.g. `"nrf54-cs"`). + #[must_use] + pub fn new(hardware_id: impl Into) -> Self { + Self { hardware_id: hardware_id.into() } + } +} + +impl RfAdapter for BleCsAdapter { + fn modality(&self) -> RfModality { + RfModality::BleCs + } + + fn hardware_id(&self) -> &str { + &self.hardware_id + } + + fn normalize(&self, raw: &RawCapture) -> Result { + let RawCapture::BleCs { frame, links, age_s, device_id } = raw else { + return Err(UnifiedError::ModalityMismatch { + adapter: self.hardware_id.clone(), + got: raw.modality(), + }); + }; + let n = frame.frequency_steps_hz.len(); + if n < 2 || frame.phase_samples_rad.len() != n { + return Err(UnifiedError::ShapeMismatch("malformed CS frame".into())); + } + let mut grid = Array3::zeros((1, n, 1)); + for (b, theta) in frame.phase_samples_rad.iter().enumerate() { + grid[[0, b, 0]] = Complex64::from_polar(1.0, *theta); + } + let centre = (frame.frequency_steps_hz[0] + frame.frequency_steps_hz[n - 1]) / 2.0; + let bandwidth = frame.frequency_steps_hz[n - 1] - frame.frequency_steps_hz[0]; + normalize_grid( + RfModality::BleCs, + grid, + links.clone(), + centre, + bandwidth.max(1.0), + *age_s, + 0, + device_id.clone(), + 0.9, + 0.15, + true, // the phase ramp is the measurement; never detrend it + ) + } +} + /// 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)] @@ -517,6 +686,7 @@ impl AdapterRegistry { r.register(Box::new(FmcwRadarAdapter::new("mr60bha2"))); r.register(Box::new(UwbCirAdapter::new("dw3000"))); r.register(Box::new(CellularSrsAdapter::new("oai-srs-xapp"))); + r.register(Box::new(BleCsAdapter::new("nrf54-cs"))); r } @@ -669,12 +839,73 @@ mod tests { assert_eq!(t.modality, RfModality::CellularSrs); } + /// Synthesizes CS phases for a known distance: θ(f) = −4π·f·d/c. + fn cs_frame(distance_m: f64, rtt_ns: Option) -> BleCsFrame { + let c = 299_792_458.0; + let steps: Vec = (0..40).map(|k| 2.402e9 + 1e6 * k as f64).collect(); + let phases: Vec = steps + .iter() + .map(|f| { + let theta = -4.0 * std::f64::consts::PI * f * distance_m / c; + theta.rem_euclid(2.0 * std::f64::consts::PI) + }) + .collect(); + BleCsFrame { frequency_steps_hz: steps, phase_samples_rad: phases, round_trip_time_ns: rtt_ns } + } + + #[test] + fn ble_cs_phase_ranging_recovers_exact_distance() { + let c = 299_792_458.0; + for d in [1.5, 5.0, 12.0] { + let rtt_ns = 2.0 * d / c * 1e9; + let ev = ble_cs_range(&cs_frame(d, Some(rtt_ns))).expect("evidence"); + assert!( + (ev.phase_distance_m - d).abs() < 1e-6, + "phase ranging {} vs true {d}", + ev.phase_distance_m + ); + assert!((ev.rtt_distance_m.unwrap() - d).abs() < 1e-9); + assert!(ev.agreement, "consistent sources must agree"); + assert!(ev.confidence > 0.9); + assert!(ev.anomaly.is_none()); + } + } + + #[test] + fn ble_cs_flags_relay_style_divergence_instead_of_averaging() { + // Phase says 5 m; a relay/timing fault inflates RTT to ~51 m. + let ev = ble_cs_range(&cs_frame(5.0, Some(340.0))).expect("evidence"); + assert!((ev.phase_distance_m - 5.0).abs() < 1e-6); + assert!(ev.rtt_distance_m.unwrap() > 50.0); + assert!(!ev.agreement); + assert_eq!(ev.anomaly, Some(RangingAnomaly::Divergent)); + assert!(ev.confidence < 0.25, "divergent evidence must not be trusted"); + } + + #[test] + fn ble_cs_adapter_produces_canonical_tensor() { + let adapter = BleCsAdapter::new("nrf54-cs"); + let raw = RawCapture::BleCs { + frame: cs_frame(3.0, None), + links: test_links(1), + age_s: 0.01, + device_id: "nrf54-a".into(), + }; + let t = adapter.normalize(&raw).expect("normalizes"); + assert_eq!(t.dims(), (1, CANONICAL_BINS, CANONICAL_SNAPSHOTS)); + assert_eq!(t.modality, RfModality::BleCs); + // The ranging ramp must survive (no detrend): phase varies across bins. + let p0 = t.data[[0, 0, 0]].arg(); + let p_mid = t.data[[0, CANONICAL_BINS / 2, 0]].arg(); + assert!((p0 - p_mid).abs() > 1e-3, "phase ramp must be preserved"); + } + #[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"] + vec!["dw3000", "esp32s3-csi", "mr60bha2", "nrf54-cs", "oai-srs-xapp"] ); // Unknown hardware ⇒ error, never a default adapter. diff --git a/v2/crates/ruview-unified/src/control.rs b/v2/crates/ruview-unified/src/control.rs new file mode 100644 index 00000000..aa686d09 --- /dev/null +++ b/v2/crates/ruview-unified/src/control.rs @@ -0,0 +1,666 @@ +//! Programmable perception — the active sensing control plane (ADR-280). +//! +//! The shift this module implements: from *passive* sensing (accept +//! whatever measurements arrive) to *programmable* perception (the system +//! chooses where, when, how, and at what fidelity to sense, then resolves +//! uncertainty deliberately). Five contracts: +//! +//! 1. [`SensingTask`] — the evidence-aware task contract (ETSI ISAC +//! sensing-task vocabulary: purpose, area, resolution, latency, +//! confidence, retention, consumers, consent). +//! 2. [`SensingAction`] + [`InformationGoal`] — a request to actively +//! gather evidence against a hypothesis, bounded by latency, energy, +//! and a privacy ceiling. +//! 3. [`ActiveSensingPlanner`] over [`SpatialStateFreshness`] — age-of- +//! information scheduling: refresh what is stale, changing, and +//! important, not everything uniformly. +//! 4. [`CoherentSensorGroup`] — distributed-aperture fusion is allowed +//! **only** when time, phase, and geometry compatibility is proven; +//! out-of-bounds members fail closed (the dominant failure mode of +//! emerging systems is hidden synchronization/calibration dependence). +//! 5. [`FieldActuator`] + [`ActuationReceipt`] — programmable radio +//! environments (RIS, movable antennas) are actuators whose state +//! changes alter *who is observable*, so actuation demands the same +//! policy authorization and auditability as sensing itself. +//! +//! Plus [`TaskSufficientRepresentation`] — semantic, task-scoped +//! compression whose leakage rules are validated, not assumed. + +use serde::{Deserialize, Serialize}; + +use crate::policy::{PolicyEngine, SensingPurpose}; +use crate::tensor::RfModality; +use crate::{Result, UnifiedError}; + +/// RuField-aligned privacy classes (ADR-262 §3.3 vocabulary). +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +pub enum PrivacyClass { + /// Raw signal — never leaves the trust boundary. + P0, + /// Heavily aggregated, non-personal. + P1, + /// Anonymous presence/occupancy grade. + P2, + /// Behavioral inference grade. + P3, + /// Derived personal inference grade. + P4, + /// Identity-bound grade. + P5, +} + +/// Axis-aligned spatial zone in the building frame. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SpatialZone { + /// Zone identifier (matches ADR-277 `PrivacyZone` ids). + pub id: String, + /// Minimum corner, metres. + pub min_m: [f64; 3], + /// Maximum corner, metres. + pub max_m: [f64; 3], +} + +impl SpatialZone { + /// Whether a point lies inside the zone. + #[must_use] + pub fn contains(&self, p: [f64; 3]) -> bool { + (0..3).all(|k| p[k] >= self.min_m[k] && p[k] <= self.max_m[k]) + } +} + +/// The evidence-aware sensing task contract (ADR-280 §2). Enforced +/// *before capture begins*, not applied later as metadata. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SensingTask { + /// Task identifier. + pub task_id: u128, + /// Purpose (drives ADR-277 zone authorization). + pub purpose: SensingPurpose, + /// Target area. + pub target_area: SpatialZone, + /// Modalities the task may use. + pub modalities: Vec, + /// Requested spatial resolution, metres. + pub requested_resolution_m: f64, + /// Maximum acceptable result latency, ms. + pub maximum_latency_ms: u32, + /// Minimum confidence below which results become *no decision*. + pub minimum_confidence: f64, + /// Raw (P0) retention bound, seconds — local only. + pub raw_retention_seconds: u64, + /// Result retention bound, seconds. + pub result_retention_seconds: u64, + /// Principals allowed to consume results. + pub authorized_consumers: Vec, + /// Consent reference, when the purpose requires one. + pub consent_reference: Option, + /// Requested raw export. Kept in the contract for ISAC-vocabulary + /// compatibility, but see [`PolicyEngine`]-backed admission: ADR-277's + /// structural rule means this is **always refused** today. + pub raw_export_allowed: bool, +} + +/// Admits a sensing task against the ADR-277 policy engine. Fail-closed: +/// unknown zone, ungranted purpose, identity single-gate, raw export, and +/// missing-consent identity tasks all deny. +pub fn admit_task(engine: &PolicyEngine, task: &SensingTask) -> Result<()> { + if task.raw_export_allowed { + return Err(UnifiedError::PolicyDenied( + "raw RF export is structurally disabled (ADR-277 §2.1); \ + the contract field exists for ISAC vocabulary compatibility only" + .into(), + )); + } + if !(task.minimum_confidence.is_finite() && (0.0..=1.0).contains(&task.minimum_confidence)) { + return Err(UnifiedError::InvalidInput("minimum_confidence must be in [0,1]".into())); + } + if task.purpose == SensingPurpose::IdentityRecognition && task.consent_reference.is_none() { + return Err(UnifiedError::PolicyDenied( + "identity recognition tasks require a consent reference".into(), + )); + } + engine.authorize(&task.target_area.id, task.purpose) +} + +/// What an active sensing request is trying to learn. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InformationGoal { + /// Human-readable hypothesis under test. + pub hypothesis: String, + /// Current uncertainty in `[0, 1]`. + pub current_uncertainty: f64, + /// Target uncertainty in `[0, 1]` (must be below current). + pub target_uncertainty: f64, + /// Expected information gain of the action (heuristic units). + pub expected_information_gain: f64, +} + +/// A deliberate act of sensing (ADR-280 §3). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SensingAction { + /// Action identifier. + pub action_id: String, + /// Region to observe. + pub target_region: SpatialZone, + /// Modality to use. + pub modality: RfModality, + /// Goal that justifies the action. + pub desired_information: InformationGoal, + /// Latency budget, ms. + pub maximum_latency_ms: u32, + /// Energy budget, joules. + pub energy_budget_j: f64, + /// Highest privacy class the action may produce. + pub privacy_ceiling: PrivacyClass, +} + +/// Freshness state of one spatial region (age-of-information model, +/// ADR-280 §4). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SpatialStateFreshness { + /// Region identifier. + pub region_id: String, + /// Region geometry. + pub region: SpatialZone, + /// Last observation, ns since epoch. + pub last_observed_ns: u64, + /// Expected change rate (events/s scale factor). + pub expected_change_rate: f64, + /// Uncertainty growth per second of staleness. + pub uncertainty_growth_rate: f64, + /// Business criticality weight (≥ 0). + pub business_criticality: f64, + /// Cost of sensing this region (energy/traffic units, > 0). + pub sensing_cost: f64, +} + +impl SpatialStateFreshness { + /// Uncertainty accumulated since the last observation, capped at 1. + #[must_use] + pub fn uncertainty_at(&self, now_ns: u64) -> f64 { + let age_s = now_ns.saturating_sub(self.last_observed_ns) as f64 / 1e9; + (self.uncertainty_growth_rate * age_s).min(1.0) + } + + /// Refresh priority: `uncertainty × change rate × criticality ÷ cost`. + #[must_use] + pub fn priority(&self, now_ns: u64) -> f64 { + self.uncertainty_at(now_ns) * self.expected_change_rate * self.business_criticality + / self.sensing_cost.max(1e-9) + } +} + +/// Age-of-information sensing scheduler: refreshes regions in priority +/// order instead of uniformly. +#[derive(Debug, Default)] +pub struct ActiveSensingPlanner { + regions: Vec, + /// Priority below which a region is not worth sensing this cycle. + pub priority_threshold: f64, +} + +impl ActiveSensingPlanner { + /// New planner with a priority threshold. + #[must_use] + pub fn new(priority_threshold: f64) -> Self { + Self { regions: Vec::new(), priority_threshold } + } + + /// Registers or replaces a region. + pub fn upsert_region(&mut self, region: SpatialStateFreshness) { + if let Some(r) = self.regions.iter_mut().find(|r| r.region_id == region.region_id) { + *r = region; + } else { + self.regions.push(region); + } + } + + /// Marks a region observed at `now_ns`. + pub fn mark_observed(&mut self, region_id: &str, now_ns: u64) { + if let Some(r) = self.regions.iter_mut().find(|r| r.region_id == region_id) { + r.last_observed_ns = now_ns; + } + } + + /// Highest-priority region above the threshold, as a concrete + /// [`SensingAction`]; `None` when nothing is worth sensing. + #[must_use] + pub fn next_action(&self, now_ns: u64, modality: RfModality) -> Option { + let best = self + .regions + .iter() + .map(|r| (r.priority(now_ns), r)) + .filter(|(p, _)| *p >= self.priority_threshold) + .max_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal))?; + let (priority, region) = best; + let uncertainty = region.uncertainty_at(now_ns); + Some(SensingAction { + action_id: format!("aoi-{}-{now_ns}", region.region_id), + target_region: region.region.clone(), + modality, + desired_information: InformationGoal { + hypothesis: format!("state of region {} is stale", region.region_id), + current_uncertainty: uncertainty, + target_uncertainty: (uncertainty * 0.2).min(0.05), + expected_information_gain: priority, + }, + maximum_latency_ms: 500, + energy_budget_j: region.sensing_cost, + privacy_ceiling: PrivacyClass::P2, + }) + } +} + +/// Clock/phase/geometry sync state reported by one member of a +/// distributed aperture. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MemberSyncState { + /// Member identifier. + pub member_id: String, + /// Measured time error vs the group reference, ns. + pub time_error_ns: f64, + /// Measured phase error vs the group reference, rad. + pub phase_error_rad: f64, + /// Hash of the member's calibrated baseline geometry. + pub geometry_hash: u64, +} + +/// A coherent sensing group (ADR-280 §5): no coherent fusion unless +/// time, phase, and geometry compatibility is *proven*. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CoherentSensorGroup { + /// Group identifier. + pub group_id: String, + /// Member identifiers. + pub members: Vec, + /// Maximum tolerated time error, ns. + pub maximum_time_error_ns: f64, + /// Maximum tolerated phase error, rad. + pub maximum_phase_error_rad: f64, + /// Required baseline geometry hash (all members must match). + pub baseline_geometry_hash: u64, +} + +impl CoherentSensorGroup { + /// Fail-closed fusion gate: every group member must report, be within + /// time and phase bounds, and match the baseline geometry hash. + /// Unknown reporters, missing members, or any out-of-bounds member + /// deny fusion with a typed error. + pub fn can_fuse(&self, states: &[MemberSyncState]) -> Result<()> { + for member in &self.members { + let Some(s) = states.iter().find(|s| &s.member_id == member) else { + return Err(UnifiedError::PolicyDenied(format!( + "coherent fusion denied: member {member:?} did not report sync state" + ))); + }; + if !s.time_error_ns.is_finite() || s.time_error_ns.abs() > self.maximum_time_error_ns { + return Err(UnifiedError::PolicyDenied(format!( + "coherent fusion denied: {member:?} time error {} ns exceeds {} ns", + s.time_error_ns, self.maximum_time_error_ns + ))); + } + if !s.phase_error_rad.is_finite() + || s.phase_error_rad.abs() > self.maximum_phase_error_rad + { + return Err(UnifiedError::PolicyDenied(format!( + "coherent fusion denied: {member:?} phase error {} rad exceeds {} rad", + s.phase_error_rad, self.maximum_phase_error_rad + ))); + } + if s.geometry_hash != self.baseline_geometry_hash { + return Err(UnifiedError::PolicyDenied(format!( + "coherent fusion denied: {member:?} geometry hash mismatch" + ))); + } + } + for s in states { + if !self.members.contains(&s.member_id) { + return Err(UnifiedError::PolicyDenied(format!( + "coherent fusion denied: {:?} is not a group member", + s.member_id + ))); + } + } + Ok(()) + } +} + +/// Kind of radio-environment actuator. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum ActuatorKind { + /// Reconfigurable intelligent surface. + Ris, + /// Mechanically movable antenna. + MovableAntenna, + /// Fluid antenna. + FluidAntenna, +} + +/// A programmable radio-environment actuator (ADR-280 §6). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FieldActuator { + /// Actuator identifier. + pub actuator_id: String, + /// Kind. + pub kind: ActuatorKind, + /// Pose in the building frame. + pub pose_m: [f64; 3], + /// Named states the actuator supports. + pub supported_states: Vec, + /// Zone whose observability this actuator changes. + pub affected_zone_id: String, +} + +/// Audit receipt for an applied actuation. Constructed only by +/// [`request_actuation`] — there is no other way to obtain one, so every +/// state change that alters observability is policy-checked and logged. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ActuationReceipt { + /// State that was requested. + pub requested_state: String, + /// State actually applied. + pub applied_state: String, + /// Application time, ns. + pub applied_ns: u64, + /// Controller identity. + pub controller_id: String, + /// Purpose under which the actuation was authorized. + pub purpose: SensingPurpose, +} + +/// Requests an actuator state change. Denied unless (a) the actuator +/// supports the state and (b) the affected zone grants the purpose under +/// the ADR-277 engine — changing an RIS configuration can change *which +/// rooms and people are observable*, so it is governed like sensing. +pub fn request_actuation( + engine: &PolicyEngine, + actuator: &FieldActuator, + state: &str, + purpose: SensingPurpose, + controller_id: &str, + now_ns: u64, +) -> Result { + if !actuator.supported_states.iter().any(|s| s == state) { + return Err(UnifiedError::InvalidInput(format!( + "actuator {:?} does not support state {state:?}", + actuator.actuator_id + ))); + } + engine.authorize(&actuator.affected_zone_id, purpose)?; + Ok(ActuationReceipt { + requested_state: state.to_string(), + applied_state: state.to_string(), + applied_ns: now_ns, + controller_id: controller_id.to_string(), + purpose, + }) +} + +/// A task-scoped semantic compression of observations (ADR-280 §7): +/// transmit only the information the current physical task needs. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TaskSufficientRepresentation { + /// Task this representation serves. + pub task_id: u128, + /// Source frame receipt ids (lineage). + pub source_receipts: Vec, + /// Compressed semantic state. + pub semantic_state: Vec, + /// Claimed information bound, bits. + pub information_bound_bits: f64, + /// Information classes *explicitly* excluded (e.g. `"identity"`, + /// `"vitals"`, `"trajectory-history"`). + pub excluded_information: Vec, + /// Privacy class of the representation. + pub privacy_class: PrivacyClass, +} + +/// Purpose-scoped leakage validation: compression must remain task +/// scoped. A representation sufficient for anonymous occupancy must not +/// retain identity information; each purpose has a privacy-class ceiling +/// and a set of information classes it must exclude. +pub fn validate_representation( + rep: &TaskSufficientRepresentation, + purpose: SensingPurpose, +) -> Result<()> { + let (ceiling, must_exclude): (PrivacyClass, &[&str]) = match purpose { + SensingPurpose::Presence | SensingPurpose::ChannelDiagnostics => { + (PrivacyClass::P2, &["identity", "vitals"]) + } + SensingPurpose::Activity | SensingPurpose::Localization => { + (PrivacyClass::P3, &["identity"]) + } + SensingPurpose::Vitals | SensingPurpose::PoseTracking => (PrivacyClass::P4, &["identity"]), + SensingPurpose::IdentityRecognition => (PrivacyClass::P5, &[]), + }; + if rep.privacy_class > ceiling { + return Err(UnifiedError::PolicyDenied(format!( + "representation class {:?} exceeds ceiling {ceiling:?} for purpose {purpose:?}", + rep.privacy_class + ))); + } + for class in must_exclude { + if !rep.excluded_information.iter().any(|e| e == class) { + return Err(UnifiedError::PolicyDenied(format!( + "purpose {purpose:?} requires the representation to explicitly exclude {class:?}" + ))); + } + } + if rep.source_receipts.is_empty() { + return Err(UnifiedError::InvalidInput( + "task-sufficient representation must carry source lineage".into(), + )); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::policy::PrivacyZone; + + fn zone(id: &str) -> SpatialZone { + SpatialZone { id: id.into(), min_m: [0.0; 3], max_m: [5.0, 4.0, 3.0] } + } + + fn engine_with(purposes: &[SensingPurpose]) -> PolicyEngine { + let mut e = PolicyEngine::new(); + e.upsert_zone(PrivacyZone { + id: "lab".into(), + allowed_purposes: purposes.iter().copied().collect(), + retention_s: 3600, + identity_explicitly_enabled: false, + }); + e + } + + fn task(purpose: SensingPurpose, raw_export: bool) -> SensingTask { + SensingTask { + task_id: 1, + purpose, + target_area: zone("lab"), + modalities: vec![RfModality::WifiCsi], + requested_resolution_m: 0.5, + maximum_latency_ms: 100, + minimum_confidence: 0.8, + raw_retention_seconds: 60, + result_retention_seconds: 3600, + authorized_consumers: vec!["ha-bridge".into()], + consent_reference: None, + raw_export_allowed: raw_export, + } + } + + #[test] + fn task_admission_is_fail_closed() { + let engine = engine_with(&[SensingPurpose::Presence]); + assert!(admit_task(&engine, &task(SensingPurpose::Presence, false)).is_ok()); + // Raw export is refused regardless of any other grant. + assert!(matches!( + admit_task(&engine, &task(SensingPurpose::Presence, true)), + Err(UnifiedError::PolicyDenied(_)) + )); + // Ungranted purpose denied. + assert!(admit_task(&engine, &task(SensingPurpose::Localization, false)).is_err()); + // Identity without consent denied before even reaching the zone check. + assert!(admit_task(&engine, &task(SensingPurpose::IdentityRecognition, false)).is_err()); + } + + #[test] + fn planner_prioritizes_stale_critical_regions() { + let mut planner = ActiveSensingPlanner::new(0.01); + let mk = |id: &str, change: f64, crit: f64, cost: f64| SpatialStateFreshness { + region_id: id.into(), + region: zone(id), + last_observed_ns: 0, + expected_change_rate: change, + uncertainty_growth_rate: 0.05, + business_criticality: crit, + sensing_cost: cost, + }; + planner.upsert_region(mk("server-room", 0.1, 5.0, 1.0)); + planner.upsert_region(mk("emergency-exit", 0.5, 8.0, 1.0)); + planner.upsert_region(mk("storage", 0.01, 0.5, 1.0)); + + let now = 10_000_000_000; // 10 s of staleness everywhere + let action = planner.next_action(now, RfModality::WifiCsi).expect("something stale"); + assert_eq!(action.target_region.id, "emergency-exit", "highest priority wins"); + + // After observing it, the next-highest region is selected. + planner.mark_observed("emergency-exit", now); + let action = planner.next_action(now, RfModality::WifiCsi).expect("next region"); + assert_eq!(action.target_region.id, "server-room"); + } + + #[test] + fn planner_reduces_sensing_traffic_versus_uniform_refresh() { + // 20 regions, one hot (changes often, critical), the rest cold. + let mut planner = ActiveSensingPlanner::new(0.05); + for i in 0..20 { + let hot = i == 0; + planner.upsert_region(SpatialStateFreshness { + region_id: format!("r{i}"), + region: zone("lab"), + last_observed_ns: 0, + expected_change_rate: if hot { 1.0 } else { 0.01 }, + uncertainty_growth_rate: 0.2, + business_criticality: if hot { 5.0 } else { 0.5 }, + sensing_cost: 1.0, + }); + } + // Simulate 100 scheduling ticks, 1 s apart. Uniform refresh would + // sense 20 regions × 100 ticks = 2000 observations; the planner + // senses at most one region per tick and only above threshold. + let mut actions = 0; + for tick in 1..=100u64 { + let now = tick * 1_000_000_000; + if let Some(a) = planner.next_action(now, RfModality::WifiCsi) { + planner.mark_observed(&a.target_region.id, now); + actions += 1; + } + } + let uniform = 20 * 100; + let reduction = 1.0 - actions as f64 / uniform as f64; + println!("AoI planner: {actions} observations vs {uniform} uniform ({reduction:.2} reduction)"); + assert!( + reduction >= 0.70, + "planner must cut sensing traffic by >= 70 % in sparse environments, got {reduction:.2}" + ); + assert!(actions > 0, "the hot region must still be observed"); + } + + #[test] + fn coherent_fusion_fails_closed() { + let group = CoherentSensorGroup { + group_id: "aisle-3".into(), + members: vec!["ap-1".into(), "ap-2".into()], + maximum_time_error_ns: 50.0, + maximum_phase_error_rad: 0.2, + baseline_geometry_hash: 0xBEEF, + }; + let ok = |id: &str| MemberSyncState { + member_id: id.into(), + time_error_ns: 10.0, + phase_error_rad: 0.05, + geometry_hash: 0xBEEF, + }; + assert!(group.can_fuse(&[ok("ap-1"), ok("ap-2")]).is_ok()); + + // Missing member ⇒ deny. + assert!(group.can_fuse(&[ok("ap-1")]).is_err()); + // Clock out of bounds ⇒ deny. + let mut drift = ok("ap-2"); + drift.time_error_ns = 400.0; + assert!(group.can_fuse(&[ok("ap-1"), drift]).is_err()); + // Phase out of bounds ⇒ deny. + let mut phase = ok("ap-2"); + phase.phase_error_rad = 1.0; + assert!(group.can_fuse(&[ok("ap-1"), phase]).is_err()); + // Geometry changed since calibration ⇒ deny. + let mut moved = ok("ap-2"); + moved.geometry_hash = 0xDEAD; + assert!(group.can_fuse(&[ok("ap-1"), moved]).is_err()); + // A non-member reporting in ⇒ deny. + assert!(group.can_fuse(&[ok("ap-1"), ok("ap-2"), ok("rogue")]).is_err()); + } + + #[test] + fn actuation_requires_policy_authorization() { + let engine = engine_with(&[SensingPurpose::Presence]); + let ris = FieldActuator { + actuator_id: "ris-7".into(), + kind: ActuatorKind::Ris, + pose_m: [2.0, 0.0, 2.5], + supported_states: vec!["beam-east".into(), "beam-west".into()], + affected_zone_id: "lab".into(), + }; + // Authorized purpose + supported state ⇒ receipt. + let receipt = + request_actuation(&engine, &ris, "beam-east", SensingPurpose::Presence, "ctl-1", 99) + .expect("authorized actuation"); + assert_eq!(receipt.applied_state, "beam-east"); + assert_eq!(receipt.purpose, SensingPurpose::Presence); + + // Unsupported state ⇒ deny. + assert!(request_actuation(&engine, &ris, "beam-up", SensingPurpose::Presence, "c", 0) + .is_err()); + // Purpose not granted in the affected zone ⇒ deny (an RIS cannot be + // steered to observe a zone for a purpose the zone never granted). + assert!(request_actuation(&engine, &ris, "beam-east", SensingPurpose::Vitals, "c", 0) + .is_err()); + } + + #[test] + fn task_sufficient_representation_is_leakage_checked() { + let rep = |class: PrivacyClass, excluded: &[&str]| TaskSufficientRepresentation { + task_id: 5, + source_receipts: vec![1, 2], + semantic_state: vec![0.1, 0.9], + information_bound_bits: 8.0, + excluded_information: excluded.iter().map(|s| (*s).to_string()).collect(), + privacy_class: class, + }; + // Occupancy-grade representation excluding identity + vitals: fine. + assert!(validate_representation( + &rep(PrivacyClass::P2, &["identity", "vitals"]), + SensingPurpose::Presence + ) + .is_ok()); + // Same purpose but the representation forgot to exclude identity: deny. + assert!(validate_representation( + &rep(PrivacyClass::P2, &["vitals"]), + SensingPurpose::Presence + ) + .is_err()); + // Class above the purpose ceiling: deny. + assert!(validate_representation( + &rep(PrivacyClass::P4, &["identity", "vitals"]), + SensingPurpose::Presence + ) + .is_err()); + // No lineage: deny. + let mut orphan = rep(PrivacyClass::P2, &["identity", "vitals"]); + orphan.source_receipts.clear(); + assert!(validate_representation(&orphan, SensingPurpose::Presence).is_err()); + } +} diff --git a/v2/crates/ruview-unified/src/encoder.rs b/v2/crates/ruview-unified/src/encoder.rs index 21583b1b..78336edd 100644 --- a/v2/crates/ruview-unified/src/encoder.rs +++ b/v2/crates/ruview-unified/src/encoder.rs @@ -27,6 +27,14 @@ use rand_chacha::ChaCha20Rng; use crate::math::{sigmoid, xavier_init}; + +/// Age input transform for the freshness gate (ADR-281 §4, the age-aware +/// CSI recipe): `log(1 + sample_age_ms)` — log-scaling keeps millisecond +/// and multi-second staleness on comparable input scales. +#[must_use] +pub fn age_feature(age_s: f64) -> f64 { + (1.0 + age_s * 1000.0).ln() +} use crate::tokenizer::{position_encoding, RfToken, TokenizedWindow, D_IN, D_POS}; /// Dense row-major matrix with a bias vector (one linear layer). @@ -256,11 +264,12 @@ impl RfEncoder { *v = v.tanh(); } + let age_feat = age_feature(ctx.age_s); let gate: Vec = self .age_w .iter() .zip(&self.age_b) - .map(|(w, b)| sigmoid(w * ctx.age_s + b)) + .map(|(w, b)| sigmoid(w * age_feat + b)) .collect(); let geo = self.wg.forward(&ctx.geometry); diff --git a/v2/crates/ruview-unified/src/eval.rs b/v2/crates/ruview-unified/src/eval.rs index 26b3f12f..29f21bfd 100644 --- a/v2/crates/ruview-unified/src/eval.rs +++ b/v2/crates/ruview-unified/src/eval.rs @@ -32,6 +32,9 @@ pub struct PartitionKey { pub firmware: String, /// Antenna layout identifier. pub layout: String, + /// Capture session identifier (packet-session leakage is as real as + /// room leakage — ADR-279 §4 split manifest). + pub session: String, } /// Which dimension of [`PartitionKey`] a split holds out. @@ -49,6 +52,8 @@ pub enum PartitionDim { Firmware, /// Hold out complete antenna layouts. Layout, + /// Hold out complete capture sessions. + Session, } impl PartitionDim { @@ -60,8 +65,75 @@ impl PartitionDim { Self::Chipset => &k.chipset, Self::Firmware => &k.firmware, Self::Layout => &k.layout, + Self::Session => &k.session, } } + + /// All partition dimensions, for exhaustive manifest checks. + pub const ALL: [Self; 7] = [ + Self::Room, + Self::Day, + Self::Person, + Self::Chipset, + Self::Firmware, + Self::Layout, + Self::Session, + ]; +} + +/// The mandatory split manifest (ADR-279 §4): per-dimension disjointness +/// certificates for a train/test split. A result is only reportable as +/// leakage-resistant along the dimensions this manifest certifies. +#[derive(Debug, Clone)] +pub struct SplitManifest { + /// `(dimension, train∩test == ∅)` for every partition dimension. + pub disjoint: Vec<(PartitionDim, bool)>, +} + +impl SplitManifest { + /// Builds the manifest for an arbitrary index split. + #[must_use] + pub fn build(keys: &[PartitionKey], train: &[usize], test: &[usize]) -> Self { + let disjoint = PartitionDim::ALL + .iter() + .map(|dim| { + let train_vals: BTreeSet<&str> = + train.iter().map(|&i| dim.value(&keys[i])).collect(); + let test_vals: BTreeSet<&str> = + test.iter().map(|&i| dim.value(&keys[i])).collect(); + (*dim, train_vals.is_disjoint(&test_vals)) + }) + .collect(); + Self { disjoint } + } + + /// True iff the given dimension is certified disjoint. + #[must_use] + pub fn is_disjoint(&self, dim: PartitionDim) -> bool { + self.disjoint.iter().any(|(d, ok)| *d == dim && *ok) + } + + /// True iff every dimension is disjoint (the full anti-leakage bar: + /// `train_rooms ∩ test_rooms = ∅` … `train_sessions ∩ test_sessions = ∅`). + #[must_use] + pub fn fully_disjoint(&self) -> bool { + self.disjoint.iter().all(|(_, ok)| *ok) + } +} + +/// Mean per-joint position error (metres) between two joint sets. +/// +/// # Panics +/// If the slices have different lengths or are empty. +#[must_use] +pub fn mpjpe(pred: &[[f64; 3]], truth: &[[f64; 3]]) -> f64 { + assert_eq!(pred.len(), truth.len()); + assert!(!pred.is_empty()); + pred.iter() + .zip(truth) + .map(|(p, t)| ((p[0] - t[0]).powi(2) + (p[1] - t[1]).powi(2) + (p[2] - t[2]).powi(2)).sqrt()) + .sum::() + / pred.len() as f64 } /// A train/test index split with a proof-of-disjointness certificate. @@ -225,6 +297,7 @@ mod tests { chipset: "esp32s3".into(), firmware: "v1.2".into(), layout: "L".into(), + session: format!("{room}-{day}-{person}"), }); } } @@ -253,6 +326,28 @@ mod tests { assert!(!split.verify(&keys), "leak must be detected"); } + #[test] + fn split_manifest_certifies_per_dimension_disjointness() { + let keys = keys(); + let split = StrictSplit::holdout(&keys, PartitionDim::Room, &["room-c"]); + let manifest = SplitManifest::build(&keys, &split.train, &split.test); + // Rooms are disjoint (and sessions, which embed the room)… + assert!(manifest.is_disjoint(PartitionDim::Room)); + assert!(manifest.is_disjoint(PartitionDim::Session)); + // …but people/days/chipsets are shared, and the manifest says so + // instead of letting the split masquerade as fully leakage-free. + assert!(!manifest.is_disjoint(PartitionDim::Person)); + assert!(!manifest.is_disjoint(PartitionDim::Chipset)); + assert!(!manifest.fully_disjoint()); + } + + #[test] + fn mpjpe_basics() { + let a = [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]]; + let b = [[0.0, 0.0, 0.1], [1.0, 0.0, 0.0]]; + assert!((mpjpe(&a, &b) - 0.05).abs() < 1e-12); + } + #[test] fn ece_is_low_for_calibrated_and_high_for_overconfident() { // Calibrated: p = 0.8 predictions that are right 80 % of the time. diff --git a/v2/crates/ruview-unified/src/frame.rs b/v2/crates/ruview-unified/src/frame.rs new file mode 100644 index 00000000..f658fefd --- /dev/null +++ b/v2/crates/ruview-unified/src/frame.rs @@ -0,0 +1,661 @@ +//! Native RF frame contract — `RfFrameV2` (ADR-279). +//! +//! The ADR-273 P1 canonical tensor (`RfTensor`, 56 bins × 8 snapshots) is +//! useful for compatibility, but it must **not** be the authoritative data +//! format: resampling every device into one fixed tensor discards +//! bandwidth, antenna, phase-state, and hardware-specific information. +//! `RfFrameV2` preserves the **native complex tensor** with explicit +//! validity masks, phase state, geometry, calibration, quality, and +//! provenance; the canonical tensor is demoted to a *derived view* +//! ([`RfFrameV2::to_canonical`]) computed on demand and never written back. +//! +//! Required invariants (ADR-279 §2, each enforced by a constructor check or +//! a test): +//! 1. Native complex samples are never overwritten by normalized samples +//! (`to_canonical` takes `&self`; test proves byte-stability). +//! 2. Subcarrier/antenna masks are explicit (`valid_mask`). +//! 3. Phase declares its state: raw, sanitized, calibrated, or unavailable. +//! 4. TX/RX geometry uses one building coordinate frame (`Pose3`). +//! 5. Results retain source frame ids + model version (via `receipt_id`). +//! 6. Synthetic and measured frames can never share a provenance class, +//! and a synthetic frame can never claim evidence above L0. +//! 7. Sample age is carried through the whole inference path. + +use num_complex::Complex64; +use serde::{Deserialize, Serialize}; + +use crate::tensor::{LinkGeometry, RfModality, RfTensor}; +use crate::{Result, UnifiedError}; + +/// Current schema version of [`RfFrameV2`]. +pub const SCHEMA_VERSION: u16 = 2; + +/// A pose in the building coordinate frame (metres, unit quaternion). +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +pub struct Pose3 { + /// Position `[x, y, z]`, metres. + pub position_m: [f64; 3], + /// Orientation quaternion `[w, x, y, z]`. + pub orientation: [f64; 4], +} + +/// One antenna element of an array. +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +pub struct AntennaElement { + /// Element position relative to the device pose, metres. + pub position_m: [f64; 3], + /// Element gain, dBi. + pub gain_dbi: f64, +} + +/// Declared state of the phase axis — consumers must branch on this +/// instead of guessing whether detrending already happened. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum PhaseState { + /// As captured; CFO/STO artifacts present. + Raw, + /// Linear ramp + constant offset removed (ADR-274 stage 3). + Sanitized, + /// Hardware/baseline calibrated upstream. + Calibrated, + /// Magnitude-only capture (e.g. some vendor RSSI/BF reports). + Unavailable, +} + +/// Calibration state carried by every native frame. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct CalibrationState { + /// Phase axis state. + pub phase_state: PhaseState, + /// Whether amplitude gain has been calibrated. + pub gain_calibrated: bool, + /// Oscillator drift, ppm. + pub clock_ppm: f64, + /// Empty-room baseline applied, if any (ADR-135). + pub baseline_id: Option, + /// Calibration confidence in `[0, 1]`. + pub confidence: f64, +} + +/// Front-end quality indicators. +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +pub struct SignalQuality { + /// Received signal strength, dBm. + pub rssi_dbm: f64, + /// Noise floor, dBm. + pub noise_floor_dbm: f64, + /// Fraction of expected packets lost in the capture window `[0, 1]`. + pub packet_loss: f64, + /// Interference score `[0, 1]` (0 = clean). + pub interference: f64, +} + +/// Whether the evidence is measured or synthetic. The two classes can +/// never alias: there is no third variant and no default. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum ProvenanceClass { + /// Captured from real hardware. + Measured, + /// Produced by a simulator/generator (ADR-276). + Synthetic, +} + +/// The public evidence ladder (ADR-282 §4): every capability and every +/// dataset carries exactly one level. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +pub enum EvidenceLevel { + /// Level 0 — simulation only. + L0Simulation, + /// Level 1 — captured replay of real signals. + L1CapturedReplay, + /// Level 2 — controlled laboratory. + L2Lab, + /// Level 3 — held-out room and subject validation. + L3HeldOutValidation, + /// Level 4 — multi-site field pilot. + L4MultisiteField, + /// Level 5 — production operational evidence. + L5Production, +} + +/// Frame provenance: class + evidence level + source identity. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct FrameProvenance { + /// Measured vs synthetic (invariant 6). + pub class: ProvenanceClass, + /// Evidence-ladder level. + pub evidence: EvidenceLevel, + /// Capturing device identifier. + pub device_id: String, + /// Firmware version string. + pub firmware: String, + /// Receipt id linking results back to this frame (invariant 5). + pub receipt_id: u128, +} + +/// Native axes a frame's tensor may be laid out over (delay-Doppler-native +/// modalities such as OTFS ISAC must not be collapsed into scalar motion +/// energy before storage — ADR-281 §3). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum FieldAxis { + /// Sample time. + Time, + /// Subcarrier / frequency. + Frequency, + /// Delay (multipath arrival). + Delay, + /// Doppler shift. + Doppler, + /// Radar range bin. + Range, + /// Azimuth angle. + Azimuth, + /// Elevation angle. + Elevation, + /// Antenna element. + Antenna, + /// Polarization. + Polarization, +} + +/// The authoritative native RF frame (ADR-279 §2). +#[derive(Debug, Clone)] +pub struct RfFrameV2 { + /// Schema version ([`SCHEMA_VERSION`]). + pub schema_version: u16, + /// Unique frame id. + pub frame_id: u128, + /// Capture timestamp, ns since epoch. + pub timestamp_ns: u64, + /// Modality. + pub modality: RfModality, + /// Native axis semantics, one entry per dimension of `native_shape`. + pub axes: Vec, + /// Carrier centre frequency, Hz. + pub centre_frequency_hz: f64, + /// Occupied bandwidth, Hz. + pub bandwidth_hz: f64, + /// Native sample rate along the time-like axis, Hz. + pub sample_rate_hz: f64, + /// Native tensor shape (arbitrary rank), row-major over `native_iq`. + pub native_shape: Vec, + /// Native complex samples — **never overwritten** (invariant 1). + pub native_iq: Vec, + /// Per-sample validity mask (invariant 2), same length as `native_iq`. + pub valid_mask: Vec, + /// Transmitter pose in the building frame, when known. + pub transmitter_pose: Option, + /// Receiver pose in the building frame, when known. + pub receiver_pose: Option, + /// Antenna elements of the capturing array. + pub antenna_geometry: Vec, + /// Age of the capture at hand-off, ns (invariant 7). + pub sample_age_ns: u64, + /// Calibration state (invariant 3). + pub calibration: CalibrationState, + /// Signal quality. + pub quality: SignalQuality, + /// Provenance (invariants 5–6). + pub provenance: FrameProvenance, +} + +impl RfFrameV2 { + /// Validated constructor — the only way to build a native frame. + /// + /// Enforced here: shape/product/mask arity, finite samples on valid + /// positions, positive frequencies, axes rank match, and the + /// provenance-class ⇄ evidence-level consistency rule: + /// `Synthetic ⇒ exactly L0Simulation`, `Measured ⇒ at least + /// L1CapturedReplay` — so synthetic evidence can never masquerade as + /// field evidence, and vice versa (invariant 6). + #[allow(clippy::too_many_arguments)] + pub fn new( + frame_id: u128, + timestamp_ns: u64, + modality: RfModality, + axes: Vec, + centre_frequency_hz: f64, + bandwidth_hz: f64, + sample_rate_hz: f64, + native_shape: Vec, + native_iq: Vec, + valid_mask: Vec, + transmitter_pose: Option, + receiver_pose: Option, + antenna_geometry: Vec, + sample_age_ns: u64, + calibration: CalibrationState, + quality: SignalQuality, + provenance: FrameProvenance, + ) -> Result { + let expected: usize = native_shape.iter().product(); + if native_shape.is_empty() || expected == 0 { + return Err(UnifiedError::ShapeMismatch("empty native shape".into())); + } + if native_iq.len() != expected { + return Err(UnifiedError::ShapeMismatch(format!( + "native_iq has {} samples, shape {:?} implies {expected}", + native_iq.len(), + native_shape + ))); + } + if valid_mask.len() != expected { + return Err(UnifiedError::ShapeMismatch(format!( + "valid_mask has {} entries, expected {expected}", + valid_mask.len() + ))); + } + if axes.len() != native_shape.len() { + return Err(UnifiedError::ShapeMismatch(format!( + "{} axes declared for rank-{} tensor", + axes.len(), + native_shape.len() + ))); + } + if !(centre_frequency_hz.is_finite() + && centre_frequency_hz > 0.0 + && bandwidth_hz.is_finite() + && bandwidth_hz > 0.0 + && sample_rate_hz.is_finite() + && sample_rate_hz > 0.0) + { + return Err(UnifiedError::InvalidInput( + "frequencies and sample rate must be finite and positive".into(), + )); + } + for (z, ok) in native_iq.iter().zip(&valid_mask) { + if *ok && (!z.re.is_finite() || !z.im.is_finite()) { + return Err(UnifiedError::InvalidInput( + "non-finite sample marked valid".into(), + )); + } + } + if !(0.0..=1.0).contains(&calibration.confidence) { + return Err(UnifiedError::InvalidInput( + "calibration confidence must be in [0,1]".into(), + )); + } + match (provenance.class, provenance.evidence) { + (ProvenanceClass::Synthetic, EvidenceLevel::L0Simulation) => {} + (ProvenanceClass::Synthetic, level) => { + return Err(UnifiedError::InvalidInput(format!( + "synthetic frames are L0Simulation by definition, got {level:?}" + ))); + } + (ProvenanceClass::Measured, EvidenceLevel::L0Simulation) => { + return Err(UnifiedError::InvalidInput( + "measured frames cannot claim L0Simulation".into(), + )); + } + (ProvenanceClass::Measured, _) => {} + } + Ok(Self { + schema_version: SCHEMA_VERSION, + frame_id, + timestamp_ns, + modality, + axes, + centre_frequency_hz, + bandwidth_hz, + sample_rate_hz, + native_shape, + native_iq, + valid_mask, + transmitter_pose, + receiver_pose, + antenna_geometry, + sample_age_ns, + calibration, + quality, + provenance, + }) + } + + /// Fraction of valid samples. + #[must_use] + pub fn valid_fraction(&self) -> f64 { + self.valid_mask.iter().filter(|v| **v).count() as f64 / self.valid_mask.len() as f64 + } + + /// Derived compatibility view (ADR-279 §3): projects a rank-3 + /// `(links, bins, snapshots)` native frame into the ADR-274 canonical + /// tensor. Invalid samples are filled by linear interpolation from the + /// nearest valid bins on the same `(link, snapshot)` column before + /// resampling. The native frame is untouched (`&self`). + pub fn to_canonical(&self, links: Vec) -> Result { + if self.native_shape.len() != 3 { + return Err(UnifiedError::ShapeMismatch(format!( + "canonical view needs a rank-3 (links, bins, snapshots) frame, got rank {}", + self.native_shape.len() + ))); + } + let (n_links, n_bins, n_snaps) = + (self.native_shape[0], self.native_shape[1], self.native_shape[2]); + if links.len() != n_links { + return Err(UnifiedError::ShapeMismatch(format!( + "geometry for {} links, frame has {n_links}", + links.len() + ))); + } + // Gap-fill invalid bins per (link, snapshot) column, then hand a + // dense grid to the shared normalization used by every adapter. + let mut grid = ndarray::Array3::zeros((n_links, n_bins, n_snaps)); + for l in 0..n_links { + for s in 0..n_snaps { + let at = |b: usize| l * n_bins * n_snaps + b * n_snaps + s; + let valid: Vec = (0..n_bins).filter(|b| self.valid_mask[at(*b)]).collect(); + if valid.is_empty() { + return Err(UnifiedError::InvalidInput(format!( + "link {l} snapshot {s} has no valid bins" + ))); + } + for b in 0..n_bins { + let v = if self.valid_mask[at(b)] { + self.native_iq[at(b)] + } else { + // Nearest valid neighbors, linear on the complex plane. + let before = valid.iter().rev().find(|x| **x < b); + let after = valid.iter().find(|x| **x > b); + match (before, after) { + (Some(&lo), Some(&hi)) => { + let t = (b - lo) as f64 / (hi - lo) as f64; + self.native_iq[at(lo)] * (1.0 - t) + self.native_iq[at(hi)] * t + } + (Some(&lo), None) => self.native_iq[at(lo)], + (None, Some(&hi)) => self.native_iq[at(hi)], + (None, None) => unreachable!("valid is non-empty"), + } + }; + grid[[l, b, s]] = v; + } + } + } + let uncertainty = { + let snr = self.quality.rssi_dbm - self.quality.noise_floor_dbm; + (1.0 - snr / 40.0).clamp(0.0, 1.0) + }; + crate::adapters::normalize_grid( + self.modality, + grid, + links, + self.centre_frequency_hz, + self.bandwidth_hz, + self.sample_age_ns as f64 / 1e9, + self.timestamp_ns, + self.provenance.device_id.clone(), + (1.0 - self.calibration.clock_ppm / 40.0).clamp(0.0, 1.0), + uncertainty, + matches!(self.calibration.phase_state, PhaseState::Sanitized | PhaseState::Calibrated), + ) + } +} + +/// IEEE P3162-profile synthetic-aperture channel-sounding import +/// (ADR-281 §5): the calibration bridge between measured environments, +/// simulators, and learned RF scene models. +#[derive(Debug, Clone)] +pub struct SyntheticApertureSoundingDataset { + /// Sounded frequency range `[low, high]`, Hz. + pub frequency_range_hz: [f64; 2], + /// Aperture element poses (building frame). + pub aperture_geometry: Vec, + /// Directional power-delay profile, `(direction, delay)` row-major. + pub directional_pdp: Vec, + /// PDP shape. + pub pdp_shape: [usize; 2], + /// Coordinate system identifier (P3162 vocabulary). + pub coordinate_system: String, + /// Hash of the processing manifest that produced the dataset. + pub processing_manifest_hash: u64, +} + +impl SyntheticApertureSoundingDataset { + /// Validated constructor. + pub fn new( + frequency_range_hz: [f64; 2], + aperture_geometry: Vec, + directional_pdp: Vec, + pdp_shape: [usize; 2], + coordinate_system: impl Into, + processing_manifest_hash: u64, + ) -> Result { + if !(frequency_range_hz[0] > 0.0 && frequency_range_hz[1] > frequency_range_hz[0]) { + return Err(UnifiedError::InvalidInput(format!( + "frequency range must be ordered and positive, got {frequency_range_hz:?}" + ))); + } + if aperture_geometry.is_empty() { + return Err(UnifiedError::InvalidInput("empty aperture geometry".into())); + } + if directional_pdp.len() != pdp_shape[0] * pdp_shape[1] { + return Err(UnifiedError::ShapeMismatch(format!( + "PDP has {} entries, shape {pdp_shape:?} implies {}", + directional_pdp.len(), + pdp_shape[0] * pdp_shape[1] + ))); + } + if directional_pdp.iter().any(|v| !v.is_finite() || *v < 0.0) { + return Err(UnifiedError::InvalidInput("PDP entries must be finite power".into())); + } + Ok(Self { + frequency_range_hz, + aperture_geometry, + directional_pdp, + pdp_shape, + coordinate_system: coordinate_system.into(), + processing_manifest_hash, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::tensor::{CANONICAL_BINS, CANONICAL_SNAPSHOTS}; + + fn quality() -> SignalQuality { + SignalQuality { rssi_dbm: -45.0, noise_floor_dbm: -92.0, packet_loss: 0.02, interference: 0.05 } + } + + fn calibration(phase: PhaseState) -> CalibrationState { + CalibrationState { + phase_state: phase, + gain_calibrated: false, + clock_ppm: 12.0, + baseline_id: None, + confidence: 0.8, + } + } + + fn provenance(class: ProvenanceClass, evidence: EvidenceLevel) -> FrameProvenance { + FrameProvenance { + class, + evidence, + device_id: "esp32s3-a1".into(), + firmware: "fw-2.1".into(), + receipt_id: 42, + } + } + + fn frame(shape: Vec, mask_off: &[usize]) -> RfFrameV2 { + let n: usize = shape.iter().product(); + let iq: Vec = (0..n) + .map(|i| Complex64::new(1.0 + 0.01 * (i % 13) as f64, 0.002 * (i % 7) as f64)) + .collect(); + let mut mask = vec![true; n]; + for &i in mask_off { + mask[i] = false; + } + RfFrameV2::new( + 7, + 1_000, + RfModality::WifiCsi, + vec![FieldAxis::Antenna, FieldAxis::Frequency, FieldAxis::Time], + 2.437e9, + 20e6, + 100.0, + shape, + iq, + mask, + Some(Pose3 { position_m: [0.0, 0.0, 2.0], orientation: [1.0, 0.0, 0.0, 0.0] }), + Some(Pose3 { position_m: [4.0, 0.0, 2.0], orientation: [1.0, 0.0, 0.0, 0.0] }), + vec![AntennaElement { position_m: [0.0; 3], gain_dbi: 2.0 }], + 5_000_000, + calibration(PhaseState::Raw), + quality(), + provenance(ProvenanceClass::Measured, EvidenceLevel::L2Lab), + ) + .expect("valid frame") + } + + #[test] + fn synthetic_and_measured_provenance_can_never_alias() { + let build = |class, evidence| { + RfFrameV2::new( + 1, + 0, + RfModality::Synthetic, + vec![FieldAxis::Frequency], + 2.4e9, + 20e6, + 100.0, + vec![4], + vec![Complex64::new(1.0, 0.0); 4], + vec![true; 4], + None, + None, + vec![], + 0, + calibration(PhaseState::Sanitized), + quality(), + provenance(class, evidence), + ) + }; + // Synthetic above L0 is refused. + assert!(build(ProvenanceClass::Synthetic, EvidenceLevel::L3HeldOutValidation).is_err()); + // Measured claiming L0 is refused. + assert!(build(ProvenanceClass::Measured, EvidenceLevel::L0Simulation).is_err()); + // The two legal pairings work. + assert!(build(ProvenanceClass::Synthetic, EvidenceLevel::L0Simulation).is_ok()); + assert!(build(ProvenanceClass::Measured, EvidenceLevel::L1CapturedReplay).is_ok()); + } + + #[test] + fn constructor_enforces_shape_mask_and_axes_arity() { + let n = 2 * 10 * 4; + let iq = vec![Complex64::new(1.0, 0.0); n]; + let bad_mask = RfFrameV2::new( + 1, + 0, + RfModality::WifiCsi, + vec![FieldAxis::Antenna, FieldAxis::Frequency, FieldAxis::Time], + 2.4e9, + 20e6, + 100.0, + vec![2, 10, 4], + iq.clone(), + vec![true; n - 1], + None, + None, + vec![], + 0, + calibration(PhaseState::Raw), + quality(), + provenance(ProvenanceClass::Measured, EvidenceLevel::L2Lab), + ); + assert!(matches!(bad_mask, Err(UnifiedError::ShapeMismatch(_)))); + + let bad_axes = RfFrameV2::new( + 1, + 0, + RfModality::WifiCsi, + vec![FieldAxis::Frequency], + 2.4e9, + 20e6, + 100.0, + vec![2, 10, 4], + iq, + vec![true; n], + None, + None, + vec![], + 0, + calibration(PhaseState::Raw), + quality(), + provenance(ProvenanceClass::Measured, EvidenceLevel::L2Lab), + ); + assert!(matches!(bad_axes, Err(UnifiedError::ShapeMismatch(_)))); + } + + #[test] + fn canonical_view_is_derived_and_native_is_untouched() { + // 114-subcarrier native with two masked-out bins. + let f = frame(vec![1, 114, 12], &[5 * 12, 60 * 12 + 3]); + let native_before = f.native_iq.clone(); + let mask_before = f.valid_mask.clone(); + + let t = f + .to_canonical(vec![LinkGeometry { tx_pos: [0.0, 0.0, 2.0], rx_pos: [4.0, 0.0, 2.0] }]) + .expect("derived view"); + assert_eq!(t.dims(), (1, CANONICAL_BINS, CANONICAL_SNAPSHOTS)); + assert!(t.data.iter().all(|z| z.re.is_finite() && z.im.is_finite())); + + // Invariant 1: the native samples and mask are byte-identical after + // deriving the view — normalization never writes back. + assert_eq!(f.native_iq, native_before); + assert_eq!(f.valid_mask, mask_before); + assert!((f.valid_fraction() - (114.0 * 12.0 - 2.0) / (114.0 * 12.0)).abs() < 1e-12); + } + + #[test] + fn canonical_view_rejects_wrong_rank_or_geometry() { + let f = frame(vec![2, 10, 4], &[]); + assert!(f.to_canonical(vec![]).is_err()); + // Rank-1 frame has no canonical projection. + let flat = RfFrameV2::new( + 9, + 0, + RfModality::WifiCsi, + vec![FieldAxis::Frequency], + 2.4e9, + 20e6, + 100.0, + vec![80], + vec![Complex64::new(1.0, 0.0); 80], + vec![true; 80], + None, + None, + vec![], + 0, + calibration(PhaseState::Raw), + quality(), + provenance(ProvenanceClass::Measured, EvidenceLevel::L2Lab), + ) + .expect("rank-1 frame is a valid native frame"); + assert!(flat + .to_canonical(vec![LinkGeometry { tx_pos: [0.0; 3], rx_pos: [1.0, 0.0, 0.0] }]) + .is_err()); + } + + #[test] + fn synthetic_aperture_profile_validates() { + let ok = SyntheticApertureSoundingDataset::new( + [3.0e9, 10.0e9], + vec![Pose3 { position_m: [0.0; 3], orientation: [1.0, 0.0, 0.0, 0.0] }], + vec![0.5; 8 * 16], + [8, 16], + "P3162-spherical", + 0xABCD, + ); + assert!(ok.is_ok()); + assert!(SyntheticApertureSoundingDataset::new( + [10.0e9, 3.0e9], // unordered + vec![Pose3 { position_m: [0.0; 3], orientation: [1.0, 0.0, 0.0, 0.0] }], + vec![0.5; 4], + [2, 2], + "x", + 0 + ) + .is_err()); + } +} diff --git a/v2/crates/ruview-unified/src/gaussian/map.rs b/v2/crates/ruview-unified/src/gaussian/map.rs index 032c41b2..c64da11e 100644 --- a/v2/crates/ruview-unified/src/gaussian/map.rs +++ b/v2/crates/ruview-unified/src/gaussian/map.rs @@ -134,12 +134,21 @@ impl GaussianMap { } } e.doppler_mps = (wa * e.doppler_mps + wb * g.doppler_mps) / wsum; + e.doppler_variance = (wa * e.doppler_variance + wb * g.doppler_variance) / wsum; e.confidence = (wa + wb - wa * wb).clamp(0.0, 1.0); + e.first_seen_ns = e.first_seen_ns.min(g.first_seen_ns); if g.timestamp_ns >= e.timestamp_ns { e.timestamp_ns = g.timestamp_ns; e.provenance = g.provenance; e.motion = g.motion; } + for r in g.source_receipts { + if !e.source_receipts.contains(&r) + && e.source_receipts.len() < super::primitive::MAX_SOURCE_RECEIPTS + { + e.source_receipts.push(r); + } + } for link in g.links { if !e.links.contains(&link) { e.links.push(link); diff --git a/v2/crates/ruview-unified/src/gaussian/primitive.rs b/v2/crates/ruview-unified/src/gaussian/primitive.rs index 9bee761e..74c91bc7 100644 --- a/v2/crates/ruview-unified/src/gaussian/primitive.rs +++ b/v2/crates/ruview-unified/src/gaussian/primitive.rs @@ -107,20 +107,31 @@ pub struct RfGaussian { pub reflectivity: [[f64; ANGLE_BINS]; Band::COUNT], /// Radial velocity estimate, m/s (signed). pub doppler_mps: f64, + /// Doppler estimate variance, (m/s)² (ADR-281 lifecycle fields). + pub doppler_variance: f64, /// Coarse motion class. pub motion: MotionState, /// Confidence in `[0, 1]`. pub confidence: f64, + /// First-observed timestamp, ns since epoch (static structure is + /// distinguishable from transients by lifetime, not just decay τ). + pub first_seen_ns: u64, /// 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, + /// Receipt ids of the source frames that shaped this Gaussian + /// (measurement→inference lineage; capped on fusion). + pub source_receipts: Vec, /// Links into the scene graph / RuVector entities. pub links: Vec, } +/// Maximum receipts retained per Gaussian after fusion (bounded lineage). +pub const MAX_SOURCE_RECEIPTS: usize = 16; + impl RfGaussian { /// Validated constructor: normalizes the quaternion and range-checks /// every numeric field (boundary rule — the map assumes validity). @@ -175,11 +186,14 @@ impl RfGaussian { semantic: [0.0; SEMANTIC_DIM], reflectivity: [[0.0; ANGLE_BINS]; Band::COUNT], doppler_mps: 0.0, + doppler_variance: 0.0, motion: MotionState::Static, confidence, + first_seen_ns: timestamp_ns, timestamp_ns, decay_tau_s, provenance, + source_receipts: Vec::new(), links: Vec::new(), }) } diff --git a/v2/crates/ruview-unified/src/heads.rs b/v2/crates/ruview-unified/src/heads.rs index 6a409661..0606a8df 100644 --- a/v2/crates/ruview-unified/src/heads.rs +++ b/v2/crates/ruview-unified/src/heads.rs @@ -253,6 +253,237 @@ impl LocalizationHead { } } +/// Number of skeleton joints (COCO-17 convention, matching the ruvsense +/// pose tracker). +pub const NUM_JOINTS: usize = 17; + +/// Generic low-rank linear regressor `y = U·(V·x) + b` — the shared +/// building block for structured heads that must stay inside the adapter +/// budget. +#[derive(Debug, Clone)] +pub struct LowRankLinear { + /// Output dimension. + pub out: usize, + /// Rank. + pub rank: usize, + d_in: usize, + /// `out × rank`, row-major. + pub u: Vec, + /// `rank × d_in`, row-major. + pub v: Vec, + /// Bias, length `out`. + pub b: Vec, +} + +impl LowRankLinear { + /// Deterministic small-value init (breaks U/V symmetry without RNG). + #[must_use] + pub fn new(d_in: usize, out: usize, rank: usize) -> Self { + let u = (0..out * rank).map(|i| 0.01 * ((i % 7) as f64 - 3.0)).collect(); + let v = (0..rank * d_in).map(|i| 0.01 * ((i % 5) as f64 - 2.0)).collect(); + Self { out, rank, d_in, u, v, b: vec![0.0; out] } + } + + /// Parameter count. + #[must_use] + pub fn param_count(&self) -> usize { + self.u.len() + self.v.len() + self.b.len() + } + + /// Prediction. + #[must_use] + pub fn predict(&self, x: &[f64]) -> Vec { + let mut vx = vec![0.0; self.rank]; + for r in 0..self.rank { + let row = &self.v[r * self.d_in..(r + 1) * self.d_in]; + vx[r] = row.iter().zip(x).map(|(a, b)| a * b).sum(); + } + let mut y = self.b.clone(); + for o in 0..self.out { + let row = &self.u[o * self.rank..(o + 1) * self.rank]; + y[o] += row.iter().zip(&vx).map(|(a, b)| a * b).sum::(); + } + y + } + + /// Full-batch MSE training; returns the final mean squared error. + pub fn train(&mut self, xs: &[Vec], ys: &[Vec], lr: f64, epochs: usize) -> f64 { + assert_eq!(xs.len(), ys.len()); + let n = xs.len() as f64; + let mut final_mse = 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 mse = 0.0; + for (x, y) in xs.iter().zip(ys) { + let mut vx = vec![0.0; self.rank]; + for r in 0..self.rank { + let row = &self.v[r * self.d_in..(r + 1) * self.d_in]; + vx[r] = row.iter().zip(x).map(|(a, b)| a * b).sum(); + } + let mut dvx = vec![0.0; self.rank]; + for o in 0..self.out { + let row = &self.u[o * self.rank..(o + 1) * self.rank]; + let pred = self.b[o] + + row.iter().zip(&vx).map(|(a, b)| a * b).sum::(); + let err = pred - y[o]; + mse += err * err; + let scale = 2.0 * err / (self.out as f64 * n); + gb[o] += scale; + for r in 0..self.rank { + gu[o * self.rank + r] += scale * vx[r]; + dvx[r] += scale * self.u[o * self.rank + r]; + } + } + for r in 0..self.rank { + for (d, xv) in x.iter().enumerate() { + gv[r * self.d_in + d] += dvx[r] * xv; + } + } + } + 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_mse = mse / (self.out as f64 * n); + } + final_mse + } +} + +/// Factorized pose estimate (ADR-281 §4, the RePos lesson): root-relative +/// skeleton and absolute root are separate quantities with separate +/// uncertainties. +#[derive(Debug, Clone)] +pub struct PoseOutput { + /// Root-relative joint positions, metres. + pub relative_joints_m: [[f64; 3]; NUM_JOINTS], + /// Absolute root position, metres, building frame. + pub root_position_m: [f64; 3], + /// Per-joint 1σ uncertainty, metres (calibrated from training residuals). + pub joint_uncertainty_m: [f64; NUM_JOINTS], + /// Root 1σ uncertainty, metres. + pub root_uncertainty_m: f64, +} + +impl PoseOutput { + /// Absolute joints: `root + relative` (the RePos composition). + #[must_use] + pub fn absolute_joints_m(&self) -> [[f64; 3]; NUM_JOINTS] { + let mut out = self.relative_joints_m; + for j in out.iter_mut() { + for k in 0..3 { + j[k] += self.root_position_m[k]; + } + } + out + } +} + +/// Factorized pose head: the **relative skeleton** branch reads the +/// environment-invariant content representation (so it cannot learn +/// room-specific position shortcuts), while the **root localization** +/// branch reads the geometry-conditioned representation (where sensor +/// pose is signal). This is the anti-leakage factorization measured in +/// `tests::factorized_pose_resists_room_shortcut_leakage`. +#[derive(Debug, Clone)] +pub struct FactorizedPoseHead { + /// Relative-skeleton regressor on the content representation. + pub relative: LowRankLinear, + /// Root regressor on the geometry-conditioned representation. + pub root: LowRankLinear, + /// Calibrated per-joint residual σ, metres. + pub joint_residual_std_m: [f64; NUM_JOINTS], + /// Calibrated root residual σ, metres. + pub root_residual_std_m: f64, +} + +impl FactorizedPoseHead { + /// New head for the given representation dims and rank. + #[must_use] + pub fn new(d_content: usize, d_full: usize, rank: usize) -> Self { + Self { + relative: LowRankLinear::new(d_content, NUM_JOINTS * 3, rank), + root: LowRankLinear::new(d_full, 3, rank), + joint_residual_std_m: [f64::INFINITY; NUM_JOINTS], + root_residual_std_m: f64::INFINITY, + } + } + + /// Parameter count (both branches + calibration statistics). + #[must_use] + pub fn param_count(&self) -> usize { + self.relative.param_count() + self.root.param_count() + NUM_JOINTS + 1 + } + + /// Trains both branches and calibrates residual uncertainties. + /// Returns `(relative_mse, root_mse)` in m². + pub fn train( + &mut self, + content_zs: &[Vec], + full_zs: &[Vec], + relative_targets: &[[[f64; 3]; NUM_JOINTS]], + root_targets: &[[f64; 3]], + lr: f64, + epochs: usize, + ) -> (f64, f64) { + let rel_flat: Vec> = relative_targets + .iter() + .map(|j| j.iter().flatten().copied().collect()) + .collect(); + let root_flat: Vec> = root_targets.iter().map(|r| r.to_vec()).collect(); + let rel_mse = self.relative.train(content_zs, &rel_flat, lr, epochs); + let root_mse = self.root.train(full_zs, &root_flat, lr, epochs); + + // Calibrate per-joint residual σ on the training set. + let n = content_zs.len().max(1) as f64; + let mut joint_sq = [0.0f64; NUM_JOINTS]; + let mut root_sq = 0.0; + for i in 0..content_zs.len() { + let rel = self.relative.predict(&content_zs[i]); + for j in 0..NUM_JOINTS { + let mut d2 = 0.0; + for k in 0..3 { + d2 += (rel[j * 3 + k] - relative_targets[i][j][k]).powi(2); + } + joint_sq[j] += d2; + } + let root = self.root.predict(&full_zs[i]); + root_sq += (0..3).map(|k| (root[k] - root_targets[i][k]).powi(2)).sum::(); + } + for j in 0..NUM_JOINTS { + self.joint_residual_std_m[j] = (joint_sq[j] / n).sqrt(); + } + self.root_residual_std_m = (root_sq / n).sqrt(); + (rel_mse, root_mse) + } + + /// Predicts a factorized pose with calibrated uncertainties. + #[must_use] + pub fn predict(&self, content_z: &[f64], full_z: &[f64]) -> PoseOutput { + let rel = self.relative.predict(content_z); + let root = self.root.predict(full_z); + let mut relative_joints_m = [[0.0; 3]; NUM_JOINTS]; + for j in 0..NUM_JOINTS { + for k in 0..3 { + relative_joints_m[j][k] = rel[j * 3 + k]; + } + } + PoseOutput { + relative_joints_m, + root_position_m: [root[0], root[1], root[2]], + joint_uncertainty_m: self.joint_residual_std_m, + root_uncertainty_m: self.root_residual_std_m, + } + } +} + /// Anomaly head: z-scores the encoder's masked-reconstruction error against /// a calibration distribution of *normal* windows. No learned weights — /// two calibration statistics. @@ -319,13 +550,128 @@ mod tests { backbone / 100 ); } + // The structured pose head is the largest adapter; its documented + // budget is < 2 % of the backbone (ADR-281 §4). + let pose = FactorizedPoseHead::new(enc.content_dim(), d, 2).param_count(); + assert!(pose * 100 < backbone * 2, "pose head {pose} params vs 2 % of {backbone}"); println!( "backbone {backbone} params; heads: presence {presence}, activity {activity}, \ - localization {localization}, anomaly {anomaly} (budget < {})", + localization {localization}, anomaly {anomaly} (budget < {}), pose {pose} (< 2 %)", backbone / 100 ); } + /// The RePos leakage experiment: in the training rooms, room position + /// correlates with body scale (small people in room A, tall in room B). + /// A monolithic absolute-pose head exploits the room feature as a + /// shortcut and collapses in an unseen room that breaks the + /// correlation; the factorized head's skeleton branch never sees room + /// features and generalizes. + #[test] + fn factorized_pose_resists_room_shortcut_leakage() { + use crate::eval::mpjpe; + + // 17 fixed joint directions (deterministic). + let dirs: Vec<[f64; 3]> = (0..NUM_JOINTS) + .map(|j| { + let a = j as f64 * 0.37; + [a.cos() * 0.3, a.sin() * 0.3, 0.1 * ((j % 5) as f64 - 2.0)] + }) + .collect(); + let skeleton = |scale: f64| { + let mut joints = [[0.0; 3]; NUM_JOINTS]; + for (j, d) in dirs.iter().enumerate() { + for k in 0..3 { + joints[j][k] = d[k] * (1.0 + 0.5 * scale); + } + } + joints + }; + // content z = [scale, 1]; full z = [scale, room_x/3, 1]. + let sample = |scale: f64, room_x: f64| { + let content = vec![scale, 1.0]; + let full = vec![scale, room_x / 3.0, 1.0]; + let root = [room_x + 0.5, 2.0, 1.0]; + (content, full, skeleton(scale), root) + }; + + // Training: room A (x=0) only small scales, room B (x=3) only large — + // the leakage trap. + let mut content_zs = Vec::new(); + let mut full_zs = Vec::new(); + let mut rels = Vec::new(); + let mut roots = Vec::new(); + for i in 0..30 { + let s = -1.0 + i as f64 / 30.0; // [-1, 0) + let (c, f, r, ro) = sample(s, 0.0); + content_zs.push(c); + full_zs.push(f); + rels.push(r); + roots.push(ro); + let s = i as f64 / 30.0; // [0, 1) + let (c, f, r, ro) = sample(s, 3.0); + content_zs.push(c); + full_zs.push(f); + rels.push(r); + roots.push(ro); + } + + let mut head = FactorizedPoseHead::new(2, 3, 2); + let (rel_mse, root_mse) = head.train(&content_zs, &full_zs, &rels, &roots, 0.3, 3000); + assert!(rel_mse < 1e-3, "relative branch must fit, mse {rel_mse}"); + assert!(root_mse < 1e-3, "root branch must fit, mse {root_mse}"); + + // Monolithic baseline: absolute joints regressed from the full + // (room-conditioned) representation. + let abs_targets: Vec> = rels + .iter() + .zip(&roots) + .map(|(rel, root)| { + rel.iter().flat_map(|j| (0..3).map(move |k| j[k] + root[k])).collect() + }) + .collect(); + let mut monolithic = LowRankLinear::new(3, NUM_JOINTS * 3, 2); + monolithic.train(&full_zs, &abs_targets, 0.3, 3000); + + // Held-out room (x=6) with the correlation broken: both scales. + let mut fact_err = 0.0; + let mut mono_err = 0.0; + let mut count = 0.0; + for i in 0..20 { + let s = -1.0 + i as f64 / 10.0; // [-1, 1) + let (c, f, rel, root) = sample(s, 6.0); + let truth: Vec<[f64; 3]> = + rel.iter().zip(std::iter::repeat(root)).map(|(j, r)| { + [j[0] + r[0], j[1] + r[1], j[2] + r[2]] + }).collect(); + + let pose = head.predict(&c, &f); + let fact_abs = pose.absolute_joints_m(); + fact_err += mpjpe(&fact_abs, &truth); + + let mono = monolithic.predict(&f); + let mono_abs: Vec<[f64; 3]> = (0..NUM_JOINTS) + .map(|j| [mono[j * 3], mono[j * 3 + 1], mono[j * 3 + 2]]) + .collect(); + mono_err += mpjpe(&mono_abs, &truth); + count += 1.0; + + // ADR-273 acceptance item: every output carries uncertainty. + assert!(pose.root_uncertainty_m.is_finite()); + assert!(pose.joint_uncertainty_m.iter().all(|u| u.is_finite() && *u >= 0.0)); + } + fact_err /= count; + mono_err /= count; + println!( + "held-out room MPJPE: factorized {fact_err:.4} m vs monolithic {mono_err:.4} m" + ); + assert!(fact_err < 0.15, "factorized head must generalize, MPJPE {fact_err}"); + assert!( + mono_err > 1.5 * fact_err, + "monolithic head must show the shortcut collapse: {mono_err} vs {fact_err}" + ); + } + fn separable_data(n: usize, d: usize) -> (Vec>, Vec) { let mut zs = Vec::new(); let mut ys = Vec::new(); diff --git a/v2/crates/ruview-unified/src/lib.rs b/v2/crates/ruview-unified/src/lib.rs index 3920f440..42ab1a3d 100644 --- a/v2/crates/ruview-unified/src/lib.rs +++ b/v2/crates/ruview-unified/src/lib.rs @@ -30,9 +30,16 @@ //! [`policy::BoundedEvent`] (uncertainty + provenance + model version + //! purpose, all mandatory) is exportable, and unknown zones/purposes deny. +// The numeric kernels (encoder forward/backward, low-rank heads) index +// several parallel arrays per iteration; index loops are the clearest and +// equally fast form there. +#![allow(clippy::needless_range_loop)] + pub mod adapters; +pub mod control; pub mod encoder; pub mod eval; +pub mod frame; pub mod gaussian; pub mod heads; pub mod math; diff --git a/v2/crates/ruview-unified/src/pretrain.rs b/v2/crates/ruview-unified/src/pretrain.rs index 698be629..dac8b2e1 100644 --- a/v2/crates/ruview-unified/src/pretrain.rs +++ b/v2/crates/ruview-unified/src/pretrain.rs @@ -110,13 +110,15 @@ pub fn masked_loss_and_grads( } loss *= norm; - // Fusion: z = g ⊙ gate + Wg·geo + bg. + // Fusion: z = g ⊙ gate + Wg·geo + bg. The gate input is the + // log-scaled age feature, matching the forward pass. + let age_feat = crate::encoder::age_feature(ctx.age_s); 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_w[k] += dgate * dsig * age_feat; grads.age_b[k] += dgate * dsig; } enc.wg.accumulate_grad(&mut grads.wg, &dz, &ctx.geometry); diff --git a/v2/crates/ruview-unified/src/synth/generator.rs b/v2/crates/ruview-unified/src/synth/generator.rs index 2153e1f7..0b0ba0eb 100644 --- a/v2/crates/ruview-unified/src/synth/generator.rs +++ b/v2/crates/ruview-unified/src/synth/generator.rs @@ -220,6 +220,9 @@ impl SynthGenerator { chipset: hw.chipset.clone(), firmware: hw.firmware.clone(), layout: hw.layout.clone(), + // Windows sharing a start-time slot within a room + // form one capture session. + session: format!("room-{room_idx}-s{}", w % 6), }, }); } diff --git a/v2/crates/ruview-unified/src/tensor.rs b/v2/crates/ruview-unified/src/tensor.rs index feb0d2b0..58322c0a 100644 --- a/v2/crates/ruview-unified/src/tensor.rs +++ b/v2/crates/ruview-unified/src/tensor.rs @@ -28,13 +28,21 @@ pub const CANONICAL_SNAPSHOTS: usize = 8; pub enum RfModality { /// 802.11 Channel State Information (per-subcarrier frequency response). WifiCsi, + /// 802.11 channel impulse response (delay-domain taps). + WifiCir, + /// 802.11 beamforming feedback report (BFI/BFLD path). + WifiBfReport, /// 5G NR uplink Sounding Reference Signal frequency response (O-RAN ISAC path). CellularSrs, /// FMCW radar range profile (post range-FFT complex bins). FmcwRadar, + /// FMCW radar range–azimuth map. + FmcwRangeAzimuth, + /// FMCW radar Doppler–azimuth map. + FmcwDopplerAzimuth, /// Ultra-wideband channel impulse response taps. UwbCir, - /// Bluetooth Channel Sounding tones. + /// Bluetooth Channel Sounding tones (phase-based ranging + RTT). BleCs, /// Output of the ADR-276 synthetic world generator (honest labeling: /// tensors of this modality must never be reported as measured). @@ -204,6 +212,37 @@ impl RfTensor { pub fn wavelength_m(&self) -> f64 { 299_792_458.0 / self.center_freq_hz } + + /// Delay–Doppler magnitude map for one link (ADR-281 §3): IDFT over the + /// frequency axis (→ delay bins) crossed with a DFT over the snapshot + /// axis (→ Doppler bins). Shape `(bins, snapshots)`. + /// + /// Delay-Doppler-native modalities (OTFS ISAC, radar) must not be + /// collapsed into scalar motion energy before storage — this transform + /// keeps the native representation queryable locally. + pub fn delay_doppler_map(&self, link: usize) -> crate::Result> { + let (n_links, n_bins, n_snaps) = self.dims(); + if link >= n_links { + return Err(crate::UnifiedError::DimensionMismatch(format!( + "link {link} out of range ({n_links} links)" + ))); + } + let mut out = ndarray::Array2::zeros((n_bins, n_snaps)); + for d in 0..n_bins { + for v in 0..n_snaps { + let mut acc = Complex64::new(0.0, 0.0); + for b in 0..n_bins { + for s in 0..n_snaps { + let ang = 2.0 * std::f64::consts::PI + * ((b * d) as f64 / n_bins as f64 - (s * v) as f64 / n_snaps as f64); + acc += self.data[[link, b, s]] * Complex64::new(ang.cos(), ang.sin()); + } + } + out[[d, v]] = acc.norm() / (n_bins * n_snaps) as f64; + } + } + Ok(out) + } } #[cfg(test)] @@ -243,6 +282,29 @@ mod tests { assert!((t.links[0].distance_m() - 5.0).abs() < 1e-12); } + #[test] + fn delay_doppler_map_localizes_a_synthetic_target() { + // A scatterer at delay bin 7 with Doppler bin 3: + // H[b,s] = exp(-j2πb·7/B) · exp(+j2πs·3/S) ⇒ single peak at (7, 3). + let (d0, v0) = (7usize, 3usize); + let data = Array3::from_shape_fn((1, CANONICAL_BINS, CANONICAL_SNAPSHOTS), |(_, b, s)| { + let ang = -2.0 * std::f64::consts::PI * (b * d0) as f64 / CANONICAL_BINS as f64 + + 2.0 * std::f64::consts::PI * (s * v0) as f64 / CANONICAL_SNAPSHOTS as f64; + Complex64::new(ang.cos(), ang.sin()) + }); + let t = build(data, vec![link()]).expect("valid tensor"); + let map = t.delay_doppler_map(0).expect("map"); + assert!((map[[d0, v0]] - 1.0).abs() < 1e-9, "peak must be unit at ({d0},{v0})"); + for d in 0..CANONICAL_BINS { + for v in 0..CANONICAL_SNAPSHOTS { + if (d, v) != (d0, v0) { + assert!(map[[d, v]] < 1e-9, "leakage at ({d},{v}): {}", map[[d, v]]); + } + } + } + assert!(t.delay_doppler_map(5).is_err(), "out-of-range link is a typed error"); + } + #[test] fn rejects_geometry_link_mismatch() { assert!(matches!(