feat(spatial-evidence): emit RF Gaussians and links as spatial.evidence.v1

New leaf crate ruview-spatial-evidence converts ruview-unified output
into the spatial.evidence.v1 JSONL wire format (WeftOS ADR-107 section 7):

- RfGaussian / GaussianMap -> rf_gaussian records. A Gaussian with
  synthetic provenance is always tagged SYNTHETIC; the caller can lower
  the proof tag, never raise it.
- Link measurements (tx/rx, measured vs free-space amplitude) ->
  rf_link_observation with excess_loss_db = 20 log10(free / measured).
  The Friis reference comes from ruview-unified's channel model.

The v1 envelope and the two RF bodies are mirrored with local serde
structs, so there is no WeftOS dependency. Validation follows the v1
rules: schema version, id charset and length, finite values, coordinate,
frequency, scale, occupancy, confidence and excess-loss ranges, and the
16 KiB line limit. Golden tests compare against the ADR example lines.

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
Mathew Beaneandclaude-flow committed 2026-10-03 18:18:41 -04:00
1 parent 03a897938f
commit 3741e7ba43
8 files changed
+921

No files matched your search

Generated
+10
View File
@@ -9559,6 +9559,16 @@ dependencies = [
"thiserror 2.0.18",
]
[[package]]
name = "ruview-spatial-evidence"
version = "0.3.1"
dependencies = [
"ruview-unified",
"serde",
"serde_json",
"thiserror 2.0.18",
]
[[package]]
name = "ruview-spatial-memory"
version = "0.3.1"
+3
View File
@@ -126,6 +126,9 @@ members = [
# ADR-368 — portable compute kernel for the `@ruvnet/ruview-kernel` npm
# package: one bounded JSON ABI, wasm32 (zero-import) + napi-rs transports.
"crates/ruview-kernel",
# spatial.evidence.v1 emitter (WeftOS ADR-107 wire contract): RF Gaussians
# and link measurements as JSONL evidence. Leaf over ruview-unified.
"crates/ruview-spatial-evidence",
# ADR-348 — RuForecast extracted to the ruvnet/RuForecast submodule
# (see crates/ruforecast in the exclude list below); path-deps only.
]
@@ -0,0 +1,25 @@
[package]
name = "ruview-spatial-evidence"
description = "Emit RuView RF Gaussians and link measurements as spatial.evidence.v1 JSONL (WeftOS ADR-107 wire contract)"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
# Standalone emitter: the v1 wire shapes are mirrored here with local serde
# structs, so this crate never depends on the WeftOS consumer. The only
# internal dependency is `ruview-unified`, the source of the Gaussians and
# of the Friis free-space reference.
[dependencies]
ruview-unified = { path = "../ruview-unified" }
serde = { workspace = true, features = ["derive"] }
serde_json.workspace = true
thiserror.workspace = true
[lints.rust]
unsafe_code = "forbid"
missing_docs = "warn"
[lints.clippy]
all = "warn"
@@ -0,0 +1,95 @@
//! `RfGaussian` / `GaussianMap` to `rf_gaussian` records.
use ruview_unified::gaussian::{GaussianMap, MotionState, RfGaussian};
use crate::wire::{
sanitize_id, EvidenceError, EvidenceRecord, Motion, ProofTag, Provenance, RecordBody,
RecordMeta, RfGaussianRecord,
};
/// Source id used when a Gaussian's device id sanitises to nothing.
const FALLBACK_SOURCE: &str = "ruview-unified";
/// Bounds for the envelope uncertainty derived from a Gaussian's scale.
const MIN_UNCERTAINTY_M: f64 = 1e-3;
const MAX_UNCERTAINTY_M: f64 = 100.0;
/// Caller context for exporting Gaussians.
#[derive(Debug, Clone, PartialEq)]
pub struct GaussianExport {
/// Urth region id the room frame belongs to.
pub region: String,
/// Producer id written into provenance.
pub producer: String,
/// Strongest proof tag the caller claims. A Gaussian with synthetic
/// RuView provenance is emitted as `SYNTHETIC` regardless.
pub proof: ProofTag,
}
fn motion(m: MotionState) -> Motion {
match m {
MotionState::Static => Motion::Static,
MotionState::Slow => Motion::Slow,
MotionState::Fast => Motion::Fast,
}
}
/// Envelope uncertainty for a Gaussian: the geometric mean of its three
/// σ, clamped to the v1 range. `RfGaussian` has no separate position
/// covariance, so its extent is the honest stand-in.
fn uncertainty(scale: [f64; 3]) -> f64 {
let gm = (scale[0] * scale[1] * scale[2]).cbrt();
gm.clamp(MIN_UNCERTAINTY_M, MAX_UNCERTAINTY_M)
}
/// Convert one Gaussian. `index` is its position in the map and makes the
/// receipt unique within one export.
pub fn gaussian_to_record(
g: &RfGaussian,
index: usize,
ctx: &GaussianExport,
) -> Result<EvidenceRecord, EvidenceError> {
let source_id = sanitize_id(&g.provenance.device_id, FALLBACK_SOURCE);
let proof = if g.provenance.synthetic {
ProofTag::Synthetic
} else {
ctx.proof
};
let receipt = sanitize_id(
&format!("gauss:{source_id}:{}:{index}", g.timestamp_ns),
"gauss",
);
let meta = RecordMeta {
t_ns: g.timestamp_ns,
region: ctx.region.clone(),
source_id,
uncertainty_m: uncertainty(g.scale),
provenance: Provenance {
receipt,
producer: ctx.producer.clone(),
proof,
},
};
let body = RfGaussianRecord {
position: g.position,
scale: g.scale,
orientation: g.orientation,
occupancy: g.occupancy,
confidence: g.confidence,
motion: motion(g.motion),
role: None,
};
EvidenceRecord::new(meta, RecordBody::RfGaussian(body))
}
/// Convert every Gaussian in a map, in the map's storage order. Fails on
/// the first Gaussian that does not satisfy the v1 rules.
pub fn map_to_records(
map: &GaussianMap,
ctx: &GaussianExport,
) -> Result<Vec<EvidenceRecord>, EvidenceError> {
map.gaussians()
.iter()
.enumerate()
.map(|(i, g)| gaussian_to_record(g, i, ctx))
.collect()
}
@@ -0,0 +1,35 @@
//! # `ruview-spatial-evidence` — RuView output as `spatial.evidence.v1` JSONL
//!
//! `spatial.evidence.v1` is the versioned, one-record-per-line wire format
//! of the WeftOS spatial evidence engine (WeftOS ADR-107 §7). This crate is
//! the RuView-side emitter for the two RF record types RuView can produce:
//!
//! - [`gaussian`]: `ruview_unified` [`RfGaussian`](ruview_unified::gaussian::RfGaussian)s
//! (one by one or a whole [`GaussianMap`](ruview_unified::gaussian::GaussianMap))
//! become `rf_gaussian` records.
//! - [`link`]: a TX/RX link with a measured and a free-space amplitude
//! becomes an `rf_link_observation` record carrying `excess_loss_db`.
//!
//! The wire shapes live in [`wire`] as local serde structs. Nothing here
//! depends on WeftOS: the contract is the JSON, not a shared crate.
//! Every record is validated against the v1 rules before it is serialised
//! ([`to_line`]), so a bad value fails here rather than in the consumer.
//!
//! ## Proof tags
//!
//! Each record carries `provenance.proof` (`MEASURED` / `CODE` /
//! `SYNTHETIC`). A Gaussian whose RuView provenance says `synthetic` is
//! always emitted as `SYNTHETIC`, whatever the caller asks for; the tag can
//! be lowered by the caller, never raised.
pub mod gaussian;
pub mod link;
pub mod wire;
pub use gaussian::{gaussian_to_record, map_to_records, GaussianExport};
pub use link::{free_space_amplitude, LinkMeasurement};
pub use wire::{
parse_line, to_jsonl, to_line, EvidenceError, EvidenceRecord, Frame, Motion, ProofTag,
Provenance, RecordBody, RecordMeta, RfGaussianRecord, RfLinkObservation, RfRole,
MAX_LINE_BYTES, SCHEMA_V1,
};
@@ -0,0 +1,81 @@
//! Link measurements to `rf_link_observation` records.
use ruview_unified::gaussian::{channel_gain, GaussianMap};
use crate::wire::{EvidenceError, EvidenceRecord, RecordBody, RecordMeta, RfLinkObservation};
/// Friis free-space amplitude `λ / (4πd)` between two antennas, taken from
/// `ruview_unified`'s channel model with an empty map so the reference is
/// exactly the one RuView's inverse update uses. `None` when the antennas
/// coincide or the frequency is not a positive finite number.
pub fn free_space_amplitude(tx: [f64; 3], rx: [f64; 3], freq_hz: f64) -> Option<f64> {
let d2: f64 = (0..3).map(|i| (rx[i] - tx[i]).powi(2)).sum();
if !(d2.is_finite() && d2 > 1e-18 && freq_hz.is_finite() && freq_hz > 0.0) {
return None;
}
Some(channel_gain(&GaussianMap::new(1.0), tx, rx, freq_hz).norm())
}
/// One link: geometry plus measured and free-space amplitudes (linear,
/// same units).
#[derive(Debug, Clone, PartialEq)]
pub struct LinkMeasurement {
/// Transmitter antenna position, metres.
pub tx: [f64; 3],
/// Receiver antenna position, metres.
pub rx: [f64; 3],
/// Carrier frequency, Hz.
pub freq_hz: f64,
/// Measured amplitude.
pub measured_amplitude: f64,
/// Free-space (reference) amplitude.
pub free_space_amplitude: f64,
}
impl LinkMeasurement {
/// A link whose reference is the Friis amplitude for its geometry.
pub fn against_friis(
tx: [f64; 3],
rx: [f64; 3],
freq_hz: f64,
measured_amplitude: f64,
) -> Result<Self, EvidenceError> {
let free = free_space_amplitude(tx, rx, freq_hz)
.ok_or(EvidenceError::OutOfRange("link geometry"))?;
Ok(Self {
tx,
rx,
freq_hz,
measured_amplitude,
free_space_amplitude: free,
})
}
/// `20·log10(free / measured)`: positive when the link is weaker than
/// free space. Both amplitudes must be positive and finite.
pub fn excess_loss_db(&self) -> Result<f64, EvidenceError> {
for (v, field) in [
(self.measured_amplitude, "measured_amplitude"),
(self.free_space_amplitude, "free_space_amplitude"),
] {
if !v.is_finite() {
return Err(EvidenceError::NonFinite(field));
}
if v <= 0.0 {
return Err(EvidenceError::OutOfRange(field));
}
}
Ok(20.0 * (self.free_space_amplitude / self.measured_amplitude).log10())
}
/// Build a validated `rf_link_observation` record.
pub fn to_record(&self, meta: RecordMeta) -> Result<EvidenceRecord, EvidenceError> {
let body = RfLinkObservation {
tx: self.tx,
rx: self.rx,
freq_hz: self.freq_hz,
excess_loss_db: self.excess_loss_db()?,
};
EvidenceRecord::new(meta, RecordBody::RfLinkObservation(body))
}
}
@@ -0,0 +1,333 @@
//! `spatial.evidence.v1` envelope, the two RF record bodies, and the
//! boundary validation rules (WeftOS ADR-107 §7).
//!
//! Only the RF types RuView emits are mirrored. A line of any other `type`
//! is rejected by [`parse_line`] here, which is correct for an emitter: it
//! never needs to read shell, pose or radar lines back.
use serde::{Deserialize, Serialize};
/// The only schema string v1 accepts.
pub const SCHEMA_V1: &str = "spatial.evidence.v1";
/// Longest accepted line, bytes.
pub const MAX_LINE_BYTES: usize = 16 * 1024;
const MAX_COORD_M: f64 = 1_000.0;
const MAX_UNCERTAINTY_M: f64 = 100.0;
const MAX_ID_LEN: usize = 128;
/// Why a record was rejected.
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
pub enum EvidenceError {
/// Not JSON, wrong shape, or an unknown `type`.
#[error("parse error: {0}")]
Parse(String),
/// `schema` missing or not `spatial.evidence.v1`.
#[error("unknown schema version {0:?}")]
UnknownVersion(String),
/// A numeric field is NaN or infinite.
#[error("non-finite value in {0}")]
NonFinite(&'static str),
/// A numeric field is outside its accepted range.
#[error("value out of range in {0}")]
OutOfRange(&'static str),
/// An id is empty, too long, or has disallowed characters.
#[error("invalid text in {0}")]
BadText(&'static str),
/// Line exceeds [`MAX_LINE_BYTES`].
#[error("line is {0} bytes, max {MAX_LINE_BYTES}")]
LineTooLong(usize),
}
type R = Result<(), EvidenceError>;
/// Coordinate frame. v1 has one: room-local ENU, origin at the room's SW
/// floor corner, x east, y north, z up, metres.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Frame {
/// Room ENU.
RoomEnu,
}
/// Proof discipline tag, ordered weakest to strongest.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum ProofTag {
/// Simulated or generated.
Synthetic,
/// Derived by code from other evidence.
Code,
/// Direct physical measurement with a reproducer.
Measured,
}
/// Where a record came from.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Provenance {
/// Unique receipt id for this record.
pub receipt: String,
/// Tool that produced the line (`ruview-spatial-evidence@0.1`, ...).
pub producer: String,
/// MEASURED / CODE / SYNTHETIC.
pub proof: ProofTag,
}
/// One evidence line: the common envelope plus a flattened typed body.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct EvidenceRecord {
/// Always [`SCHEMA_V1`].
pub schema: String,
/// Observation time, ns since the Unix epoch.
pub t_ns: u64,
/// Coordinate frame.
pub frame: Frame,
/// Urth region id (`region/urth/meso/<room>`).
pub region: String,
/// Device or operator id (never a person).
pub source_id: String,
/// 1σ position uncertainty, metres, in (0, 100].
pub uncertainty_m: f64,
/// Provenance envelope.
pub provenance: Provenance,
/// `type` tag and type-specific fields.
#[serde(flatten)]
pub body: RecordBody,
}
/// Envelope fields a caller supplies for one record.
#[derive(Debug, Clone, PartialEq)]
pub struct RecordMeta {
/// Observation time, ns since the Unix epoch.
pub t_ns: u64,
/// Urth region id.
pub region: String,
/// Device id.
pub source_id: String,
/// 1σ position uncertainty, metres.
pub uncertainty_m: f64,
/// Provenance envelope.
pub provenance: Provenance,
}
impl EvidenceRecord {
/// Assemble a v1 record and validate it.
pub fn new(meta: RecordMeta, body: RecordBody) -> Result<Self, EvidenceError> {
let rec = Self {
schema: SCHEMA_V1.to_string(),
t_ns: meta.t_ns,
frame: Frame::RoomEnu,
region: meta.region,
source_id: meta.source_id,
uncertainty_m: meta.uncertainty_m,
provenance: meta.provenance,
body,
};
rec.validate()?;
Ok(rec)
}
/// Check every field against the v1 rules.
pub fn validate(&self) -> R {
if self.schema != SCHEMA_V1 {
return Err(EvidenceError::UnknownVersion(self.schema.clone()));
}
id(&self.region, "region")?;
id(&self.source_id, "source_id")?;
id(&self.provenance.receipt, "provenance.receipt")?;
id(&self.provenance.producer, "provenance.producer")?;
finite(self.uncertainty_m, "uncertainty_m")?;
if !(self.uncertainty_m > 0.0 && self.uncertainty_m <= MAX_UNCERTAINTY_M) {
return Err(EvidenceError::OutOfRange("uncertainty_m"));
}
match &self.body {
RecordBody::RfLinkObservation(l) => l.validate(),
RecordBody::RfGaussian(g) => g.validate(),
}
}
}
/// The record bodies this crate emits, tagged by `"type"`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum RecordBody {
/// One RF link measurement.
RfLinkObservation(RfLinkObservation),
/// One RF Gaussian (ADR-275 shape).
RfGaussian(RfGaussianRecord),
}
/// Excess attenuation over free space between two antennas.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RfLinkObservation {
/// Transmitter antenna position, metres.
pub tx: [f64; 3],
/// Receiver antenna position, metres.
pub rx: [f64; 3],
/// Carrier frequency, Hz.
pub freq_hz: f64,
/// Loss beyond Friis, dB (negative = constructive multipath).
pub excess_loss_db: f64,
}
impl RfLinkObservation {
fn validate(&self) -> R {
point(&self.tx, "tx")?;
point(&self.rx, "rx")?;
in_range(self.freq_hz, 1e8, 3e11, "freq_hz")?;
in_range(self.excess_loss_db, -60.0, 200.0, "excess_loss_db")
}
}
/// Coarse motion class (mirrors `ruview_unified::gaussian::MotionState`).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Motion {
/// Structural / furniture.
Static,
/// Breathing, posture shifts.
Slow,
/// Walking speed or faster.
Fast,
}
/// Role of a Gaussian exported as an RF prior.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RfRole {
/// Attenuates links passing through it.
Absorber,
/// Dominant reflecting surface.
Reflector,
}
/// Neutral RF Gaussian shape (the fields of `RfGaussian` that cross the wire).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RfGaussianRecord {
/// Centre, metres.
pub position: [f64; 3],
/// Per-axis σ, metres.
pub scale: [f64; 3],
/// Orientation quaternion `[w, x, y, z]`.
pub orientation: [f64; 4],
/// Peak extinction, nepers/metre.
pub occupancy: f64,
/// Confidence in `[0, 1]`.
pub confidence: f64,
/// Motion class.
pub motion: Motion,
/// Prior role; absent on records RuView emits.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub role: Option<RfRole>,
}
impl RfGaussianRecord {
fn validate(&self) -> R {
point(&self.position, "position")?;
self.scale
.iter()
.try_for_each(|s| in_range(*s, 1e-6, 1e4, "scale"))?;
self.orientation
.iter()
.try_for_each(|q| finite(*q, "orientation"))?;
let n = self.orientation.iter().map(|q| q * q).sum::<f64>().sqrt();
if n <= 1e-6 {
return Err(EvidenceError::OutOfRange("orientation"));
}
in_range(self.occupancy, 0.0, 1e6, "occupancy")?;
in_range(self.confidence, 0.0, 1.0, "confidence")
}
}
fn finite(v: f64, field: &'static str) -> R {
if v.is_finite() {
Ok(())
} else {
Err(EvidenceError::NonFinite(field))
}
}
fn in_range(v: f64, lo: f64, hi: f64, field: &'static str) -> R {
finite(v, field)?;
if (lo..=hi).contains(&v) {
Ok(())
} else {
Err(EvidenceError::OutOfRange(field))
}
}
fn point(p: &[f64; 3], field: &'static str) -> R {
p.iter()
.try_for_each(|v| in_range(*v, -MAX_COORD_M, MAX_COORD_M, field))
}
/// Ids: ASCII alphanumerics plus `._:/-@`, 1..=128 bytes.
fn id(s: &str, field: &'static str) -> R {
if is_valid_id(s) {
Ok(())
} else {
Err(EvidenceError::BadText(field))
}
}
pub(crate) fn is_id_byte(b: u8) -> bool {
b.is_ascii_alphanumeric() || b"._:/-@".contains(&b)
}
pub(crate) fn is_valid_id(s: &str) -> bool {
!s.is_empty() && s.len() <= MAX_ID_LEN && s.bytes().all(is_id_byte)
}
/// Map arbitrary text to a valid id: disallowed bytes become `-`, the
/// result is cut to 128 bytes, and empty input yields `fallback`.
pub(crate) fn sanitize_id(s: &str, fallback: &str) -> String {
let out: String = s
.bytes()
.take(MAX_ID_LEN)
.map(|b| if is_id_byte(b) { b as char } else { '-' })
.collect();
if out.is_empty() {
fallback.to_string()
} else {
out
}
}
/// Validate and serialise one record as a single JSONL line (no newline).
pub fn to_line(rec: &EvidenceRecord) -> Result<String, EvidenceError> {
rec.validate()?;
let line = serde_json::to_string(rec).map_err(|e| EvidenceError::Parse(e.to_string()))?;
if line.len() > MAX_LINE_BYTES {
return Err(EvidenceError::LineTooLong(line.len()));
}
Ok(line)
}
/// Serialise records as a JSONL document, one line each with a trailing
/// newline. Fails on the first invalid record, so no partial file is built.
pub fn to_jsonl(records: &[EvidenceRecord]) -> Result<String, EvidenceError> {
let mut out = String::new();
for rec in records {
out.push_str(&to_line(rec)?);
out.push('\n');
}
Ok(out)
}
/// Parse and validate one line of a type this crate mirrors.
pub fn parse_line(line: &str) -> Result<EvidenceRecord, EvidenceError> {
if line.len() > MAX_LINE_BYTES {
return Err(EvidenceError::LineTooLong(line.len()));
}
let value: serde_json::Value =
serde_json::from_str(line).map_err(|e| EvidenceError::Parse(e.to_string()))?;
match value.get("schema").and_then(|s| s.as_str()) {
Some(SCHEMA_V1) => {}
Some(other) => return Err(EvidenceError::UnknownVersion(other.to_string())),
None => return Err(EvidenceError::UnknownVersion(String::new())),
}
let rec: EvidenceRecord =
serde_json::from_value(value).map_err(|e| EvidenceError::Parse(e.to_string()))?;
rec.validate()?;
Ok(rec)
}
@@ -0,0 +1,339 @@
//! Wire-contract tests: golden lines match the WeftOS ADR-107 §7 examples,
//! 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-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"}"#;
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(_))
));
}