Files
RuView/v2/crates/ruview-spatial-evidence/tests/evidence.rs
T
Mathew Beaneandclaude-flow a1d7eb0bfd docs(spatial-evidence): ADR-382 for the RF wire contract, pin the v1 yaw convention
The spatial.evidence.v1 format is specified in WeftOS ADR-107 section 7.
ADR-382 records the part RuView emits (envelope, room_enu frame,
rf_gaussian, rf_link_observation, validation ranges, versioning) so the
contract is readable from this repository, and names ADR-107 as its origin.

ADR-107 was amended on 2026-10-03: one yaw convention (degrees
counter-clockwise from room +x), new tof_depth and radar_range types, and
an optional sensor block on radar_track_point. The two RF bodies did not
change. The crate now documents the yaw sense of an rf_gaussian
orientation and tests it (+90 degrees about +z maps local x onto room +y),
and tests that the amended example lines are rejected as unknown types by
this RF-only mirror.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-03 22:57:08 -04:00

378 lines
15 KiB
Rust

//! Wire-contract tests: golden lines match the ADR-382 examples (copied
//! verbatim from WeftOS ADR-107 §7), conversions mirror their sources, and
//! invalid values never serialise.
use ruview_spatial_evidence::{
free_space_amplitude, gaussian_to_record, map_to_records, parse_line, to_jsonl, to_line,
EvidenceError, GaussianExport, LinkMeasurement, Motion, ProofTag, Provenance, RecordBody,
RecordMeta,
};
use ruview_unified::gaussian::{GaussianMap, MotionState, Provenance as UProv, RfGaussian};
use serde_json::Value;
/// ADR-382 / ADR-107 §7 example lines for the two RF types, verbatim.
const ADR_RF_LINK: &str = r#"{"schema":"spatial.evidence.v1","type":"rf_link_observation","t_ns":1759500001300000000,"frame":"room_enu","region":"region/urth/meso/test-room","source_id":"node-2","uncertainty_m":1.0,"provenance":{"receipt":"csi:node1-node2:000881","producer":"ruview-adapter@0.1","proof":"MEASURED"},"tx":[6.20,3.50,1.10],"rx":[0.20,0.20,1.10],"freq_hz":2437000000.0,"excess_loss_db":4.2}"#;
const ADR_RF_GAUSSIAN: &str = r#"{"schema":"spatial.evidence.v1","type":"rf_gaussian","t_ns":1759500002000000000,"frame":"room_enu","region":"region/urth/meso/test-room","source_id":"ruview-unified","uncertainty_m":0.5,"provenance":{"receipt":"gauss:7f3a","producer":"ruview-unified@0.3","proof":"CODE"},"position":[3.2,0.0,1.2],"scale":[0.05,1.6,1.2],"orientation":[1,0,0,0],"occupancy":0.7,"confidence":0.8,"motion":"static","role":"absorber"}"#;
/// The 2026-10-03 ADR-107 amendment's mounted-sensor lines, verbatim. This
/// crate does not mirror them, so they must fail as unknown types.
const ADR_TOF_DEPTH: &str = r#"{"schema":"spatial.evidence.v1","type":"tof_depth","t_ns":1759500004000000000,"frame":"room_enu","region":"region/urth/meso/test-room","source_id":"sen0628-1","uncertainty_m":0.02,"provenance":{"receipt":"sen0628-1:000042","producer":"sen0628-tof@0.1.0","proof":"MEASURED"},"position":[4.80,0.05,1.60],"yaw_deg":90.0,"pitch_deg":-35.0,"fov_deg":[60.0,60.0],"grid":[4,4],"range_mm":[0,0,0,0,3290,3105,3110,3302,2512,1190,1185,2530,2005,1072,1066,2011],"valid":[false,false,false,false,true,true,true,true,true,true,true,true,true,true,true,true]}"#;
const ADR_RADAR_RANGE: &str = r#"{"schema":"spatial.evidence.v1","type":"radar_range","t_ns":1759500004100000000,"frame":"room_enu","region":"region/urth/meso/test-room","source_id":"rd03e-1","uncertainty_m":0.3,"provenance":{"receipt":"rd03e-1:000007","producer":"rd-03e@0.1.0","proof":"MEASURED"},"position":[0.10,1.83,1.00],"yaw_deg":0.0,"fov_deg":[90.0,60.0],"range_m":2.45,"targets":1}"#;
const ADR_RADAR_TRACK_SENSOR: &str = r#"{"schema":"spatial.evidence.v1","type":"radar_track_point","t_ns":1759500001350000000,"frame":"room_enu","region":"region/urth/meso/test-room","source_id":"ld2450-1","uncertainty_m":0.3,"provenance":{"receipt":"ld2450-1:000124","producer":"ld2450-radar@0.1.0","proof":"MEASURED"},"track":1,"position":[2.60,1.30,0.0],"sensor":{"position":[0.50,0.50,1.80],"yaw_deg":26.0,"pitch_deg":-10.0,"elev_half_deg":35.0,"az_half_deg":60.0}}"#;
const REGION: &str = "region/urth/meso/test-room";
fn meta(
t_ns: u64,
source: &str,
unc: f64,
receipt: &str,
producer: &str,
proof: ProofTag,
) -> RecordMeta {
RecordMeta {
t_ns,
region: REGION.into(),
source_id: source.into(),
uncertainty_m: unc,
provenance: Provenance {
receipt: receipt.into(),
producer: producer.into(),
proof,
},
}
}
/// Parse JSON with every number as f64, so `1` and `1.0` compare equal.
fn json(s: &str) -> Value {
fn norm(v: Value) -> Value {
match v {
Value::Number(n) => Value::from(n.as_f64().expect("finite")),
Value::Array(a) => Value::Array(a.into_iter().map(norm).collect()),
Value::Object(o) => Value::Object(o.into_iter().map(|(k, v)| (k, norm(v))).collect()),
other => other,
}
}
norm(serde_json::from_str(s).expect("json"))
}
fn gaussian(device: &str, synthetic: bool) -> RfGaussian {
RfGaussian::new(
[3.2, 0.0, 1.2],
[0.05, 1.6, 1.2],
[1.0, 0.0, 0.0, 0.0],
0.7,
0.8,
1_759_500_002_000_000_000,
300.0,
UProv {
device_id: device.into(),
model_version: 3,
synthetic,
},
)
.expect("valid gaussian")
}
fn export(proof: ProofTag) -> GaussianExport {
GaussianExport {
region: REGION.into(),
producer: "ruview-unified@0.3".into(),
proof,
}
}
#[test]
fn adr_examples_parse_and_reserialise_to_the_same_shape() {
for line in [ADR_RF_LINK, ADR_RF_GAUSSIAN] {
let rec = parse_line(line).expect("ADR example parses");
assert_eq!(json(&to_line(&rec).unwrap()), json(line));
}
}
#[test]
fn golden_rf_link_line_matches_adr_example() {
let m = LinkMeasurement {
tx: [6.20, 3.50, 1.10],
rx: [0.20, 0.20, 1.10],
freq_hz: 2_437_000_000.0,
measured_amplitude: 1.0,
free_space_amplitude: 10f64.powf(4.2 / 20.0),
};
let rec = m
.to_record(meta(
1_759_500_001_300_000_000,
"node-2",
1.0,
"csi:node1-node2:000881",
"ruview-adapter@0.1",
ProofTag::Measured,
))
.unwrap();
let mut got = json(&to_line(&rec).unwrap());
// Float round-off in 20·log10(10^(4.2/20)): compare that field numerically.
let loss = got["excess_loss_db"].as_f64().unwrap();
assert!((loss - 4.2).abs() < 1e-12);
got["excess_loss_db"] = json("4.2");
assert_eq!(got, json(ADR_RF_LINK));
}
#[test]
fn golden_rf_gaussian_line_matches_adr_example_except_role() {
let rec = gaussian_to_record(
&gaussian("ruview-unified", false),
0,
&export(ProofTag::Code),
)
.unwrap();
let line = to_line(&rec).unwrap();
assert_eq!(
line,
r#"{"schema":"spatial.evidence.v1","t_ns":1759500002000000000,"frame":"room_enu","region":"region/urth/meso/test-room","source_id":"ruview-unified","uncertainty_m":0.45788569702133275,"provenance":{"receipt":"gauss:ruview-unified:1759500002000000000:0","producer":"ruview-unified@0.3","proof":"CODE"},"type":"rf_gaussian","position":[3.2,0.0,1.2],"scale":[0.05,1.6,1.2],"orientation":[1.0,0.0,0.0,0.0],"occupancy":0.7,"confidence":0.8,"motion":"static"}"#
);
// Same field set as the ADR example once the export-only `role` and
// the per-record receipt/uncertainty are aligned.
let mut got = json(&line);
let want = json(ADR_RF_GAUSSIAN);
got["role"] = want["role"].clone();
got["provenance"]["receipt"] = want["provenance"]["receipt"].clone();
got["uncertainty_m"] = want["uncertainty_m"].clone();
assert_eq!(got, want);
}
#[test]
fn synthetic_gaussian_cannot_be_raised_to_measured() {
let rec = gaussian_to_record(&gaussian("sim", true), 0, &export(ProofTag::Measured)).unwrap();
assert_eq!(rec.provenance.proof, ProofTag::Synthetic);
let rec =
gaussian_to_record(&gaussian("esp32-node-1", false), 0, &export(ProofTag::Code)).unwrap();
assert_eq!(rec.provenance.proof, ProofTag::Code);
}
#[test]
fn device_id_is_sanitised_into_a_valid_source_id() {
let rec = gaussian_to_record(
&gaussian("node 1 (kitchen)", false),
4,
&export(ProofTag::Code),
)
.unwrap();
assert_eq!(rec.source_id, "node-1--kitchen-");
let rec = gaussian_to_record(&gaussian("", false), 0, &export(ProofTag::Code)).unwrap();
assert_eq!(rec.source_id, "ruview-unified");
assert!(to_line(&rec).is_ok());
}
#[test]
fn map_export_mirrors_every_gaussian_and_motion_class() {
let mut map = GaussianMap::new(1.0);
let mut moving = gaussian("node-5", false);
moving.position = [1.0, 1.0, 1.0];
moving.scale = [0.3, 0.3, 0.9];
moving.motion = MotionState::Fast;
map.insert(gaussian("node-1", false));
map.insert(moving);
let recs = map_to_records(&map, &export(ProofTag::Code)).unwrap();
assert_eq!(recs.len(), map.len());
for (rec, g) in recs.iter().zip(map.gaussians()) {
let RecordBody::RfGaussian(body) = &rec.body else {
panic!("wrong type")
};
assert_eq!(body.position, g.position);
assert_eq!(body.scale, g.scale);
assert_eq!(body.orientation, g.orientation);
assert_eq!(rec.t_ns, g.timestamp_ns);
}
assert!(recs
.iter()
.any(|r| matches!(&r.body, RecordBody::RfGaussian(b) if b.motion == Motion::Fast)));
// Deterministic, newline-terminated JSONL that parses back line by line.
let doc = to_jsonl(&recs).unwrap();
assert_eq!(
doc,
to_jsonl(&map_to_records(&map, &export(ProofTag::Code)).unwrap()).unwrap()
);
// serde_json's default float parse may differ by one ulp, so compare
// the uncertainty numerically and everything else exactly.
let back: Vec<_> = doc.lines().map(|l| parse_line(l).unwrap()).collect();
assert_eq!(back.len(), recs.len());
for (b, r) in back.iter().zip(&recs) {
assert!((b.uncertainty_m - r.uncertainty_m).abs() < 1e-12);
assert_eq!(
(&b.body, &b.provenance, b.t_ns),
(&r.body, &r.provenance, r.t_ns)
);
}
}
#[test]
fn friis_reference_and_excess_loss() {
let (tx, rx, f) = ([0.4, 0.4, 0.9], [6.2, 3.46, 0.91], 2.437e9);
let d = ((5.8f64).powi(2) + 3.06f64.powi(2) + 0.01f64.powi(2)).sqrt();
let want = 299_792_458.0 / f / (4.0 * std::f64::consts::PI * d);
let free = free_space_amplitude(tx, rx, f).unwrap();
assert!((free - want).abs() / want < 1e-9);
assert_eq!(free_space_amplitude(tx, tx, f), None);
assert_eq!(free_space_amplitude(tx, rx, f64::NAN), None);
let at_free = LinkMeasurement::against_friis(tx, rx, f, free).unwrap();
assert!(at_free.excess_loss_db().unwrap().abs() < 1e-12);
let halved = LinkMeasurement::against_friis(tx, rx, f, free / 2.0).unwrap();
assert!((halved.excess_loss_db().unwrap() - 6.020_599_913).abs() < 1e-6);
let stronger = LinkMeasurement::against_friis(tx, rx, f, free * 2.0).unwrap();
assert!(stronger.excess_loss_db().unwrap() < 0.0);
}
#[test]
fn bad_link_amplitudes_and_out_of_range_loss_are_rejected() {
let base = LinkMeasurement {
tx: [0.0; 3],
rx: [1.0, 0.0, 0.0],
freq_hz: 2.437e9,
measured_amplitude: 1.0,
free_space_amplitude: 1.0,
};
let m = |measured: f64, free: f64| LinkMeasurement {
measured_amplitude: measured,
free_space_amplitude: free,
..base.clone()
};
let mk = || meta(1, "node-1", 1.0, "r-1", "p@0.1", ProofTag::Code);
assert_eq!(
m(0.0, 1.0).excess_loss_db(),
Err(EvidenceError::OutOfRange("measured_amplitude"))
);
assert_eq!(
m(f64::NAN, 1.0).excess_loss_db(),
Err(EvidenceError::NonFinite("measured_amplitude"))
);
assert_eq!(
m(1.0, -1.0).excess_loss_db(),
Err(EvidenceError::OutOfRange("free_space_amplitude"))
);
// 1e-11 below free space is 220 dB: outside the v1 range [-60, 200].
assert_eq!(
m(1e-11, 1.0).to_record(mk()),
Err(EvidenceError::OutOfRange("excess_loss_db"))
);
let far = LinkMeasurement {
tx: [0.0, 0.0, 2000.0],
..base.clone()
};
assert_eq!(far.to_record(mk()), Err(EvidenceError::OutOfRange("tx")));
let low = LinkMeasurement {
freq_hz: 1e6,
..base
};
assert_eq!(
low.to_record(mk()),
Err(EvidenceError::OutOfRange("freq_hz"))
);
}
#[test]
fn envelope_rules_match_v1() {
let link = LinkMeasurement {
tx: [0.0; 3],
rx: [1.0, 0.0, 0.0],
freq_hz: 2.437e9,
measured_amplitude: 1.0,
free_space_amplitude: 1.0,
};
let rec = |src: &str, unc: f64, receipt: &str| {
link.to_record(meta(1, src, unc, receipt, "p@0.1", ProofTag::Code))
};
assert_eq!(
rec("node 1", 1.0, "r"),
Err(EvidenceError::BadText("source_id"))
);
assert_eq!(rec("", 1.0, "r"), Err(EvidenceError::BadText("source_id")));
assert_eq!(
rec(&"a".repeat(129), 1.0, "r"),
Err(EvidenceError::BadText("source_id"))
);
assert_eq!(
rec("n", 0.0, "r"),
Err(EvidenceError::OutOfRange("uncertainty_m"))
);
assert_eq!(
rec("n", 100.5, "r"),
Err(EvidenceError::OutOfRange("uncertainty_m"))
);
assert_eq!(
rec("n", f64::INFINITY, "r"),
Err(EvidenceError::NonFinite("uncertainty_m"))
);
assert_eq!(
rec("n", 1.0, "bad\nreceipt"),
Err(EvidenceError::BadText("provenance.receipt"))
);
}
#[test]
fn parse_line_rejects_wrong_version_type_overflow_and_length() {
let v2 = ADR_RF_LINK.replace("spatial.evidence.v1", "spatial.evidence.v2");
assert_eq!(
parse_line(&v2),
Err(EvidenceError::UnknownVersion("spatial.evidence.v2".into()))
);
let unschema = ADR_RF_LINK.replace(r#""schema":"spatial.evidence.v1","#, "");
assert_eq!(
parse_line(&unschema),
Err(EvidenceError::UnknownVersion(String::new()))
);
let unknown = ADR_RF_LINK.replace("rf_link_observation", "rf_teleport");
assert!(matches!(parse_line(&unknown), Err(EvidenceError::Parse(_))));
let overflow = ADR_RF_LINK.replace("4.2}", "1e999}");
assert!(matches!(
parse_line(&overflow),
Err(EvidenceError::Parse(_))
));
let bad_quat = ADR_RF_GAUSSIAN.replace("[1,0,0,0]", "[0,0,0,0]");
assert_eq!(
parse_line(&bad_quat),
Err(EvidenceError::OutOfRange("orientation"))
);
let bad_conf = ADR_RF_GAUSSIAN.replace("\"confidence\":0.8", "\"confidence\":1.5");
assert_eq!(
parse_line(&bad_conf),
Err(EvidenceError::OutOfRange("confidence"))
);
let long = format!("{}{}", ADR_RF_LINK, " ".repeat(16 * 1024));
assert!(matches!(
parse_line(&long),
Err(EvidenceError::LineTooLong(_))
));
}
#[test]
fn non_rf_types_are_unknown_to_this_emitter() {
for line in [ADR_TOF_DEPTH, ADR_RADAR_RANGE, ADR_RADAR_TRACK_SENSOR] {
let err = parse_line(line).expect_err("not mirrored here");
let EvidenceError::Parse(msg) = err else {
panic!("expected an unknown-type parse error, got {err:?}")
};
assert!(msg.contains("unknown variant"), "{msg}");
}
}
#[test]
fn gaussian_orientation_follows_the_v1_yaw_convention() {
// v1 yaw is counter-clockwise from room +x. A quaternion for +90° about
// +z must therefore turn the Gaussian's local x axis onto room +y, and
// reach the wire unchanged.
let h = std::f64::consts::FRAC_PI_4;
let mut g = gaussian("node-1", false);
g.orientation = [h.cos(), 0.0, 0.0, h.sin()];
let r = g.rotation();
let local_x = [r[0][0], r[1][0], r[2][0]];
for (got, want) in local_x.iter().zip([0.0, 1.0, 0.0]) {
assert!((got - want).abs() < 1e-12, "{local_x:?}");
}
let rec = gaussian_to_record(&g, 0, &export(ProofTag::Code)).unwrap();
let RecordBody::RfGaussian(body) = &rec.body else {
panic!("wrong type")
};
assert_eq!(body.orientation, g.orientation);
}