feat(ADR-262 P1): wifi-densepose-rufield bridge — RuView sensing → signed RuField FieldEvents (fail-closed privacy map) (#1070)
* 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 <ruv@ruv.net> * 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 <ruv@ruv.net>
This commit is contained in:
parent
faca0530de
commit
f250149e94
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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/`)
|
||||
|
|
|
|||
|
|
@ -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/`)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
@ -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<f32>) -> BTreeMap<String, f32> {
|
||||
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<f32>, 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<f32> = 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_<level>`); the RuField fusion engine never reads labels
|
||||
/// (`event.rs:45-48`), so this carries no identity.
|
||||
fn build_labels(snap: &SensingSnapshot) -> Vec<String> {
|
||||
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)
|
||||
}
|
||||
|
|
@ -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};
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<f64>,
|
||||
}
|
||||
|
||||
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<usize> = 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<SignalField>,
|
||||
/// 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,
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
Loading…
Reference in New Issue