From f250149e94148d3f9b6a01a004c074178413cc2c Mon Sep 17 00:00:00 2001 From: rUv Date: Sun, 14 Jun 2026 12:46:58 -0400 Subject: [PATCH] =?UTF-8?q?feat(ADR-262=20P1):=20wifi-densepose-rufield=20?= =?UTF-8?q?bridge=20=E2=80=94=20RuView=20sensing=20=E2=86=92=20signed=20Ru?= =?UTF-8?q?Field=20FieldEvents=20(fail-closed=20privacy=20map)=20(#1070)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(rufield): ADR-262 P1 — wifi-densepose-rufield anti-corruption bridge New v2 workspace member that converts RuView WiFi-CSI sensing output into signed RuField FieldEvents. Path-deps the vendor/rufield submodule crates (rufield-core/-provenance/-privacy/-fusion); single coupling point between RuView and the standalone RuField MFS spec (ADR-262 §5.4). - SensingSnapshot: owned primitives mirroring SensingUpdate + TrustedOutput (no dependency on wifi-densepose-sensing-server). - snapshot_to_field_event(): builds a WifiCsi FieldTensor + Observation, derives a real position from the signal-field peak (never fabricated), real sha256 provenance + ed25519 signature (synthetic=false). - map_privacy() (§3.3 crux): maps by information content, NEVER byte value — Derived (byte 1) → P4/P5, never P1; fail-closed demotion floor to P2. P1 gates (tests/p1_gates.rs): round-trip serde, is_fusable verified receipt, RuFieldFusion::ingest accept + infer runs, privacy-safety (Derived never P1), full §3.3 table, fail-closed demotion, determinism, no-fabricated-position. 15 tests pass (5 unit + 9 integration + 1 doc), 0 failed. Honesty: P1 plumbing (tested conversion + safe privacy mapping), NOT wired into the live server (P3) and NOT an accuracy claim. Co-Authored-By: claude-flow * docs(adr-262): mark P1 implemented + CI submodules:recursive + CHANGELOG/CLAUDE - ADR-262 Status → "Proposed — P1 implemented"; add §0.1 Implementation status (the bridge crate + the five P1 gates that pass; defers the provenance-carrier reuse, P3 live wiring, and P4 multi-modality). - ci.yml: add `submodules: recursive` to the rust-tests checkout so the new crate's `vendor/rufield` path-deps resolve in CI (they fail otherwise even though the workspace build passes locally with the submodule present). - CHANGELOG [Unreleased]: P1 bridge entry (kept alongside the upstream ADR-262 research entry). - CLAUDE.md: crate table row for `wifi-densepose-rufield`. Co-Authored-By: claude-flow --- .github/workflows/ci.yml | 5 + CHANGELOG.md | 1 + CLAUDE.md | 1 + .../adr/ADR-262-rufield-ruview-integration.md | 15 +- v2/Cargo.lock | 48 ++++ v2/Cargo.toml | 5 + v2/crates/wifi-densepose-rufield/Cargo.toml | 26 +++ .../wifi-densepose-rufield/src/bridge.rs | 206 ++++++++++++++++++ v2/crates/wifi-densepose-rufield/src/lib.rs | 85 ++++++++ .../wifi-densepose-rufield/src/privacy.rs | 147 +++++++++++++ .../wifi-densepose-rufield/src/snapshot.rs | 152 +++++++++++++ .../wifi-densepose-rufield/tests/p1_gates.rs | 172 +++++++++++++++ 12 files changed, 862 insertions(+), 1 deletion(-) create mode 100644 v2/crates/wifi-densepose-rufield/Cargo.toml create mode 100644 v2/crates/wifi-densepose-rufield/src/bridge.rs create mode 100644 v2/crates/wifi-densepose-rufield/src/lib.rs create mode 100644 v2/crates/wifi-densepose-rufield/src/privacy.rs create mode 100644 v2/crates/wifi-densepose-rufield/src/snapshot.rs create mode 100644 v2/crates/wifi-densepose-rufield/tests/p1_gates.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6e57c11e..2c79ce45 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -82,6 +82,11 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v4 + # ADR-262 P1: `wifi-densepose-rufield` path-deps the `vendor/rufield` + # submodule. Without a recursive checkout the workspace build fails to + # resolve those path deps in CI even though it passes locally. + with: + submodules: recursive # `wifi-densepose-desktop` is a Tauri v2 app — `glib-sys`, `gtk-sys`, # `webkit2gtk-sys`, etc. need the Linux dev libraries via pkg-config or the diff --git a/CHANGELOG.md b/CHANGELOG.md index fc49ad80..f34d9feb 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 +- **ADR-262 P1 — `wifi-densepose-rufield` anti-corruption bridge: RuView WiFi-CSI sensing → signed RuField `FieldEvent`s.** A new v2 workspace member (the *single coupling point* between RuView and the standalone RuField MFS spec, ADR-262 §5.4) that **path-deps** the `vendor/rufield` submodule crates (`rufield-core`/`-provenance`/`-privacy`/`-fusion` — pure-Rust, `--no-default-features`-buildable: serde/sha2/ed25519/toml only, no tch/openblas/ndarray/candle) and **no** RuView internal crate. The bridge takes owned primitives — `SensingSnapshot` mirrors the `/ws/sensing` `SensingUpdate` (features + classification + signal_field) joined with the `TrustedOutput` trust state (`trust_class`/`demoted`/`identity_bound`) — and `snapshot_to_field_event()` emits one **signed** `FieldEvent` (`Modality::WifiCsi`, axis `[Frequency]`): a real `FieldTensor` from the feature scalars with the real `timestamp_ns`; an `Observation` whose `range_m`/`motion_vector`/`space_cell` are derived from the strongest **signal-field peak** when present (else `None` — coordinates are **never fabricated**, per the `field_localize` caveat) and `confidence` from the classification; a real `ProvenanceRef` (sha256 over the tensor bytes, `synthetic=false`) **ed25519-signed** so `rufield_provenance::is_fusable` passes. **The §3.3 privacy mapping is the critical correctness item**, implemented as `map_privacy()` mapping RuView's class onto RuField P0–P5 **by information content, NEVER by byte value** and **fail-closed**: RuView `Derived` (byte `1`, which sorts *below* `Anonymous` byte `2`) carries an identity embedding → maps to **P4** (or **P5** if identity-bound), **never P1** (the single most dangerous mapping mistake); `Raw → P0`, `Anonymous → P2`, `Restricted → P2`; a governed-engine `demoted` cycle floors the egress class to ≥ P2 with raw suppressed. **P1 acceptance gates (15 tests / 0 failed — 5 unit + 9 integration + 1 doc):** round-trip (`SensingSnapshot → FieldEvent →` serde `→` equal), `is_fusable` (verified ed25519 receipt), `RuFieldFusion::ingest` accept + `infer()` runs, **privacy-safety** (`gate_privacy_safety_derived_never_maps_to_low_privacy` — `Derived → P4/P5`, never P1; a table test over every RuView class; fail-closed demotion), and determinism (same snapshot + same signer seed → byte-identical event). **Honest scope:** this is **P1 plumbing** — a tested conversion + a safe privacy mapping. It is **not** wired into the live server (that is P3) and makes **no accuracy claim** (RuField v0.1 is synthetic; RuView's single-link CSI carries its own caveats). CI: the `rust-tests` workflow checkout gains `submodules: recursive` so the path-deps resolve. Python deterministic proof unchanged (off the signal proof path). - **ADR-262 (Proposed): RuField MFS ↔ RuView integration — a live `SensingServerAdapter`, a privacy/provenance bridge, MAPPED not papered-over.** Researched integration design for wiring RuField into RuView. Recommends: a thin **`wifi-densepose-rufield` bridge crate** (anti-corruption layer, path-deps on the `vendor/rufield` submodule — the `vendor/rvcsi` pattern, since rufield crates are unpublished); a **live `SensingServerAdapter`** that taps the real `SensingUpdate` emit site joined with `TrustedOutput` trust state and emits one signed `FieldEvent`/cycle (the file-based `CsiReplayAdapter` stays for offline replay); **vertical fusion composition** (ruvsense fuses *within* WiFi → one `wifi_csi` event → rufield-fusion graph fuses *across* modalities above it); and **one canonical privacy/provenance model** (RuView `effective_class` is source-of-truth, mapped to RuField P0–P5 at egress; reuse the existing `cog-ha-matter` SHA-256+Ed25519 chain for the `ProvenanceReceipt`). **Key honest finding:** RuView has **two privacy enums + three witness mechanisms across two hash algorithms** that do not map 1:1 onto P0–P5, and a real trap — RuView's `Derived` privacy byte (`1`) sorts *below* `Anonymous` (`2`) yet carries identity embeddings, so the bridge must map by **information content** (`Derived → P4/P5`), never by byte value, or it would leak identity as low-privacy P1. 4 independently-shippable phases, each with a test gate (round-trip / `is_fusable` / privacy-monotonicity / ed25519-verify). Honest scope: this is **plumbing architecture, not accuracy** — RuField v0.1 is synthetic and RuView's only real-CSI path is unlabeled replay; the ADR claims only architecture, gated by round-trip/monotonicity/signature tests. - **RuField `CsiReplayAdapter` — first real (non-synthetic) WiFi-CSI adapter (ADR-260 §17).** RuField now ingests **real captured WiFi CSI** instead of only the synthetic simulator. New `rufield-adapters::csi_replay` parses RuView's `.csi.jsonl` recording format (`{timestamp, subcarriers[]}`), normalizes each frame to a `FieldTensor` (`WifiCsi`, real amplitudes + real `timestamp_ns`), establishes a per-subcarrier Welford **empty-room baseline** via `calibrate()`, derives a **physically-grounded CSI-variance motion/presence proxy** (normalized MAD vs baseline → P2 motion/presence, else P1), and emits `FieldEvent`s with a **real sha256 + ed25519 provenance receipt** (`synthetic=false`). **Measured on 199 real captured frames:** 184 presence-proxy / 69 motion-proxy → fed through `RuFieldFusion` → **182 fused inferences (115 breathing, 67 person_present) from real signal.** 12 tests (9 unit + 3 integration over real-CSI fixtures), deterministic (byte-identical stream per file). **Honest caveats (stated everywhere):** it's **replay from file, not live hardware**; recordings are **unlabeled**, so the motion/presence output is a **proxy, NOT validated accuracy** (no pose, no accuracy numbers); live streaming + labeled validation remain roadmap; mmWave/thermal stay synthetic. The win is "RuField ingests real WiFi CSI and produces fused events from it." [`ruvnet/rufield`](https://github.com/ruvnet/rufield) `crates/rufield-adapters`; `vendor/rufield` submodule bumped. - **RuField `rufield-viewer` web dashboard — completes ADR-260 §27.9 (all §27 criteria 1–10 now PASS).** A read-only Axum + vanilla-JS dashboard (no build step — `cargo run -p rufield-viewer`) that streams the deterministic SyntheticSim→fusion camera-free room-intelligence demo: live room-state inferences with confidence, a scrolling event log where every event carries its modality + a colour-coded **P0–P5 privacy badge**, the fusion graph (supporting=green / contradicting=red per inference), and a click-to-open **provenance-receipt modal** (sha256 + ed25519 signer + verified ✓ / fusable ✓) — behind a permanent, undismissable `SYNTHETIC — simulated sensors, no hardware` banner. Endpoints `/` · `/app.js` · `/health` · `/api/run` (full deterministic JSON) · `/events` (SSE). 12 new tests. Honest scope: a read-only SYNTHETIC demo viewer, **not** a device-management console — fleet/real-adapter management is a separate later milestone. Lives in [`ruvnet/rufield`](https://github.com/ruvnet/rufield) (`crates/rufield-viewer`, repo now 7 crates / 72 tests); `vendor/rufield` submodule bumped to include it. diff --git a/CLAUDE.md b/CLAUDE.md index 01ca929a..226d09a4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,6 +23,7 @@ Dual codebase: Python v1 (`v1/`) and Rust port (`v2/`). | `nvsim` | Deterministic NV-diamond magnetometer pipeline simulator (ADR-089) — standalone leaf, WASM-ready | | `vendor/rvcsi` (submodule) | **rvCSI** — edge RF sensing runtime (ADR-095/096): 9 crates (`rvcsi-core`/`-dsp`/`-events`/`-adapter-file`/`-adapter-nexmon`/`-ruvector`/`-runtime`/`-node`/`-cli`). Lives in its own repo ([github.com/ruvnet/rvcsi](https://github.com/ruvnet/rvcsi)), vendored here under `vendor/rvcsi`, published to crates.io as `rvcsi-* 0.3.x` and to npm as `@ruv/rvcsi`. Not a `v2/` workspace member — depend on the published crates (or the submodule's `crates/rvcsi-*` paths). Normalized `CsiFrame`/`CsiWindow`/`CsiEvent` schema, validate-before-FFI, reusable DSP, typed confidence-scored events, the napi-c Nexmon shim (real nexmon_csi `.pcap` from a Raspberry Pi 5 / 4 / 3B+ — BCM43455c0), the napi-rs SDK, the `rvcsi` CLI, a Claude Code plugin. | | `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 | ### RuvSense Modules (`signal/src/ruvsense/`) diff --git a/docs/adr/ADR-262-rufield-ruview-integration.md b/docs/adr/ADR-262-rufield-ruview-integration.md index 7fcde475..2484dc65 100644 --- a/docs/adr/ADR-262-rufield-ruview-integration.md +++ b/docs/adr/ADR-262-rufield-ruview-integration.md @@ -2,7 +2,7 @@ | Field | Value | |-------|-------| -| **Status** | Proposed | +| **Status** | Proposed — P1 implemented | | **Date** | 2026-06-14 | | **Deciders** | ruv | | **Codebase target** | New thin bridge crate `wifi-densepose-rufield` (v2 workspace member); taps `wifi-densepose-sensing-server` emit path + `wifi-densepose-engine` `TrustedOutput`; depends on `vendor/rufield/crates/rufield-*` via path (the `vendor/rvcsi` pattern) | @@ -21,6 +21,19 @@ This project has been publicly accused of "AI slop." This ADR answers with **evi --- +## 0.1 Implementation status + +**P1 (§4) is implemented** as the `wifi-densepose-rufield` bridge crate (`v2/crates/wifi-densepose-rufield/`, a new v2 workspace member; path-deps the `vendor/rufield` submodule per §5.4): + +- **Input** — `SensingSnapshot` (owned primitives mirroring `SensingUpdate` features/classification/signal_field joined with the `TrustedOutput` `trust_class`/`demoted`/`identity_bound`); the bridge does **not** depend on `wifi-densepose-sensing-server` (anti-corruption layer). +- **Conversion** — `snapshot_to_field_event(&snap, &Signer)` emits a signed `FieldEvent` (`Modality::WifiCsi`, axis `[Frequency]`, real `timestamp_ns`); position derived from the signal-field peak when present (never fabricated); real sha256 `ProvenanceRef` + ed25519 signature, `synthetic = false`. +- **Privacy (§3.3 crux)** — `map_privacy()` maps by information content, **fail-closed**: `Raw → P0`, `Derived → P4` (or `P5` if identity-bound — **never P1**), `Anonymous → P2`, `Restricted → P2`; a `demoted` cycle floors egress to ≥ P2. +- **Gates that pass** (`tests/p1_gates.rs`, 15 tests / 0 failed = 5 unit + 9 integration + 1 doc): round-trip (snapshot → `FieldEvent` → serde → equal); `is_fusable` (verified ed25519 receipt); `RuFieldFusion::ingest` accept + `infer()` runs; **privacy-safety** (`gate_privacy_safety_derived_never_maps_to_low_privacy` — `Derived → P4/P5`, never P1; full §3.3 table; fail-closed demotion); determinism (same snapshot + same signer seed → byte-identical event). + +**Deferred:** the §3.3 *provenance carrier* recommendation (reuse the `cog-ha-matter` SHA-256+Ed25519 chain + embed the BLAKE3 engine witness) is **not** in P1 — P1 takes a dedicated `Signer` param (the §8 open question 1 key-ownership decision is unresolved). P2's BLAKE3-embed, P3 (live `/ws/field` surfacing — the bridge is **not** wired into the running server yet), and P4 (multi-modality) remain future work. **No accuracy is claimed** (§0 / §6) — P1 is tested plumbing + a safe privacy mapping. + +--- + ## 1. Context — two architectures, mapped ### 1.1 RuField MFS (ADR-260, `vendor/rufield/`) diff --git a/v2/Cargo.lock b/v2/Cargo.lock index f0cdd0e6..ed0a4a6c 100644 --- a/v2/Cargo.lock +++ b/v2/Cargo.lock @@ -7085,6 +7085,42 @@ dependencies = [ "smallvec", ] +[[package]] +name = "rufield-core" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "rufield-fusion" +version = "0.1.0" +dependencies = [ + "rufield-core", + "rufield-provenance", + "serde", + "toml 0.8.23", +] + +[[package]] +name = "rufield-privacy" +version = "0.1.0" +dependencies = [ + "rufield-core", +] + +[[package]] +name = "rufield-provenance" +version = "0.1.0" +dependencies = [ + "ed25519-dalek", + "rufield-core", + "serde", + "serde_json", + "sha2", +] + [[package]] name = "rumqttc" version = "0.24.0" @@ -11045,6 +11081,18 @@ dependencies = [ "tower-http", ] +[[package]] +name = "wifi-densepose-rufield" +version = "0.3.0" +dependencies = [ + "rufield-core", + "rufield-fusion", + "rufield-privacy", + "rufield-provenance", + "serde", + "serde_json", +] + [[package]] name = "wifi-densepose-ruvector" version = "0.3.2" diff --git a/v2/Cargo.toml b/v2/Cargo.toml index 22dc8a7a..1988c30e 100644 --- a/v2/Cargo.toml +++ b/v2/Cargo.toml @@ -72,6 +72,11 @@ members = [ "crates/homecore-assist", # ADR-133 — HOMECORE voice assistant + ruflo bridge "crates/homecore-server", # iter-9 — HOMECORE integration binary (all 8 crates wired together) "crates/ruview-swarm", # ADR-148 — drone swarm control system + # ADR-262 P1 — anti-corruption bridge converting RuView WiFi-CSI sensing + # output into signed RuField FieldEvents. Path-deps the `vendor/rufield` + # submodule crates (rufield-core/-provenance/-privacy/-fusion); single + # coupling point between RuView and the standalone RuField MFS spec. + "crates/wifi-densepose-rufield", ] # ADR-040: WASM edge crate targets wasm32-unknown-unknown (no_std), # excluded from workspace to avoid breaking `cargo test --workspace`. diff --git a/v2/crates/wifi-densepose-rufield/Cargo.toml b/v2/crates/wifi-densepose-rufield/Cargo.toml new file mode 100644 index 00000000..f8f1b5ea --- /dev/null +++ b/v2/crates/wifi-densepose-rufield/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "wifi-densepose-rufield" +version = "0.3.0" +edition = "2021" +description = "ADR-262 anti-corruption bridge: converts RuView WiFi-CSI sensing output into signed RuField FieldEvents (P0–P5 privacy mapping + ed25519 provenance)" +license.workspace = true +authors.workspace = true +repository.workspace = true + +# ADR-262 §5.4: this crate is the single coupling point ("anti-corruption +# layer") between RuView and the standalone RuField MFS spec. It depends on the +# `vendor/rufield` submodule crates **via path** (the `vendor/rvcsi` pattern) — +# RuView does NOT depend on published rufield crates (there are none) and does +# NOT make rufield a v2 workspace member. The four crates below are pure-Rust +# (serde / serde_json / toml / sha2 / ed25519-dalek only — no tch / openblas / +# ndarray / candle), so they build under `--no-default-features`. +[dependencies] +rufield-core = { path = "../../../vendor/rufield/crates/rufield-core" } +rufield-provenance = { path = "../../../vendor/rufield/crates/rufield-provenance" } +rufield-privacy = { path = "../../../vendor/rufield/crates/rufield-privacy" } +rufield-fusion = { path = "../../../vendor/rufield/crates/rufield-fusion" } +serde = { workspace = true } +serde_json = { workspace = true } + +[dev-dependencies] +serde_json = { workspace = true } diff --git a/v2/crates/wifi-densepose-rufield/src/bridge.rs b/v2/crates/wifi-densepose-rufield/src/bridge.rs new file mode 100644 index 00000000..09dabec2 --- /dev/null +++ b/v2/crates/wifi-densepose-rufield/src/bridge.rs @@ -0,0 +1,206 @@ +//! The conversion: `SensingSnapshot` → signed `FieldEvent` (ADR-262 P1). +//! +//! This is the in-process `SensingServerAdapter` core (ADR-262 §4 P1 / §5.1): +//! it consumes a `(SensingUpdate, TrustedOutput)` join — modelled here as a +//! [`SensingSnapshot`] of owned primitives — and emits one signed +//! [`FieldEvent`] (`Modality::WifiCsi`, axis `[Frequency]`) per cycle. + +use crate::privacy::egress_class; +use crate::snapshot::{SensingSnapshot, SignalField}; +use rufield_core::{ + FieldAxis, FieldEvent, FieldTensor, Modality, Observation, PrivacyClass, ProvenanceRef, + SensorDescriptor, +}; +use rufield_provenance::{sha256_hex, Signer}; +use std::collections::BTreeMap; + +/// Model id stamped on emitted events (ADR-262 — derived features come from +/// RuView's `/ws/sensing` pipeline, not a trained encoder). +const MODEL_ID: &str = "ruview_sensing_server_v1"; + +/// Firmware hash placeholder until the real ESP32 firmware image hash is wired +/// through (ADR-262 §8 open question 3 — the BLAKE3 engine witness slot). A +/// stable `sha256:` over the model id keeps it a real digest, not a fake. +fn firmware_hash() -> String { + sha256_hex(MODEL_ID.as_bytes()) +} + +/// Squash a non-negative power-like scalar into `[0, 1]` deterministically. +/// `x / (x + 1)` — monotone, no panics, no calibration claim. +fn squash(x: f64) -> f32 { + if !x.is_finite() || x <= 0.0 { + return 0.0; + } + (x / (x + 1.0)) as f32 +} + +/// Build the `Observation.features` map the RuField fusion engine reads +/// (`rufield-fusion/engine.rs:217-228`: `motion_energy`, `breathing_band`, +/// `transient`, `presence`, `range_m`, plus `posture_height`). +fn build_features(snap: &SensingSnapshot, range_m: Option) -> BTreeMap { + let f = &snap.features; + let mut m = BTreeMap::new(); + m.insert("motion_energy".to_string(), squash(f.motion_band_power)); + m.insert("breathing_band".to_string(), squash(f.breathing_band_power)); + m.insert("transient".to_string(), squash(f.change_points as f64)); + m.insert( + "presence".to_string(), + if snap.classification.presence { 1.0 } else { 0.0 }, + ); + if let Some(r) = range_m { + m.insert("range_m".to_string(), r); + } + m +} + +/// Derive a real range (metres) and motion vector from the strongest signal +/// field peak, if a field is present. Returns `(range_m, motion_vector, +/// space_cell)` — all `None` when there is no field (we do NOT fabricate +/// coordinates, per ADR-262 §4 P1). +fn derive_position( + field: Option<&SignalField>, +) -> (Option, Option<[f32; 3]>, Option<[i32; 3]>) { + let Some(field) = field else { + return (None, None, None); + }; + let Some(cell) = field.peak_cell() else { + return (None, None, None); + }; + // Range from origin in grid-cell units (real readout, not calibrated + // metres — the honesty caveat from `field_localize.rs:16-27`). + let [x, y, z] = cell; + let range = ((x * x + y * y + z * z) as f32).sqrt(); + let mag = if range > 0.0 { range } else { 1.0 }; + let motion_vector = [x as f32 / mag, y as f32 / mag, z as f32 / mag]; + (Some(range), Some(motion_vector), Some(cell)) +} + +/// Stable, deterministic event id from `(node_id, timestamp_ns)`. No RNG, so +/// the same snapshot always yields the same id (required for the determinism +/// gate). +fn event_id(snap: &SensingSnapshot) -> String { + format!("ruview-{}-{}", snap.node_id, snap.timestamp_ns) +} + +/// Convert a [`SensingSnapshot`] to a **signed** [`FieldEvent`] (ADR-262 P1). +/// +/// 1. Builds a `FieldTensor` (`Modality::WifiCsi`, axis `[Frequency]`) whose +/// values are the RuView feature scalars, with the real `timestamp_ns`. +/// 2. Builds an `Observation` — `motion_vector`/`range_m`/`space_cell` derived +/// from the signal-field peak when present (else `None`; coordinates are +/// never fabricated), `confidence` from the classification, labels from +/// motion-level/presence. +/// 3. Stamps the §3.3 egress privacy class (information-content mapping with +/// the demotion floor) on both tensor and observation. +/// 4. Builds a real `ProvenanceRef` (sha256 raw hash over the tensor/feature +/// bytes, `synthetic = false`) and **signs** it with the supplied ed25519 +/// [`Signer`] so `rufield_provenance::is_fusable` passes. +/// +/// Determinism: with no RNG anywhere and a deterministic ed25519 signer, the +/// same `snap` + same signer seed yields a byte-identical event. +#[must_use] +pub fn snapshot_to_field_event(snap: &SensingSnapshot, signer: &Signer) -> FieldEvent { + let class = egress_class(snap.trust_class, snap.identity_bound, snap.demoted); + + let (range_m, motion_vector, space_cell) = derive_position(snap.signal_field.as_ref()); + + // ── 1. Tensor ────────────────────────────────────────────────────────── + // The frequency-domain feature scalars, in a stable order. + let f = &snap.features; + let values: Vec = vec![ + f.mean_rssi as f32, + f.variance as f32, + f.motion_band_power as f32, + f.breathing_band_power as f32, + f.dominant_freq_hz as f32, + f.spectral_power as f32, + ]; + let confidence = (snap.classification.confidence as f32).clamp(0.0, 1.0); + let noise_floor = f.variance.max(0.0) as f32; + let calibration_id = format!("ruview_node_{}", snap.node_id); + + // `FieldTensor::new` only errors on a shape/axis mismatch; our shape + // exactly matches `values.len()` and one axis, so this is infallible here. + let tensor = FieldTensor::new( + snap.timestamp_ns, + Modality::WifiCsi, + vec![FieldAxis::Frequency], + vec![values.len()], + values, + confidence, + noise_floor, + Some(calibration_id.clone()), + class, + ) + .expect("feature tensor shape is well-formed by construction"); + + // ── 2. Observation ───────────────────────────────────────────────────── + let observation = Observation { + zone_id: Some(snap.node_id.clone()), + space_cell, + range_m, + velocity_mps: None, + motion_vector, + confidence, + features: build_features(snap, range_m), + labels: build_labels(snap), + privacy_class: class, + }; + + // ── 3. Provenance (real sha256 over the tensor bytes) ─────────────────── + let raw_hash = sha256_hex( + &serde_json::to_vec(&tensor).expect("tensor serializes to JSON for hashing"), + ); + let provenance = ProvenanceRef { + raw_hash, + firmware_hash: firmware_hash(), + model_id: MODEL_ID.to_string(), + calibration_id, + synthetic: false, // a real (non-synthetic) live/replay event + signature_hex: None, + signer_pubkey_hex: None, + }; + + let sensor = SensorDescriptor { + modality: "wifi_csi".to_string(), + vendor: "esp32".to_string(), + device_id: snap.node_id.clone(), + placement: "unknown".to_string(), + clock_domain: "local".to_string(), + }; + + let mut event = FieldEvent::new( + event_id(snap), + snap.timestamp_ns, + sensor, + tensor, + observation, + provenance, + ); + + // ── 4. Sign (ed25519) so `is_fusable` passes for this real event ──────── + signer + .sign_event(&mut event) + .expect("ed25519 signing of a serializable event is infallible"); + + event +} + +/// Labels from the classification. These are descriptive (`person_present`, +/// `motion_`); the RuField fusion engine never reads labels +/// (`event.rs:45-48`), so this carries no identity. +fn build_labels(snap: &SensingSnapshot) -> Vec { + let mut labels = Vec::new(); + if snap.classification.presence { + labels.push("person_present".to_string()); + } + labels.push(format!("motion_{}", snap.classification.motion_level)); + labels +} + +/// Convenience: the privacy class that *would* be stamped for a snapshot, +/// without building the whole event. Useful for egress badges (P3) and tests. +#[must_use] +pub fn snapshot_egress_class(snap: &SensingSnapshot) -> PrivacyClass { + egress_class(snap.trust_class, snap.identity_bound, snap.demoted) +} diff --git a/v2/crates/wifi-densepose-rufield/src/lib.rs b/v2/crates/wifi-densepose-rufield/src/lib.rs new file mode 100644 index 00000000..6beaa0b5 --- /dev/null +++ b/v2/crates/wifi-densepose-rufield/src/lib.rs @@ -0,0 +1,85 @@ +//! # wifi-densepose-rufield +//! +//! ADR-262 **anti-corruption bridge**: converts RuView's live WiFi-CSI sensing +//! output into signed RuField [`FieldEvent`](rufield_core::FieldEvent)s. +//! +//! This crate is the **single coupling point** (ADR-262 §5.4) between RuView and +//! the standalone RuField MFS spec (`vendor/rufield`, ADR-260). It depends on +//! the four pure-Rust rufield crates **via path** — `rufield-core`, +//! `-provenance`, `-privacy`, `-fusion` — and on **no** RuView internal crate. +//! Inputs are owned primitives ([`SensingSnapshot`]) that mirror what RuView's +//! sensing cycle produces, so the bridge never imports `SensingUpdate` / +//! `TrustedOutput` directly. +//! +//! ## What P1 ships (honesty — ADR-262 §0 / §6) +//! +//! This is **P1 plumbing**: a tested `SensingSnapshot → FieldEvent` conversion +//! plus the **fail-closed privacy mapping** that is the §3.3 correctness item. +//! It is **not** wired into the live server (that is P3) and makes **no accuracy +//! claim** — RuField v0.1 is synthetic end-to-end and RuView's single-link CSI +//! carries its own caveats. The gates here are round-trip / fusability / +//! privacy-safety / determinism, not validated F1. +//! +//! ## The critical correctness item: the privacy mapping (§3.3) +//! +//! RuView's `Derived` class has byte value `1` (below `Anonymous = 2`) yet +//! carries an identity embedding. The bridge maps it to **P4/P5 by information +//! content, never P1** — see [`map_privacy`]. Mapping off the byte would leak +//! identity as low-privacy; [`map_privacy`] (and its dedicated test +//! `derived_identity_never_maps_to_low_privacy`) exist specifically to prevent +//! that. +//! +//! ## Example +//! +//! ``` +//! use wifi_densepose_rufield::{ +//! snapshot_to_field_event, SensingSnapshot, SensingFeatures, SensingClass, +//! RuViewPrivacyClass, +//! }; +//! use rufield_provenance::{Signer, is_fusable}; +//! +//! let snap = SensingSnapshot { +//! timestamp_ns: 1_791_986_400_000_000_000, +//! features: SensingFeatures { +//! mean_rssi: -55.0, +//! variance: 0.4, +//! motion_band_power: 2.0, +//! breathing_band_power: 0.3, +//! dominant_freq_hz: 0.25, +//! change_points: 1, +//! spectral_power: 3.0, +//! }, +//! classification: SensingClass { +//! motion_level: "low".into(), +//! presence: true, +//! confidence: 0.82, +//! }, +//! signal_field: None, +//! trust_class: RuViewPrivacyClass::Anonymous, +//! demoted: false, +//! identity_bound: false, +//! node_id: "esp32_room_01".into(), +//! }; +//! +//! let signer = Signer::from_seed(b"adr-262-bridge-seed-32-bytes-ok!"); +//! let event = snapshot_to_field_event(&snap, &signer); +//! assert!(is_fusable(&event)); // ed25519-signed, non-synthetic ⇒ fusable +//! ``` + +#![forbid(unsafe_code)] + +pub mod bridge; +pub mod privacy; +pub mod snapshot; + +pub use bridge::{snapshot_egress_class, snapshot_to_field_event}; +pub use privacy::{apply_demotion_floor, egress_class, map_privacy}; +pub use snapshot::{ + RuViewPrivacyClass, SensingClass, SensingFeatures, SensingSnapshot, SignalField, +}; + +// Re-export the rufield surface a bridge consumer needs, so callers depend on +// one crate. +pub use rufield_core::{FieldEvent, Modality, PrivacyClass}; +pub use rufield_fusion::RuFieldFusion; +pub use rufield_provenance::{is_fusable, verify_event, Signer}; diff --git a/v2/crates/wifi-densepose-rufield/src/privacy.rs b/v2/crates/wifi-densepose-rufield/src/privacy.rs new file mode 100644 index 00000000..a5d5247d --- /dev/null +++ b/v2/crates/wifi-densepose-rufield/src/privacy.rs @@ -0,0 +1,147 @@ +//! The ADR-262 §3.3 privacy mapping — the critical correctness item. +//! +//! RuView's effective `PrivacyClass` (4 byte-level classes) is the source of +//! truth; the bridge maps it onto RuField's `PrivacyClass` (P0–P5) **at the +//! egress boundary, by information content, NEVER by byte value**. +//! +//! ## The trap (ADR-262 §3, §6) +//! +//! RuView's `Derived` has byte value `1`, which sorts *below* `Anonymous` +//! (byte `2`). A naive byte-mapping (`Derived = 1 → P1`) would leak +//! identity-bearing features (`identity_embedding`, `identity_risk_score`) as a +//! **low-privacy P1** event. Because `Derived` carries derived *identity*, it +//! must map to the **biometric/identity tier (P4/P5)** — never P1. This is the +//! single most dangerous mapping mistake; it gets a dedicated test +//! (`derived_identity_never_maps_to_low_privacy`). +//! +//! ## Fail-closed +//! +//! [`RuViewPrivacyClass`] is a closed enum, so there is no runtime "unknown" +//! value to receive — but the mapping is written `match`-exhaustively with an +//! explicit, documented arm per class, and the `demoted`/`identity_bound` +//! overlays only ever move the result **toward more privacy**, never less. + +use crate::snapshot::RuViewPrivacyClass; +use rufield_core::PrivacyClass; + +/// Map a RuView effective `PrivacyClass` onto a RuField `PrivacyClass` +/// (ADR-262 §3.3), by information content. +/// +/// | RuView (byte) | → RuField | Rationale | +/// |---|---|---| +/// | `Raw` (0) | `P0` | raw CSI waveform | +/// | `Derived` (1) | `P4` (or `P5` if `identity_bound`) | derived **identity** features ⇒ biometric/identity tier, **not** P1 | +/// | `Anonymous` (2) | `P2` | occupancy / motion only | +/// | `Restricted` (3) | `P2` (raw suppressed) | matches `suppress_raw_outputs` | +/// +/// `identity_bound` only promotes `Derived` (already identity-derived) from P4 +/// to P5; it can never lower the class. +#[must_use] +pub fn map_privacy(ruview_class: RuViewPrivacyClass, identity_bound: bool) -> PrivacyClass { + match ruview_class { + // Raw CSI amplitude → raw waveform tier. + RuViewPrivacyClass::Raw => PrivacyClass::P0, + + // THE CRITICAL ARM (§3.3 / §6): `Derived` carries identity. Map by + // information content to the biometric/identity tier P4, and to P5 when + // the surface is bound to a named identity. NEVER P1. + RuViewPrivacyClass::Derived => { + if identity_bound { + PrivacyClass::P5 + } else { + PrivacyClass::P4 + } + } + + // Anonymous occupancy / motion aggregate → P2. + RuViewPrivacyClass::Anonymous => PrivacyClass::P2, + + // Restricted: occupancy with risk score / hash stripped and raw + // suppressed. Capped at P2 (occupancy tier), matching + // `EngineBridge::suppress_raw_outputs` (`engine_bridge.rs:240`). + RuViewPrivacyClass::Restricted => PrivacyClass::P2, + } +} + +/// The §4 P2 gate (b) monotonicity overlay: a governed-engine **demotion** +/// (`TrustedOutput.demoted == true`) must never let the emitted class fall +/// below P2 (occupancy floor), and raw is suppressed. +/// +/// This is applied *after* [`map_privacy`] and can only raise the class +/// (toward more privacy) — it is fail-closed by construction. +#[must_use] +pub fn apply_demotion_floor(class: PrivacyClass, demoted: bool) -> PrivacyClass { + if demoted && class < PrivacyClass::P2 { + PrivacyClass::P2 + } else { + class + } +} + +/// The full egress class for a snapshot: information-content mapping with the +/// demotion floor overlaid. This is what the bridge stamps on the emitted +/// `FieldEvent`. +#[must_use] +pub fn egress_class( + ruview_class: RuViewPrivacyClass, + identity_bound: bool, + demoted: bool, +) -> PrivacyClass { + apply_demotion_floor(map_privacy(ruview_class, identity_bound), demoted) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn derived_maps_to_identity_tier_not_p1() { + // The single most dangerous mapping mistake: Derived (byte 1) must NOT + // become P1. It carries identity ⇒ P4, or P5 if identity-bound. + assert_eq!(map_privacy(RuViewPrivacyClass::Derived, false), PrivacyClass::P4); + assert_eq!(map_privacy(RuViewPrivacyClass::Derived, true), PrivacyClass::P5); + } + + #[test] + fn full_table_matches_adr_262_section_3_3() { + assert_eq!(map_privacy(RuViewPrivacyClass::Raw, false), PrivacyClass::P0); + assert_eq!(map_privacy(RuViewPrivacyClass::Derived, false), PrivacyClass::P4); + assert_eq!(map_privacy(RuViewPrivacyClass::Anonymous, false), PrivacyClass::P2); + assert_eq!(map_privacy(RuViewPrivacyClass::Restricted, false), PrivacyClass::P2); + } + + #[test] + fn mapping_ignores_non_monotonic_byte_value() { + // Derived's byte (1) is *below* Anonymous's byte (2), but Derived's + // mapped class must be *above* Anonymous's mapped class — proving the + // mapping uses information content, not the byte. + assert!(RuViewPrivacyClass::Derived.raw_byte() < RuViewPrivacyClass::Anonymous.raw_byte()); + assert!( + map_privacy(RuViewPrivacyClass::Derived, false) + > map_privacy(RuViewPrivacyClass::Anonymous, false) + ); + } + + #[test] + fn demotion_floor_only_raises_privacy() { + // Raw → P0, but a demoted cycle floors to P2 with raw suppressed. + assert_eq!(apply_demotion_floor(PrivacyClass::P0, true), PrivacyClass::P2); + // Already-high classes are never lowered by the floor. + assert_eq!(apply_demotion_floor(PrivacyClass::P5, true), PrivacyClass::P5); + // No demotion ⇒ unchanged. + assert_eq!(apply_demotion_floor(PrivacyClass::P0, false), PrivacyClass::P0); + } + + #[test] + fn identity_bound_only_promotes() { + // identity_bound never lowers privacy; it only promotes Derived P4→P5. + for c in [ + RuViewPrivacyClass::Raw, + RuViewPrivacyClass::Derived, + RuViewPrivacyClass::Anonymous, + RuViewPrivacyClass::Restricted, + ] { + assert!(map_privacy(c, true) >= map_privacy(c, false)); + } + } +} diff --git a/v2/crates/wifi-densepose-rufield/src/snapshot.rs b/v2/crates/wifi-densepose-rufield/src/snapshot.rs new file mode 100644 index 00000000..12347bed --- /dev/null +++ b/v2/crates/wifi-densepose-rufield/src/snapshot.rs @@ -0,0 +1,152 @@ +//! Owned, primitive input types for the ADR-262 bridge. +//! +//! These deliberately **mirror** the shapes RuView's sensing cycle produces +//! (the `/ws/sensing` `SensingUpdate` build site at +//! `wifi-densepose-sensing-server/src/main.rs:~5938` and the `TrustedOutput` +//! trust state surfaced via `EngineBridge` at `main.rs:~5886`) **without +//! importing** RuView's internal crates. Keeping the bridge an anti-corruption +//! layer (ADR-262 §5.4) means it takes owned primitives, not `SensingUpdate` +//! or `TrustedOutput` directly — so this crate never depends on +//! `wifi-densepose-sensing-server`. + +use serde::{Deserialize, Serialize}; + +/// The CSI feature scalars RuView publishes on every `/ws/sensing` cycle. +/// +/// Mirrors `FeatureInfo` (`main.rs:368-377`). All values are in RuView's own +/// units; the bridge normalizes them into `Observation.features` for fusion. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SensingFeatures { + /// Mean RSSI across the CSI window (dBm). + pub mean_rssi: f64, + /// CSI amplitude variance. + pub variance: f64, + /// Motion-band spectral power (drives `motion_energy`). + pub motion_band_power: f64, + /// Breathing-band spectral power (drives `breathing_band`). + pub breathing_band_power: f64, + /// Dominant frequency of the CSI window (Hz). + pub dominant_freq_hz: f64, + /// Number of change points detected in the window (drives `transient`). + pub change_points: usize, + /// Total spectral power of the window. + pub spectral_power: f64, +} + +/// The RuView classification block. Mirrors `ClassificationInfo` +/// (`main.rs:379-384`). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SensingClass { + /// Coarse motion level label (e.g. `"none"`, `"low"`, `"high"`). + pub motion_level: String, + /// Whether a person is present. + pub presence: bool, + /// Classification confidence `0.0..=1.0`. + pub confidence: f64, +} + +/// A RuView signal field — a floor-plane grid of field values. Mirrors +/// `SignalField` (`main.rs:386-390`). The bridge derives a real position from +/// the strongest field peak (like `field_localize`) and **never fabricates** +/// coordinates when this is absent. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SignalField { + /// Grid dimensions `[x, y, z]`. + pub grid_size: [usize; 3], + /// Row-major flattened field values; `len() == grid_size.product()`. + pub values: Vec, +} + +impl SignalField { + /// Index `[x, y, z]` of the strongest field cell, or `None` if the grid is + /// empty / all-NaN. This is the honest "strongest field peak" readout that + /// `field_localize` (`field_localize.rs:16-27`) exposes — **not** calibrated + /// triangulation. + #[must_use] + pub fn peak_cell(&self) -> Option<[i32; 3]> { + let [nx, ny, nz] = self.grid_size; + if nx == 0 || ny == 0 || nz == 0 || self.values.is_empty() { + return None; + } + let mut best_idx: Option = None; + let mut best_val = f64::NEG_INFINITY; + for (i, &v) in self.values.iter().enumerate() { + if v.is_finite() && v > best_val { + best_val = v; + best_idx = Some(i); + } + } + let idx = best_idx?; + // Row-major: idx = ((x * ny) + y) * nz + z. + let z = idx % nz; + let y = (idx / nz) % ny; + let x = idx / (nz * ny); + Some([x as i32, y as i32, z as i32]) + } +} + +/// RuView's effective privacy class (the `effective_class` / privacy byte on +/// `TrustedOutput`). +/// +/// This **mirrors** `wifi_densepose_bfld::PrivacyClass` (`bfld/lib.rs:103-116`, +/// `#[repr(u8)]`) — the four byte-level classes. The byte values are +/// **deliberately non-monotonic in information content**: `Derived = 1` carries +/// an identity embedding yet sorts *below* `Anonymous = 2`. The bridge's +/// `map_privacy` must therefore map by information content, NEVER by byte value +/// (ADR-262 §3.3 — the central correctness item). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RuViewPrivacyClass { + /// Byte `0` — raw CSI amplitude, local-only. + Raw, + /// Byte `1` — derived **identity** features (identity_embedding + + /// identity_risk_score), LAN-only. The dangerous one (§3.3). + Derived, + /// Byte `2` — aggregate occupancy / motion, no identity. + Anonymous, + /// Byte `3` — care/regulated: occupancy minus risk score and hash; + /// raw suppressed. + Restricted, +} + +impl RuViewPrivacyClass { + /// The raw byte value used by RuView's `#[repr(u8)]` enum + /// (`bfld/lib.rs:103`). Exposed only so callers can demonstrate the + /// non-monotonicity trap in tests; the bridge never maps off this byte. + #[must_use] + pub fn raw_byte(self) -> u8 { + match self { + RuViewPrivacyClass::Raw => 0, + RuViewPrivacyClass::Derived => 1, + RuViewPrivacyClass::Anonymous => 2, + RuViewPrivacyClass::Restricted => 3, + } + } +} + +/// One sensing cycle, as a bridge input. Mirrors the join of `SensingUpdate` +/// (features + classification + signal_field) and the `TrustedOutput` trust +/// state (`trust_class`) that ADR-262 §1.2 / P1 say must be done at the bridge. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SensingSnapshot { + /// Capture time, nanoseconds since Unix epoch (the real `SensingUpdate` + /// timestamp, ns). + pub timestamp_ns: u64, + /// CSI feature scalars (`/ws/sensing` feature set). + pub features: SensingFeatures, + /// Classification (motion level / presence / confidence). + pub classification: SensingClass, + /// Optional signal field for a real position readout. + pub signal_field: Option, + /// RuView's effective privacy class (the source-of-truth, §3.3). + pub trust_class: RuViewPrivacyClass, + /// Whether the governed engine demoted this cycle (`TrustedOutput.demoted`). + /// When `true` the emitted event must be `>= P2` and raw suppressed + /// (§3.3 / §4 P2 gate (b)). + pub demoted: bool, + /// Whether this cycle's identity surface is bound to an enrolled identity + /// (RuView's `identity_bound`). Promotes `Derived` to P5 when set. + pub identity_bound: bool, + /// Stable node id (e.g. `"esp32_room_01"`). + pub node_id: String, +} diff --git a/v2/crates/wifi-densepose-rufield/tests/p1_gates.rs b/v2/crates/wifi-densepose-rufield/tests/p1_gates.rs new file mode 100644 index 00000000..f467d8ed --- /dev/null +++ b/v2/crates/wifi-densepose-rufield/tests/p1_gates.rs @@ -0,0 +1,172 @@ +//! ADR-262 P1 acceptance gates. Each test below IS an acceptance criterion. +//! +//! - round-trip: snapshot → FieldEvent → serde → equal +//! - is_fusable: emitted event passes the §11 fusability invariant +//! - fusion ingest accept: `RuFieldFusion::ingest` accepts it + `infer` runs +//! - privacy safety: `Derived` never maps to a low-privacy class (the §3.3 trap) +//! - determinism: same snapshot + same signer seed → identical event + +use rufield_core::{FusionEngine, InferenceQuery, PrivacyClass}; +use rufield_fusion::RuFieldFusion; +use rufield_provenance::{is_fusable, verify_event, Signer}; +use wifi_densepose_rufield::{ + map_privacy, snapshot_to_field_event, RuViewPrivacyClass, SensingClass, SensingFeatures, + SensingSnapshot, SignalField, +}; + +const SEED: &[u8; 32] = b"adr-262-bridge-seed-32-bytes-ok!"; + +fn signer() -> Signer { + Signer::from_seed(SEED) +} + +/// A representative snapshot with a real signal field (so a position is derived). +fn sample_snapshot() -> SensingSnapshot { + SensingSnapshot { + timestamp_ns: 1_791_986_400_123_456_789, + features: SensingFeatures { + mean_rssi: -52.5, + variance: 0.73, + motion_band_power: 2.4, + breathing_band_power: 0.6, + dominant_freq_hz: 0.27, + change_points: 2, + spectral_power: 4.1, + }, + classification: SensingClass { + motion_level: "high".into(), + presence: true, + confidence: 0.88, + }, + signal_field: Some(SignalField { + grid_size: [2, 1, 2], + // peak at flat index 2 → cell [1,0,0] + values: vec![0.1, 0.2, 0.9, 0.3], + }), + trust_class: RuViewPrivacyClass::Anonymous, + demoted: false, + identity_bound: false, + node_id: "esp32_room_01".into(), + } +} + +#[test] +fn gate_round_trip_serde_equal() { + let ev = snapshot_to_field_event(&sample_snapshot(), &signer()); + let json = serde_json::to_string(&ev).expect("serialize"); + let back: rufield_core::FieldEvent = serde_json::from_str(&json).expect("deserialize"); + assert_eq!(ev, back, "FieldEvent must round-trip through serde unchanged"); +} + +#[test] +fn gate_is_fusable_verified_receipt() { + let ev = snapshot_to_field_event(&sample_snapshot(), &signer()); + // Real (non-synthetic) event must carry a verifying ed25519 signature. + assert!(!ev.provenance.synthetic, "live event must NOT be marked synthetic"); + assert!(ev.provenance.signature_hex.is_some(), "must be signed"); + assert!(verify_event(&ev).is_ok(), "signature must verify"); + assert!(is_fusable(&ev), "verified receipt ⇒ fusable (§11 invariant)"); +} + +#[test] +fn gate_fusion_ingest_accepts_and_infers() { + let ev = snapshot_to_field_event(&sample_snapshot(), &signer()); + let mut engine = RuFieldFusion::new(); + engine.ingest(ev).expect("fusion engine must accept the signed event"); + // infer() must run without error (may or may not produce inferences). + let inferences = engine + .infer(&InferenceQuery::all()) + .expect("infer() must run"); + // The graph recorded the event/sensor provenance nodes. + assert!( + engine.graph().node_count() >= 2, + "ingest should record sensor + event nodes" + ); + let _ = inferences; // count is not an accuracy claim +} + +#[test] +fn gate_privacy_safety_derived_never_maps_to_low_privacy() { + // THE critical §3.3 gate. Derived carries identity ⇒ P4/P5, NEVER P1. + let p4 = map_privacy(RuViewPrivacyClass::Derived, false); + let p5 = map_privacy(RuViewPrivacyClass::Derived, true); + assert_eq!(p4, PrivacyClass::P4); + assert_eq!(p5, PrivacyClass::P5); + assert!(p4 >= PrivacyClass::P4, "Derived must be in the identity tier"); + assert_ne!(p4, PrivacyClass::P1, "Derived must NEVER be P1"); + + // And end-to-end: an emitted event from a Derived snapshot must be P4/P5. + let mut snap = sample_snapshot(); + snap.trust_class = RuViewPrivacyClass::Derived; + let ev = snapshot_to_field_event(&snap, &signer()); + assert!( + ev.observation.privacy_class >= PrivacyClass::P4, + "emitted Derived event must be P4 or P5, got {:?}", + ev.observation.privacy_class + ); + assert_eq!(ev.observation.privacy_class, ev.tensor.privacy_class); +} + +/// Full §3.3 table over every RuView class → expected RuField class. +#[test] +fn gate_privacy_table_over_every_ruview_class() { + let cases = [ + (RuViewPrivacyClass::Raw, false, PrivacyClass::P0), + (RuViewPrivacyClass::Derived, false, PrivacyClass::P4), + (RuViewPrivacyClass::Derived, true, PrivacyClass::P5), + (RuViewPrivacyClass::Anonymous, false, PrivacyClass::P2), + (RuViewPrivacyClass::Restricted, false, PrivacyClass::P2), + ]; + for (ruview, id_bound, expected) in cases { + assert_eq!( + map_privacy(ruview, id_bound), + expected, + "{ruview:?} (identity_bound={id_bound}) must map to {expected:?}" + ); + } +} + +/// Fail-closed: a demoted Raw snapshot must NOT emit P0 (raw) — it floors to P2. +#[test] +fn gate_demotion_is_fail_closed() { + let mut snap = sample_snapshot(); + snap.trust_class = RuViewPrivacyClass::Raw; // would be P0 + snap.demoted = true; // governed engine demotion + let ev = snapshot_to_field_event(&snap, &signer()); + assert!( + ev.observation.privacy_class >= PrivacyClass::P2, + "demoted cycle must floor to >= P2, got {:?}", + ev.observation.privacy_class + ); +} + +#[test] +fn gate_determinism_same_seed_identical_event() { + let snap = sample_snapshot(); + let a = snapshot_to_field_event(&snap, &Signer::from_seed(SEED)); + let b = snapshot_to_field_event(&snap, &Signer::from_seed(SEED)); + assert_eq!(a, b, "same snapshot + same signer seed ⇒ identical event"); + // Including the signature (ed25519 is deterministic). + assert_eq!(a.provenance.signature_hex, b.provenance.signature_hex); +} + +#[test] +fn no_fabricated_position_when_field_absent() { + let mut snap = sample_snapshot(); + snap.signal_field = None; + let ev = snapshot_to_field_event(&snap, &signer()); + assert!(ev.observation.range_m.is_none(), "no field ⇒ no fabricated range"); + assert!(ev.observation.space_cell.is_none(), "no field ⇒ no fabricated cell"); + assert!( + ev.observation.motion_vector.is_none(), + "no field ⇒ no fabricated motion vector" + ); +} + +#[test] +fn derives_real_position_from_field_peak() { + let ev = snapshot_to_field_event(&sample_snapshot(), &signer()); + // peak at flat index 2, grid [2,1,2] (row-major) → cell [1,0,0] + assert_eq!(ev.observation.space_cell, Some([1, 0, 0])); + assert_eq!(ev.observation.range_m, Some(1.0)); +}