mirror of
https://github.com/ruvnet/RuView.git
synced 2026-10-18 21:00:22 -05:00
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:
1 parent
03a897938f
commit
3741e7ba43
8 files changed
+921
No files matched your search
Generated
+10
@@ -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"
|
||||
|
||||
@@ -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(_))
|
||||
));
|
||||
}
|
||||
Reference in new issue
Block a user