mirror of
https://github.com/ruvnet/RuView.git
synced 2026-10-18 21:00:22 -05:00
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:
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.
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
Latest proposed decisions:
|
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-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-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)
|
- [ADR-376: `ruview` — one npm install for every RuView component](ADR-376-ruview-umbrella-npm-package.md)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "ruview-spatial-evidence"
|
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
|
version.workspace = true
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
authors.workspace = true
|
authors.workspace = true
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
//! # `ruview-spatial-evidence` — RuView output as `spatial.evidence.v1` JSONL
|
//! # `ruview-spatial-evidence` — RuView output as `spatial.evidence.v1` JSONL
|
||||||
//!
|
//!
|
||||||
//! `spatial.evidence.v1` is the versioned, one-record-per-line wire format
|
//! `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:
|
//! the RuView-side emitter for the two RF record types RuView can produce:
|
||||||
//!
|
//!
|
||||||
//! - [`gaussian`]: `ruview_unified` [`RfGaussian`](ruview_unified::gaussian::RfGaussian)s
|
//! - [`gaussian`]: `ruview_unified` [`RfGaussian`](ruview_unified::gaussian::RfGaussian)s
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
//! `spatial.evidence.v1` envelope, the two RF record bodies, and the
|
//! `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`
|
//! 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
|
//! (`shell_measure`, `pose`, `radar_track_point`, `uwb_echo`, `imu_event`,
|
||||||
//! never needs to read shell, pose or radar lines back.
|
//! `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};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
@@ -208,7 +215,8 @@ pub struct RfGaussianRecord {
|
|||||||
pub position: [f64; 3],
|
pub position: [f64; 3],
|
||||||
/// Per-axis σ, metres.
|
/// Per-axis σ, metres.
|
||||||
pub scale: [f64; 3],
|
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],
|
pub orientation: [f64; 4],
|
||||||
/// Peak extinction, nepers/metre.
|
/// Peak extinction, nepers/metre.
|
||||||
pub occupancy: f64,
|
pub occupancy: f64,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
//! Wire-contract tests: golden lines match the WeftOS ADR-107 §7 examples,
|
//! Wire-contract tests: golden lines match the ADR-382 examples (copied
|
||||||
//! conversions mirror their sources, and invalid values never serialise.
|
//! verbatim from WeftOS ADR-107 §7), conversions mirror their sources, and
|
||||||
|
//! invalid values never serialise.
|
||||||
|
|
||||||
use ruview_spatial_evidence::{
|
use ruview_spatial_evidence::{
|
||||||
free_space_amplitude, gaussian_to_record, map_to_records, parse_line, to_jsonl, to_line,
|
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 ruview_unified::gaussian::{GaussianMap, MotionState, Provenance as UProv, RfGaussian};
|
||||||
use serde_json::Value;
|
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_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 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";
|
const REGION: &str = "region/urth/meso/test-room";
|
||||||
|
|
||||||
fn meta(
|
fn meta(
|
||||||
@@ -337,3 +344,34 @@ fn parse_line_rejects_wrong_version_type_overflow_and_length() {
|
|||||||
Err(EvidenceError::LineTooLong(_))
|
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);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user