mirror of
https://github.com/ruvnet/RuView
synced 2026-08-05 19:41:44 +00:00
f250149e94
* 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>
207 lines
8.2 KiB
Rust
207 lines
8.2 KiB
Rust
//! 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)
|
|
}
|