diff --git a/v2/Cargo.lock b/v2/Cargo.lock index d9648e583..06fcee49e 100644 --- a/v2/Cargo.lock +++ b/v2/Cargo.lock @@ -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" diff --git a/v2/Cargo.toml b/v2/Cargo.toml index 284a32bf7..978a6c9f0 100644 --- a/v2/Cargo.toml +++ b/v2/Cargo.toml @@ -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. ] diff --git a/v2/crates/ruview-spatial-evidence/Cargo.toml b/v2/crates/ruview-spatial-evidence/Cargo.toml new file mode 100644 index 000000000..f2e3024cb --- /dev/null +++ b/v2/crates/ruview-spatial-evidence/Cargo.toml @@ -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" diff --git a/v2/crates/ruview-spatial-evidence/src/gaussian.rs b/v2/crates/ruview-spatial-evidence/src/gaussian.rs new file mode 100644 index 000000000..6c0773a51 --- /dev/null +++ b/v2/crates/ruview-spatial-evidence/src/gaussian.rs @@ -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 { + 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, EvidenceError> { + map.gaussians() + .iter() + .enumerate() + .map(|(i, g)| gaussian_to_record(g, i, ctx)) + .collect() +} diff --git a/v2/crates/ruview-spatial-evidence/src/lib.rs b/v2/crates/ruview-spatial-evidence/src/lib.rs new file mode 100644 index 000000000..d443d8e02 --- /dev/null +++ b/v2/crates/ruview-spatial-evidence/src/lib.rs @@ -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, +}; diff --git a/v2/crates/ruview-spatial-evidence/src/link.rs b/v2/crates/ruview-spatial-evidence/src/link.rs new file mode 100644 index 000000000..2bb4b4c58 --- /dev/null +++ b/v2/crates/ruview-spatial-evidence/src/link.rs @@ -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 { + 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 { + 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 { + 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 { + 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)) + } +} diff --git a/v2/crates/ruview-spatial-evidence/src/wire.rs b/v2/crates/ruview-spatial-evidence/src/wire.rs new file mode 100644 index 000000000..8861079a2 --- /dev/null +++ b/v2/crates/ruview-spatial-evidence/src/wire.rs @@ -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/`). + 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 { + 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, +} + +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::().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 { + 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 { + 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 { + 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) +} diff --git a/v2/crates/ruview-spatial-evidence/tests/evidence.rs b/v2/crates/ruview-spatial-evidence/tests/evidence.rs new file mode 100644 index 000000000..3ca2f4cfc --- /dev/null +++ b/v2/crates/ruview-spatial-evidence/tests/evidence.rs @@ -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(_)) + )); +}