Files
ruvnet--RuView/v2/crates/wifi-densepose-sensing-server/tests/rufield_surface_test.rs
T
rUv df617145d6 feat(ADR-262 P3): live /api/field + /ws/field — RuView sensing speaks RuField (fail-closed egress) (#1071)
* feat(ADR-262 P3): live RuField surface — RuView sensing speaks RuField on /api/field + /ws/field

Wire the P1 `wifi-densepose-rufield` bridge into the live
`wifi-densepose-sensing-server` so the governed sensing cycle emits real
signed RuField `FieldEvent`s on two additive endpoints.

- Cargo: add the `wifi-densepose-rufield` path dep (the single coupling
  point, ADR-262 §5.4 — no new RuView-internal coupling).
- New `src/rufield_surface.rs` (kept out of the 8k-line main.rs):
  `FieldSurface` holds a dedicated ed25519 `Signer` + a bounded ring of
  recent events + the `/ws/field` broadcast topic; `GET /api/field` and
  `GET /ws/field` handlers; a standalone `router()` for isolated testing.
- Signer (defers the P2 key decision, ADR-262 §8 Q1): a STANDALONE
  dev/sensing key from `WDP_RUFIELD_SIGNING_SEED`, else a deterministic
  dev default with a logged WARN. Reusing the `cog-ha-matter` Ed25519
  key is the deferred P2 call — P3 does not pre-empt it.
- Tap: at the ESP32 governed-trust cycle (`main.rs` ~5886 observe_cycle
  / ~5938 SensingUpdate build), `emit_rufield_event` joins the cycle's
  features/classification/signal_field with the engine's
  effective_class/demoted trust state into a `SensingSnapshot` and
  surfaces it via the bridge. Existing endpoints (`/ws/sensing` etc.)
  are unchanged — purely additive.
- Privacy egress: `network_egress_allowed` is fail-closed for an
  unattended live surface — only P1/P2 leave the box; P0 raw and
  P3/P4/P5 (identity/biometric/aggregate) are held edge-local. A
  `Derived` cycle maps to P4/P5 and never surfaces.
- No-phantom: `emit` drops no-presence cycles (no fabricated events).

Gates (tests/rufield_surface_test.rs, tower::oneshot, 4/0): well-formed
signed event (WifiCsi, P2 not P1, is_fusable, real timestamp); empty
cycle → no phantom; Derived trust never surfaces; mixed stream surfaces
only egress-safe events.

Honesty (ADR-262 §0/§6): real plumbing on a live endpoint, NOT accuracy.
Single-link CSI with its existing caveats (no validated room-coordinate
accuracy); dedicated dev signing key pending the P2 ownership decision;
no accuracy claim.

Co-Authored-By: claude-flow <ruv@ruv.net>

* docs(ADR-262 P3): mark P1+P3 implemented; document /api/field + /ws/field; CHANGELOG

- ADR-262 Status → "P1 + P3 implemented"; add a P3 implementation-status
  block (tap site, endpoints, dedicated dev signer deferring the §8 Q1
  key decision, fail-closed egress, gates). Keep the honesty framing:
  real plumbing on a live endpoint, not accuracy.
- CHANGELOG [Unreleased]: add the ADR-262 P3 entry.
- user-guide: add `/api/field` to the REST table + a "RuField surface
  (ADR-262 P3)" section covering `/api/field` + `/ws/field`, the
  fail-closed P1/P2-only egress, the WDP_RUFIELD_SIGNING_SEED dev key,
  and the no-accuracy honesty note.

Co-Authored-By: claude-flow <ruv@ruv.net>

* ci: checkout submodules everywhere + Dockerfile copies vendor/rufield

Making wifi-densepose-rufield (ADR-262 bridge) a v2 workspace member means
EVERY cargo-on-workspace context must have the vendor/rufield submodule
present (cargo loads all member manifests). P1 only fixed the rust-tests
job; this adds `submodules: recursive` to all workflow checkouts that run
cargo (mqtt-integration was failing on the missing submodule manifest), and
makes Dockerfile.rust COPY vendor/rufield/ to /vendor/rufield (matches the
bridge's ../../../vendor/rufield path-dep under the collapsed Docker layout).
update-submodules.yml left alone (it manages submodules itself).

Co-Authored-By: claude-flow <ruv@ruv.net>

---------

Co-authored-by: ruv <ruvnet@gmail.com>
2026-06-14 13:55:41 -04:00

179 lines
6.9 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! ADR-262 **P3** acceptance gate — the live RuField surface.
//!
//! In-process integration test (mirrors the `/ws/sensing` / #1050 oneshot
//! style with `tower::ServiceExt::oneshot`): drives synthetic sensing cycles
//! through the real `FieldSurface` + the real `/api/field` router, and asserts:
//!
//! 1. an injected `Anonymous` (occupancy) cycle surfaces a **well-formed signed
//! `FieldEvent`** — `Modality::WifiCsi`, privacy class consistent with the
//! trust (P2, never P1), `is_fusable` (ed25519 receipt verifies), real
//! timestamp;
//! 2. an empty / no-presence cycle produces **no phantom event** (explicit
//! empty payload);
//! 3. the **privacy-safety pin** — an injected `Derived` (identity) trust state
//! never surfaces as a low-privacy event on `/api/field` (held edge-local).
//!
//! These gates are plumbing + privacy-safety, NOT accuracy (ADR-262 §0 / §6).
use std::sync::Arc;
use axum::body::Body;
use axum::http::{Request, StatusCode};
use tokio::sync::RwLock;
use tower::ServiceExt; // `oneshot`
use wifi_densepose_rufield::{is_fusable, verify_event, FieldEvent, Modality, PrivacyClass};
use wifi_densepose_sensing_server::rufield_surface::{
self, FieldState, FieldSurface, RuViewPrivacyClass, SensingClass, SensingFeatures, SignalField,
};
/// A fixed dev seed for deterministic, signed events under test.
const TEST_SEED: &[u8; 32] = b"adr262-p3-integration-test-seed!";
fn features() -> SensingFeatures {
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,
}
}
fn class(presence: bool) -> SensingClass {
SensingClass {
motion_level: if presence { "low".into() } else { "none".into() },
presence,
confidence: if presence { 0.82 } else { 0.05 },
}
}
/// A small 2×1×2 signal field with a clear peak, so the bridge derives a real
/// (non-fabricated) position from the strongest cell.
fn signal_field() -> SignalField {
SignalField {
grid_size: [2, 1, 2],
values: vec![0.1, 0.2, 0.9, 0.3], // peak at index 2
}
}
/// Build a `FieldState` + the real `/api/field` + `/ws/field` router over it.
fn surface_router() -> (FieldState, axum::Router) {
let state: FieldState = Arc::new(RwLock::new(FieldSurface::from_seed(TEST_SEED, true)));
let app = rufield_surface::router(state.clone());
(state, app)
}
/// Drive one cycle into the surface (the in-process equivalent of the live
/// sensing loop calling `emit()` per cycle).
async fn inject(state: &FieldState, trust: RuViewPrivacyClass, presence: bool, identity_bound: bool) {
let snap = rufield_surface::build_snapshot(
1_791_986_400_000_000_000,
"esp32_node_7".into(),
features(),
class(presence),
Some(signal_field()),
trust,
false, // demoted
identity_bound,
);
state.write().await.emit(&snap);
}
/// `GET /api/field` and parse the `events` array.
async fn get_field_events(app: &axum::Router) -> Vec<FieldEvent> {
let resp = app
.clone()
.oneshot(
Request::builder()
.uri("/api/field")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK, "/api/field must return 200");
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(v["spec"], "rufield");
serde_json::from_value(v["events"].clone()).expect("events array deserializes to FieldEvents")
}
#[tokio::test]
async fn gate_anonymous_cycle_surfaces_wellformed_signed_event() {
let (state, app) = surface_router();
inject(&state, RuViewPrivacyClass::Anonymous, true, false).await;
let events = get_field_events(&app).await;
assert_eq!(events.len(), 1, "one occupancy cycle ⇒ exactly one surfaced event");
let ev = &events[0];
// Well-formed: WiFi-CSI modality, real timestamp.
assert_eq!(ev.tensor.modality, Modality::WifiCsi);
assert_eq!(ev.timestamp_ns, 1_791_986_400_000_000_000);
assert!(ev.timestamp_ns > 0, "real (non-zero) timestamp");
// Privacy consistent with the injected trust: Anonymous → P2, NEVER P1.
assert_eq!(ev.observation.privacy_class, PrivacyClass::P2);
assert_ne!(ev.observation.privacy_class, PrivacyClass::P1);
// Signed + fusable: the ed25519 receipt verifies (real, non-synthetic).
assert!(!ev.provenance.synthetic, "live event is non-synthetic");
assert!(verify_event(ev).is_ok(), "ed25519 signature must verify");
assert!(is_fusable(ev), "verified receipt ⇒ fusable");
// Real position derived from the signal-field peak (not fabricated).
assert!(ev.observation.range_m.is_some(), "field peak ⇒ a real range readout");
}
#[tokio::test]
async fn gate_empty_cycle_produces_no_phantom_event() {
let (state, app) = surface_router();
// A no-presence cycle: nothing to describe.
inject(&state, RuViewPrivacyClass::Anonymous, false, false).await;
let events = get_field_events(&app).await;
assert!(
events.is_empty(),
"no-presence cycle must surface no phantom event (explicit empty payload)"
);
}
#[tokio::test]
async fn gate_derived_trust_never_surfaces_low_privacy() {
// The privacy-safety pin (ADR-262 §3.3 / §6): a Derived (identity) trust
// state maps to P4/P5 and is held edge-local — it must NEVER appear on the
// network surface, and certainly never as a low-privacy (P1/P2) event.
for identity_bound in [false, true] {
let (state, app) = surface_router();
inject(&state, RuViewPrivacyClass::Derived, true, identity_bound).await;
let events = get_field_events(&app).await;
assert!(
events.is_empty(),
"Derived cycle (identity_bound={identity_bound}) must not surface on /api/field"
);
}
}
#[tokio::test]
async fn gate_mixed_stream_surfaces_only_egress_safe_events() {
// Determinism / privacy-safety over a stream: Anonymous cycles surface,
// interleaved Derived cycles are dropped — the surface only ever carries
// egress-safe (P1/P2) events.
let (state, app) = surface_router();
inject(&state, RuViewPrivacyClass::Anonymous, true, false).await; // P2 → surfaced
inject(&state, RuViewPrivacyClass::Derived, true, false).await; // P4 → dropped
inject(&state, RuViewPrivacyClass::Anonymous, true, false).await; // P2 → surfaced
inject(&state, RuViewPrivacyClass::Derived, true, true).await; // P5 → dropped
let events = get_field_events(&app).await;
assert_eq!(events.len(), 2, "only the two Anonymous cycles surface");
for ev in &events {
assert_eq!(ev.observation.privacy_class, PrivacyClass::P2);
assert!(is_fusable(ev));
}
}