docs(spatial-evidence): ADR-382 for the RF wire contract, pin the v1 yaw convention

The spatial.evidence.v1 format is specified in WeftOS ADR-107 section 7.
ADR-382 records the part RuView emits (envelope, room_enu frame,
rf_gaussian, rf_link_observation, validation ranges, versioning) so the
contract is readable from this repository, and names ADR-107 as its origin.

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

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
Mathew Beaneandclaude-flow committed 2026-10-03 22:57:08 -04:00
1 parent 3741e7ba43
commit a1d7eb0bfd
6 files changed
+194 -9

No files matched your search

@@ -0,0 +1,137 @@
# ADR-382: `spatial.evidence.v1` export of RF Gaussians and link observations
- **Status**: proposed
- **Date**: 2026-10-03
- **Deciders**: RuView maintainers
- **Tags**: rf, gaussian, spatial, evidence, wire-format, interop
- **Related**: ADR-273 (unified RF world model), ADR-275 (RF-aware Gaussian
memory), ADR-142 (temporal voxel evidence). Origin of the wire format:
WeftOS ADR-107 §7 ("Spatial evidence engine and workspace"), amended
2026-10-03.
- **Crate**: `v2/crates/ruview-spatial-evidence`
Numbering: on 2026-10-03 `main` had up to ADR-378, open PRs claimed 379
and 380, and 381 was taken by another branch.
## Context
`ruview-unified` keeps a map of RF Gaussians (ADR-275) and a Friis channel
model that turns a TX/RX link into an expected amplitude. A spatial evidence
engine fuses many sensor types (tape measurements, radar, ToF, UWB, RF) into
an occupancy octree and promotes stable geometry (walls, furniture) into room
models. That engine reads a versioned JSONL format, `spatial.evidence.v1`,
specified in WeftOS ADR-107 §7.
RuView should be able to feed it without depending on it, and without a
reader of this repository having to go to another project to learn what the
export writes. This ADR records the part of the contract RuView emits.
## Decision
1. A leaf crate, `ruview-spatial-evidence`, converts `ruview-unified` output
to `spatial.evidence.v1` lines. Its only internal dependency is
`ruview-unified`. The wire shapes are local serde structs; the contract is
the JSON, not a shared crate.
2. It emits two record types, `rf_gaussian` and `rf_link_observation`. It
does not mirror the other v1 types and rejects them as unknown when
parsing.
3. Every record is validated against the v1 rules below before it is
serialised, so a bad value fails in RuView rather than in the consumer.
4. If WeftOS ADR-107 §7 changes the envelope or either RF body, this ADR is
amended in the same change that updates the crate.
### Envelope (every record)
| Field | Rule |
|---|---|
| `schema` | exactly `spatial.evidence.v1` |
| `type` | `rf_gaussian` or `rf_link_observation` here |
| `t_ns` | u64, ns since the Unix epoch |
| `frame` | `room_enu` |
| `region` | region id, e.g. `region/urth/meso/<room>` |
| `source_id` | device or operator id, never a person |
| `uncertainty_m` | 1σ position uncertainty, metres, in (0, 100] |
| `provenance` | `{receipt, producer, proof}`; `proof` is `MEASURED`, `CODE` or `SYNTHETIC` |
Ids (`region`, `source_id`, `receipt`, `producer`) match
`[A-Za-z0-9._:/-@]{1,128}`. A line is at most 16 KiB.
**Frame.** `room_enu` is room-local, origin at the room's SW floor corner,
x east, y north, z up, metres. Coordinates are within ±1000 m.
**Angles.** Every yaw in v1 is degrees counter-clockwise from room +x
(east): 0 faces +x, 90 faces +y. The two RF types have no yaw field. The
`rf_gaussian` orientation is a body-to-room quaternion, and a positive
rotation about +z turns the Gaussian's local x axis counter-clockwise from
east, the same sense.
### `rf_gaussian`
| Field | Rule |
|---|---|
| `position` | [x, y, z] m |
| `scale` | per-axis σ, m, each in [1e-6, 1e4] |
| `orientation` | quaternion `[w, x, y, z]`, finite, norm > 1e-6 |
| `occupancy` | peak extinction, nepers/m, in [0, 1e6] |
| `confidence` | [0, 1] |
| `motion` | `static`, `slow` or `fast` |
| `role`? | `absorber` or `reflector`; set only on priors sent back to RuView |
The exporter maps `RfGaussian` field for field. `uncertainty_m` is the
geometric mean of the three σ, clamped to the envelope range, because
`RfGaussian` has no separate position covariance. The receipt is
`gauss:<device>:<t_ns>:<index>`. A Gaussian whose provenance is `synthetic`
is always tagged `SYNTHETIC`; a caller can lower the proof tag, never raise
it.
### `rf_link_observation`
| Field | Rule |
|---|---|
| `tx`, `rx` | antenna positions, m |
| `freq_hz` | [1e8, 3e11] |
| `excess_loss_db` | `20·log10(free / measured)`, in [−60, 200]; negative is constructive multipath |
The free-space reference comes from `ruview-unified`'s channel model with an
empty map, so it is the same Friis amplitude the ADR-275 inverse update uses.
### Examples
These two lines are the WeftOS ADR-107 §7 examples. The crate's tests parse
them and check that its own output has the same shape.
```jsonl
{"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}
{"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"}
```
### Versioning
Additive optional fields may appear inside v1 and older readers ignore them.
New record types may also be added inside v1 (the 2026-10-03 amendment added
`tof_depth` and `radar_range`, and an optional `sensor` block on
`radar_track_point`); a reader that does not know a type rejects it as
unknown rather than misreading it. Removing, renaming or changing the
meaning of a field requires `spatial.evidence.v2`, and the parser reports a
v2 line as a version mismatch.
## Consequences
- RuView output can feed a spatial evidence engine with no new runtime
dependency, and the contract is readable from this repository.
- The engine can send promoted walls and objects back as `rf_gaussian`
priors with a `role`. That gives ADR-275's inverse update a geometric
starting point instead of an empty map.
- The struct definitions are duplicated between the two projects. The
golden-line tests catch drift in the RF types; a change on the WeftOS side
still needs someone to amend this ADR and the crate.
## Limitations
- Nothing calls the crate yet. The sensing server does not export records,
and there is no adapter that turns an inbound `rf_gaussian` prior into an
`RfGaussian` in the live map.
- The tests are CODE-level (deterministic conversions and validation). No
MEASURED claim is made about what the exported evidence does to a room
model.
- `uncertainty_m` for a Gaussian is a stand-in derived from its extent.
+1
View File
@@ -2,6 +2,7 @@
Latest proposed decisions:
- [ADR-382: `spatial.evidence.v1` export of RF Gaussians and link observations](ADR-382-spatial-evidence-rf-export.md)
- [ADR-378: `ruview-live` showcase views — CSI waterfall, radar fan, animated cells](ADR-378-ruview-live-showcase-views.md)
- [ADR-377: `ruview-live` — a Claude Code mod shipped inside `@ruvnet/ruview`](ADR-377-ruview-live-claude-code-mod.md)
- [ADR-376: `ruview` — one npm install for every RuView component](ADR-376-ruview-umbrella-npm-package.md)
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "ruview-spatial-evidence"
description = "Emit RuView RF Gaussians and link measurements as spatial.evidence.v1 JSONL (WeftOS ADR-107 wire contract)"
description = "Emit RuView RF Gaussians and link measurements as spatial.evidence.v1 JSONL (ADR-382)"
version.workspace = true
edition.workspace = true
authors.workspace = true
+2 -1
View File
@@ -1,7 +1,8 @@
//! # `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
//! of the WeftOS spatial evidence engine. ADR-382 records the RF part of the
//! contract in this repository; WeftOS ADR-107 §7 is its origin. 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
+12 -4
View File
@@ -1,9 +1,16 @@
//! `spatial.evidence.v1` envelope, the two RF record bodies, and the
//! boundary validation rules (WeftOS ADR-107 §7).
//! boundary validation rules (ADR-382, which records 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.
//! (`shell_measure`, `pose`, `radar_track_point`, `uwb_echo`, `imu_event`,
//! `human_confirm`, `tof_depth`, `radar_range`) is rejected by
//! [`parse_line`] here as an unknown type, which is correct for an emitter:
//! it never needs to read those lines back.
//!
//! Angles: every yaw in v1 is degrees counter-clockwise from room +x
//! (east), so 0 faces +x and 90 faces +y. The RF types carry no yaw field;
//! an `rf_gaussian` orientation is a room-ENU rotation quaternion, whose
//! rotation about +z follows the same counter-clockwise sense.
use serde::{Deserialize, Serialize};
@@ -208,7 +215,8 @@ pub struct RfGaussianRecord {
pub position: [f64; 3],
/// Per-axis σ, metres.
pub scale: [f64; 3],
/// Orientation quaternion `[w, x, y, z]`.
/// Orientation quaternion `[w, x, y, z]`, body to room ENU (a positive
/// rotation about +z turns the local x axis counter-clockwise from east).
pub orientation: [f64; 4],
/// Peak extinction, nepers/metre.
pub occupancy: f64,
@@ -1,5 +1,6 @@
//! Wire-contract tests: golden lines match the WeftOS ADR-107 §7 examples,
//! conversions mirror their sources, and invalid values never serialise.
//! Wire-contract tests: golden lines match the ADR-382 examples (copied
//! verbatim from WeftOS ADR-107 §7), conversions mirror their sources, and
//! invalid values never serialise.
use ruview_spatial_evidence::{
free_space_amplitude, gaussian_to_record, map_to_records, parse_line, to_jsonl, to_line,
@@ -9,10 +10,16 @@ use ruview_spatial_evidence::{
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.
/// ADR-382 / ADR-107 §7 example lines for the two RF types, verbatim.
const ADR_RF_LINK: &str = r#"{"schema":"spatial.evidence.v1","type":"rf_link_observation","t_ns":1759500001300000000,"frame":"room_enu","region":"region/urth/meso/test-room","source_id":"node-2","uncertainty_m":1.0,"provenance":{"receipt":"csi:node1-node2:000881","producer":"ruview-adapter@0.1","proof":"MEASURED"},"tx":[6.20,3.50,1.10],"rx":[0.20,0.20,1.10],"freq_hz":2437000000.0,"excess_loss_db":4.2}"#;
const ADR_RF_GAUSSIAN: &str = r#"{"schema":"spatial.evidence.v1","type":"rf_gaussian","t_ns":1759500002000000000,"frame":"room_enu","region":"region/urth/meso/test-room","source_id":"ruview-unified","uncertainty_m":0.5,"provenance":{"receipt":"gauss:7f3a","producer":"ruview-unified@0.3","proof":"CODE"},"position":[3.2,0.0,1.2],"scale":[0.05,1.6,1.2],"orientation":[1,0,0,0],"occupancy":0.7,"confidence":0.8,"motion":"static","role":"absorber"}"#;
/// The 2026-10-03 ADR-107 amendment's mounted-sensor lines, verbatim. This
/// crate does not mirror them, so they must fail as unknown types.
const ADR_TOF_DEPTH: &str = r#"{"schema":"spatial.evidence.v1","type":"tof_depth","t_ns":1759500004000000000,"frame":"room_enu","region":"region/urth/meso/test-room","source_id":"sen0628-1","uncertainty_m":0.02,"provenance":{"receipt":"sen0628-1:000042","producer":"sen0628-tof@0.1.0","proof":"MEASURED"},"position":[4.80,0.05,1.60],"yaw_deg":90.0,"pitch_deg":-35.0,"fov_deg":[60.0,60.0],"grid":[4,4],"range_mm":[0,0,0,0,3290,3105,3110,3302,2512,1190,1185,2530,2005,1072,1066,2011],"valid":[false,false,false,false,true,true,true,true,true,true,true,true,true,true,true,true]}"#;
const ADR_RADAR_RANGE: &str = r#"{"schema":"spatial.evidence.v1","type":"radar_range","t_ns":1759500004100000000,"frame":"room_enu","region":"region/urth/meso/test-room","source_id":"rd03e-1","uncertainty_m":0.3,"provenance":{"receipt":"rd03e-1:000007","producer":"rd-03e@0.1.0","proof":"MEASURED"},"position":[0.10,1.83,1.00],"yaw_deg":0.0,"fov_deg":[90.0,60.0],"range_m":2.45,"targets":1}"#;
const ADR_RADAR_TRACK_SENSOR: &str = r#"{"schema":"spatial.evidence.v1","type":"radar_track_point","t_ns":1759500001350000000,"frame":"room_enu","region":"region/urth/meso/test-room","source_id":"ld2450-1","uncertainty_m":0.3,"provenance":{"receipt":"ld2450-1:000124","producer":"ld2450-radar@0.1.0","proof":"MEASURED"},"track":1,"position":[2.60,1.30,0.0],"sensor":{"position":[0.50,0.50,1.80],"yaw_deg":26.0,"pitch_deg":-10.0,"elev_half_deg":35.0,"az_half_deg":60.0}}"#;
const REGION: &str = "region/urth/meso/test-room";
fn meta(
@@ -337,3 +344,34 @@ fn parse_line_rejects_wrong_version_type_overflow_and_length() {
Err(EvidenceError::LineTooLong(_))
));
}
#[test]
fn non_rf_types_are_unknown_to_this_emitter() {
for line in [ADR_TOF_DEPTH, ADR_RADAR_RANGE, ADR_RADAR_TRACK_SENSOR] {
let err = parse_line(line).expect_err("not mirrored here");
let EvidenceError::Parse(msg) = err else {
panic!("expected an unknown-type parse error, got {err:?}")
};
assert!(msg.contains("unknown variant"), "{msg}");
}
}
#[test]
fn gaussian_orientation_follows_the_v1_yaw_convention() {
// v1 yaw is counter-clockwise from room +x. A quaternion for +90° about
// +z must therefore turn the Gaussian's local x axis onto room +y, and
// reach the wire unchanged.
let h = std::f64::consts::FRAC_PI_4;
let mut g = gaussian("node-1", false);
g.orientation = [h.cos(), 0.0, 0.0, h.sin()];
let r = g.rotation();
let local_x = [r[0][0], r[1][0], r[2][0]];
for (got, want) in local_x.iter().zip([0.0, 1.0, 0.0]) {
assert!((got - want).abs() < 1e-12, "{local_x:?}");
}
let rec = gaussian_to_record(&g, 0, &export(ProofTag::Code)).unwrap();
let RecordBody::RfGaussian(body) = &rec.body else {
panic!("wrong type")
};
assert_eq!(body.orientation, g.orientation);
}