mirror of
https://github.com/ruvnet/RuView.git
synced 2026-08-26 02:04:55 +00:00
ADR-262 §4 lists multi-modality as P4 and §8 question 5 leaves the choice of the second modality open, asking whether it should be `rvcsi`. This is a different answer, and the reason is that the cost collapsed. `rufield-adapters` now ships `UltrasonicReplayAdapter` — the first adapter for `Modality::Ultrasonic`, registry code 7, which had sat in the §8 registry since v0.1 with nothing implementing it. It parses, validates and signs BatVu (https://github.com/ruvnet/batvu) range profiles upstream. RuView does not have to build any of that. It has to decide what it is willing to put on a wire. ## The submodule bump `vendor/rufield` moves 43b1df3 -> 9955672, which is main. Two mechanical breakages in the existing bridge, both struct literals missing fields added upstream since the old pin: Observation + track_id, attributes, identity_evidence, channel_sounding_provenance SensorDescriptor + coordinate_frame, position_m, orientation_xyzw All left empty, and each for its own reason rather than because a default was convenient — the comments say which. The pose fields in particular are unset because a CSI link has no boresight and no surveyed position, and §6 is explicit that this surface makes no validated room-coordinate claim. A placeholder pose would be inventing exactly that claim. The ultrasonic path DOES set them, because a handheld scanner genuinely knows where it was pointing. The nine existing P1 gates pass unchanged against the bumped submodule. ## The decision this module actually makes RuView's egress rule is stricter than RuField's default guard — only P1/P2 leave the box — and the difference has to be structural rather than a runtime refusal. The adapter offers two output modes. The full per-bin frame is P0, which `network_egress_allowed` holds edge-local: correctly, but silently, as a dropped event at the end of a pipeline that did all the parsing and signing first. The 32-bin max-pooled reduction is P1 and egress-safe. So `ultrasonic.rs` does not offer the choice. It configures the coarse mode, because a consumer cannot un-coarsen a coarse profile whereas a runtime check can be reordered by someone who does not know why it is there. The gate still runs, and `gate_coarse_scan_passes_the_egress_gate_intact` asserts it drops nothing — a policy enforced only where it never fires is a policy nobody notices removing. ## Gates `tests/ultrasonic_gates.rs`, 12 tests, same shape as `p1_gates.rs`: round-trip and serde stability, signature-verify plus tamper-detection, fusion ingest, P1 on BOTH tensor and observation (the guard is conjunctive — a P0 tensor under a P1 observation is the composite leak it exists to close), structural unreachability of P4/P5, trust-tier refusal in both directions, determinism, and whole-file rejection of a malformed recording rather than a partial ingest. And one asserting the honest negative result: an ultrasonic scan produces NO fused inferences at all. The adapter declines to populate `presence` — one transducer pair cannot distinguish a person from a coat over the back of a chair — and the engine's feature vocabulary is entirely statements about a body, so `range_m` has nothing to drive. RuField v0.1 has no predicate for static geometry. That is asserted rather than papered over, because a demo that fabricated `presence` would light up `person_present` and look like success. ## The fixture `batvu_living_room.ultrasonic.jsonl` is not hand-written. It is produced by BatVu's own `npm run artifacts` and copied verbatim, and the same file is a fixture in ruvnet/rufield. A schema drift between BatVu's emitter and RuField's parser now fails a build in one of three repositories rather than an ingest in a deployment. ## Scope Not wired into the running server. P1 shipped as a library before P3 wired it in; this follows the same staging, so the translation and its gates are reviewable on their own. `UltrasonicScan::egress_events` returns exactly what `FieldSurface::emit` would need to broadcast. Honesty unchanged from P1, plus one that is BatVu's: every current recording is its own simulator's output, so events carry `synthetic: true` and are fusable only under simulation trust. `captured_replay()` and `production()` reject them outright, as they should. 27 tests pass (5 unit + 9 P1 + 12 P4 + 1 doc). clippy clean on the crate. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BRtoJRh2M4LV3vLcSg2nSX
261 lines
11 KiB
Rust
261 lines
11 KiB
Rust
//! ADR-262 P4 gates for the second modality.
|
|
//!
|
|
//! Same shape as `p1_gates.rs`: round-trip, fusability, privacy-safety,
|
|
//! determinism. Not accuracy — nothing here claims an ultrasonic scan is
|
|
//! *right*, only that it is well-formed, signed, correctly classified, and that
|
|
//! the things it must refuse to do, it refuses.
|
|
//!
|
|
//! The fixture is not hand-written. `batvu_living_room.ultrasonic.jsonl` is
|
|
//! produced by BatVu's own `npm run artifacts` — a 72-ping simulated sweep,
|
|
//! written by its TypeScript emitter — and copied here verbatim. The same file
|
|
//! is a test fixture in `ruvnet/rufield`, so a schema drift between BatVu's
|
|
//! emitter and RuField's parser fails a build in one of three repositories
|
|
//! rather than an ingest in a deployment.
|
|
|
|
use rufield_core::{FieldEvent, FusionEngine, InferenceQuery, Modality, PrivacyClass};
|
|
use rufield_fusion::RuFieldFusion;
|
|
use rufield_provenance::{is_fusable, verify_event};
|
|
use wifi_densepose_rufield::{
|
|
network_egress_allowed, ScanSource, UltrasonicScan, ULTRASONIC_EGRESS_CLASS,
|
|
};
|
|
|
|
const SCAN: &str = include_str!("fixtures/batvu_living_room.ultrasonic.jsonl");
|
|
|
|
fn scan() -> UltrasonicScan {
|
|
UltrasonicScan::load(SCAN, "living_room", ScanSource::Simulated).expect("fixture loads")
|
|
}
|
|
|
|
// ── round-trip ───────────────────────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn gate_round_trip_every_event_is_well_formed_and_serializes() {
|
|
let mut s = scan();
|
|
assert_eq!(s.ping_count(), 72);
|
|
assert_eq!(s.device_id(), "batvu-reference-01");
|
|
assert!(!s.calibration_id().is_empty());
|
|
|
|
let events = s.events().expect("events");
|
|
assert_eq!(events.len(), 72);
|
|
|
|
let mut previous = 0u64;
|
|
for event in &events {
|
|
event
|
|
.validate_evidence_at(event.timestamp_ns)
|
|
.expect("structural evidence invariants hold");
|
|
|
|
assert_eq!(event.tensor.modality, Modality::Ultrasonic);
|
|
assert_eq!(event.sensor.vendor, "batvu");
|
|
assert_eq!(event.observation.zone_id.as_deref(), Some("living_room"));
|
|
|
|
// Every profile value finite and non-negative. `FieldTensor::validate`
|
|
// checks only shape and axis rank, so a NaN would serialize to JSON
|
|
// `null` and then fail to deserialize as an f32 on the far side of the
|
|
// wire — the worst place to find it. The adapter rejects it at parse.
|
|
assert!(
|
|
event
|
|
.tensor
|
|
.values
|
|
.iter()
|
|
.all(|v| v.is_finite() && *v >= 0.0),
|
|
"profile values are finite and non-negative"
|
|
);
|
|
|
|
// Strictly increasing, which RuField's replay watermark requires in
|
|
// every trust mode and silently drops what does not satisfy.
|
|
assert!(event.timestamp_ns > previous);
|
|
previous = event.timestamp_ns;
|
|
|
|
// serde round-trip, byte-stable.
|
|
let json = serde_json::to_string(event).expect("serializes");
|
|
let back: FieldEvent = serde_json::from_str(&json).expect("deserializes");
|
|
assert_eq!(*event, back);
|
|
}
|
|
|
|
let mut ids: Vec<&str> = events.iter().map(|e| e.event_id.as_str()).collect();
|
|
ids.sort_unstable();
|
|
ids.dedup();
|
|
assert_eq!(ids.len(), events.len(), "event ids are unique");
|
|
}
|
|
|
|
// ── fusability ───────────────────────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn gate_every_event_carries_a_signature_that_verifies() {
|
|
let mut s = scan();
|
|
let events = s.events().expect("events");
|
|
for event in &events {
|
|
verify_event(event).expect("ed25519 signature verifies");
|
|
assert!(is_fusable(event));
|
|
}
|
|
|
|
// And tampering breaks it, so the signature is load-bearing rather than
|
|
// decorative.
|
|
let mut tampered = events[0].clone();
|
|
tampered.tensor.values[0] = 999.0;
|
|
assert!(verify_event(&tampered).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn gate_fusion_ingests_every_event() {
|
|
let mut s = scan();
|
|
let mut engine = RuFieldFusion::new();
|
|
for event in s.events().expect("events") {
|
|
engine.ingest(event).expect("fusion accepts the event");
|
|
}
|
|
}
|
|
|
|
// ── privacy safety — the correctness item ────────────────────────────────────
|
|
|
|
#[test]
|
|
fn gate_coarse_scan_passes_the_egress_gate_intact() {
|
|
// The claim the module makes: configuring the adapter for the coarse output
|
|
// means the egress gate has nothing left to catch. If a submodule bump ever
|
|
// changes the adapter's default class, this is where it surfaces.
|
|
let mut s = scan();
|
|
let all = s.events().expect("events").len();
|
|
|
|
let mut s = scan();
|
|
let egress = s.egress_events().expect("egress events").len();
|
|
|
|
assert_eq!(all, 72);
|
|
assert_eq!(
|
|
egress, all,
|
|
"no event is dropped at the gate in coarse mode"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn gate_every_event_is_p1_on_both_tensor_and_observation() {
|
|
let mut s = scan();
|
|
for event in s.events().expect("events") {
|
|
assert_eq!(event.tensor.privacy_class, ULTRASONIC_EGRESS_CLASS);
|
|
assert_eq!(event.observation.privacy_class, ULTRASONIC_EGRESS_CLASS);
|
|
// Both, because the guard is conjunctive over the pair — a P0 tensor
|
|
// under a P1 observation is exactly the composite leak the default
|
|
// guard exists to close.
|
|
assert!(network_egress_allowed(event.tensor.privacy_class, false));
|
|
assert!(network_egress_allowed(
|
|
event.observation.privacy_class,
|
|
false
|
|
));
|
|
}
|
|
assert_eq!(ULTRASONIC_EGRESS_CLASS, PrivacyClass::P1);
|
|
}
|
|
|
|
#[test]
|
|
fn gate_ultrasonic_can_never_reach_the_identity_tiers() {
|
|
// P5 is only reachable through `identity_evidence`, which
|
|
// `validate_evidence_at` restricts to BLE advertisement RSSI — so an
|
|
// ultrasonic event carrying it is a hard validation failure rather than a
|
|
// policy question. The ceiling is structural.
|
|
let mut s = scan();
|
|
for event in s.events().expect("events") {
|
|
assert!(event.tensor.privacy_class < PrivacyClass::P4);
|
|
assert!(event.observation.privacy_class < PrivacyClass::P4);
|
|
assert!(event.observation.identity_evidence.is_none());
|
|
assert!(event.observation.channel_sounding_provenance.is_none());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn gate_no_event_claims_a_person() {
|
|
// The tempting mistake, refused deliberately. `presence` is one of exactly
|
|
// six feature keys the fusion window reads, and populating it would light
|
|
// up the shipped `person_present` rule. One transducer pair cannot
|
|
// distinguish a person from a coat over the back of a chair.
|
|
let mut s = scan();
|
|
for event in s.events().expect("events") {
|
|
for forbidden in ["presence", "breathing_band", "posture_height", "transient"] {
|
|
assert!(
|
|
!event.observation.features.contains_key(forbidden),
|
|
"an ultrasonic event must not claim `{forbidden}`"
|
|
);
|
|
}
|
|
for label in &event.observation.labels {
|
|
assert!(
|
|
!label.contains("person") && !label.contains("presence"),
|
|
"unexpected personhood label: {label}"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The honest negative result, asserted rather than avoided.
|
|
///
|
|
/// Because nothing claims presence, a BatVu scan produces no fused inferences
|
|
/// under the shipped rules. Two independent reasons, and pinning both matters
|
|
/// because fixing only the first would look like progress and change nothing:
|
|
/// no rule lists `"ultrasonic"` among its inputs, and the engine's feature
|
|
/// vocabulary is entirely statements about a body, so `range_m` has nothing to
|
|
/// drive. RuField v0.1 has no predicate for static geometry.
|
|
#[test]
|
|
fn gate_no_inferences_and_that_is_the_correct_outcome() {
|
|
let mut s = scan();
|
|
let mut engine = RuFieldFusion::new();
|
|
for event in s.events().expect("events") {
|
|
engine.ingest(event).expect("ingest");
|
|
}
|
|
let inferences = engine
|
|
.infer(&InferenceQuery {
|
|
zone_id: Some("living_room".into()),
|
|
labels: vec![],
|
|
track_id: None,
|
|
as_of_ns: None,
|
|
})
|
|
.expect("infer");
|
|
assert!(
|
|
inferences.is_empty(),
|
|
"no shipped rule can fire on a range-only sensor: {inferences:?}"
|
|
);
|
|
}
|
|
|
|
// ── trust tier ───────────────────────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn gate_a_recording_cannot_relabel_itself_into_a_higher_trust_tier() {
|
|
// The fixture declares `simulated`. An operator pointing a capture-trusting
|
|
// deployment at it gets a hard refusal rather than a silently upgraded
|
|
// tier — and the reverse is refused too, so neither direction is a quiet
|
|
// reinterpretation.
|
|
let err = UltrasonicScan::load(SCAN, "living_room", ScanSource::DeviceCapture)
|
|
.expect_err("must refuse");
|
|
assert!(
|
|
err.to_string().contains("device_capture"),
|
|
"the refusal names the mismatch: {err}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn gate_simulated_recordings_are_marked_synthetic() {
|
|
// Not cosmetic: `synthetic: true` is what keeps simulator output out of
|
|
// captured-replay and production trust, which reject it before any key
|
|
// lookup. Marking it otherwise to get it accepted is the §11 invariant
|
|
// violation ADR-262 forbids.
|
|
let mut s = scan();
|
|
for event in s.events().expect("events") {
|
|
assert!(event.provenance.synthetic);
|
|
}
|
|
}
|
|
|
|
// ── determinism ──────────────────────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn gate_same_recording_yields_a_byte_identical_event_stream() {
|
|
let mut a = scan();
|
|
let mut b = scan();
|
|
let left = serde_json::to_string(&a.events().expect("events")).expect("serializes");
|
|
let right = serde_json::to_string(&b.events().expect("events")).expect("serializes");
|
|
assert_eq!(left, right);
|
|
}
|
|
|
|
#[test]
|
|
fn gate_a_malformed_recording_is_refused_whole() {
|
|
// No partial ingest. A stream that half-replays and then dies is the worst
|
|
// outcome for a consumer, because it has already acted on the good prefix.
|
|
let mut bad = SCAN.lines().take(4).collect::<Vec<_>>().join("\n");
|
|
bad.push_str("\n{\"timestamp\":1756162800,\"source\":\"simulated\",\"device_id\":\"x\"}\n");
|
|
let err =
|
|
UltrasonicScan::load(&bad, "living_room", ScanSource::Simulated).expect_err("must refuse");
|
|
assert!(err.to_string().contains("rejected"));
|
|
}
|