diff --git a/docs/adr/ADR-382-spatial-evidence-rf-export.md b/docs/adr/ADR-382-spatial-evidence-rf-export.md new file mode 100644 index 000000000..63aa6c979 --- /dev/null +++ b/docs/adr/ADR-382-spatial-evidence-rf-export.md @@ -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/` | +| `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:::`. 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. diff --git a/docs/adr/README.md b/docs/adr/README.md index c5051feaa..34445881c 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -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) diff --git a/v2/crates/ruview-spatial-evidence/Cargo.toml b/v2/crates/ruview-spatial-evidence/Cargo.toml index f2e3024cb..538d0d8fc 100644 --- a/v2/crates/ruview-spatial-evidence/Cargo.toml +++ b/v2/crates/ruview-spatial-evidence/Cargo.toml @@ -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 diff --git a/v2/crates/ruview-spatial-evidence/src/lib.rs b/v2/crates/ruview-spatial-evidence/src/lib.rs index d443d8e02..11528051a 100644 --- a/v2/crates/ruview-spatial-evidence/src/lib.rs +++ b/v2/crates/ruview-spatial-evidence/src/lib.rs @@ -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 diff --git a/v2/crates/ruview-spatial-evidence/src/wire.rs b/v2/crates/ruview-spatial-evidence/src/wire.rs index 8861079a2..6f1efd810 100644 --- a/v2/crates/ruview-spatial-evidence/src/wire.rs +++ b/v2/crates/ruview-spatial-evidence/src/wire.rs @@ -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, diff --git a/v2/crates/ruview-spatial-evidence/tests/evidence.rs b/v2/crates/ruview-spatial-evidence/tests/evidence.rs index 3ca2f4cfc..49f4a2c70 100644 --- a/v2/crates/ruview-spatial-evidence/tests/evidence.rs +++ b/v2/crates/ruview-spatial-evidence/tests/evidence.rs @@ -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); +}