mirror of
https://github.com/ruvnet/RuView.git
synced 2026-09-01 04:55:54 +00:00
Merge pull request #1579 from ruvnet/claude/adr-288-290-sota-gaps
ADR-288/289/290/291: benchmark harness, wideband CSI ingest, vitals ground-truth rig, wifi-veil integration
This commit is contained in:
53
.github/workflows/csi-data-policy.yml
vendored
Normal file
53
.github/workflows/csi-data-policy.yml
vendored
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
name: CSI data policy (ADR-299)
|
||||||
|
|
||||||
|
# ADR-299 repository CSI data-incident guard. Fails when CSI-format files
|
||||||
|
# (*.csi.jsonl / *.csi.meta.json) or oversized JSONL captures are tracked in
|
||||||
|
# git. Raw CSI is person data and must never be committed (CLAUDE.md, ADR-299).
|
||||||
|
#
|
||||||
|
# NOTE: the tree currently still contains the pre-existing incident recordings
|
||||||
|
# under data/recordings/ and v2/data/recordings/, whose removal is gated on
|
||||||
|
# data-owner sign-off (ADR-299). Until they are removed this job is EXPECTED to
|
||||||
|
# fail, and that failure documents the incident. To make it green in a
|
||||||
|
# follow-up without weakening the guard for NEW files, set CSI_POLICY_BASELINE
|
||||||
|
# to a file listing the acknowledged paths (see the script header).
|
||||||
|
#
|
||||||
|
# Checker: scripts/csi-data-policy-check.sh Run locally: bash the same script.
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- master
|
||||||
|
pull_request:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
csi-data-policy:
|
||||||
|
name: CSI data policy check
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Self-test the policy checker (deterministic, offline)
|
||||||
|
run: bash scripts/csi-data-policy-check.sh --self-test
|
||||||
|
|
||||||
|
- name: Enforce CSI data policy on tracked files
|
||||||
|
# CSI_POLICY_BASELINE can point at an acknowledged-paths file once the
|
||||||
|
# owner remediates the tree; unset here so a regression fails loudly.
|
||||||
|
run: bash scripts/csi-data-policy-check.sh --tracked
|
||||||
|
|
||||||
|
- name: Summarize result
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
{
|
||||||
|
echo '### CSI data policy (ADR-299)'
|
||||||
|
echo ''
|
||||||
|
echo '```'
|
||||||
|
bash scripts/csi-data-policy-check.sh --tracked 2>&1 || true
|
||||||
|
echo '```'
|
||||||
|
} >> "$GITHUB_STEP_SUMMARY"
|
||||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -28,8 +28,13 @@ firmware/esp32-csi-node/test/*.obj
|
|||||||
# Claude Flow swarm runtime state
|
# Claude Flow swarm runtime state
|
||||||
.swarm/
|
.swarm/
|
||||||
|
|
||||||
# CSI recordings (local training data, machine-specific)
|
# CSI recordings (local training/capture data — CSI is person data per
|
||||||
|
# CLAUDE.md; never commit). Covers current and legacy layouts. See ADR-299.
|
||||||
|
data/recordings/
|
||||||
|
v2/data/recordings/
|
||||||
rust-port/wifi-densepose-rs/data/recordings/
|
rust-port/wifi-densepose-rs/data/recordings/
|
||||||
|
**/*.csi.jsonl
|
||||||
|
**/*.csi.meta.json
|
||||||
|
|
||||||
# NVS partition images and CSVs (contain WiFi credentials)
|
# NVS partition images and CSVs (contain WiFi credentials)
|
||||||
nvs.bin
|
nvs.bin
|
||||||
|
|||||||
106
docs/adr/ADR-291-public-benchmark-evaluation-harness.md
Normal file
106
docs/adr/ADR-291-public-benchmark-evaluation-harness.md
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
# ADR-291: Public-benchmark evaluation harness — Widar3.0 ingest, standard split protocols, leakage guards
|
||||||
|
|
||||||
|
- **Status**: Accepted — initial implementation (this PR)
|
||||||
|
- **Date**: 2026-08-10
|
||||||
|
- **Deciders**: ruv
|
||||||
|
- **Tags**: training, evaluation, benchmarks, widar, mm-fi, leakage, honesty
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
RuView implements the field's key techniques (CSI ratio, BVP features, MAE
|
||||||
|
pretraining, rapid adaptation) but reports results only on self-collected data
|
||||||
|
with self-defined metrics (e.g. the README's held-out temporal-triplet
|
||||||
|
accuracy). A 2026 deep-research sweep of the WiFi-sensing literature found:
|
||||||
|
|
||||||
|
1. Cross-domain generalization is the field's central unsolved problem; the
|
||||||
|
only widely reproduced cross-domain result is Widar3.0's BVP benchmark.
|
||||||
|
2. MM-Fi (NeurIPS 2023) is the standard WiFi-pose benchmark, with defined
|
||||||
|
cross-subject and cross-environment protocols.
|
||||||
|
3. The field had a documented leakage reckoning in 2024–2025: window-level
|
||||||
|
random splits on continuous recordings inflate accuracy (one dataset's F1
|
||||||
|
collapsed from ~90% to ~22% under subject-disjoint splits — Sensors
|
||||||
|
24(10):3159; Signals 6(4):59).
|
||||||
|
|
||||||
|
`wifi-densepose-train` already has an `MmFiDataset` NPY loader and a
|
||||||
|
deterministic `SyntheticCsiDataset`, but no Widar3.0 ingest, no standard split
|
||||||
|
protocols, and no structural leakage guard. CLAUDE.md already requires
|
||||||
|
mean-pose baselines and leakage-free held-out splits for pose PCK; nothing in
|
||||||
|
the code enforces this.
|
||||||
|
|
||||||
|
Without leaderboard-comparable numbers, RuView's claims cannot be ranked
|
||||||
|
against published systems, which blocks both scientific credibility and
|
||||||
|
commercial (OEM licensing) conversations.
|
||||||
|
|
||||||
|
## Options considered
|
||||||
|
|
||||||
|
1. **Do nothing; keep self-collected metrics.** Rejected: perpetuates the
|
||||||
|
comparability gap.
|
||||||
|
2. **Port a Python eval stack (SenseFi) alongside the Rust pipeline.**
|
||||||
|
Rejected: violates the v2 Rust-workspace direction and adds an unreviewed
|
||||||
|
dependency surface.
|
||||||
|
3. **Extend `wifi-densepose-train` with native loaders + protocol machinery.**
|
||||||
|
Chosen.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Extend `v2/crates/wifi-densepose-train` with three additions:
|
||||||
|
|
||||||
|
### 1. Widar3.0 ingest (`dataset::widar`)
|
||||||
|
|
||||||
|
- A parser for the Intel 5300 `.dat` CSI log format ("bfee" records) used by
|
||||||
|
the Widar3.0 raw distribution: framed records with a 3-byte header
|
||||||
|
(2-byte little-endian length + 1-byte code 0xBB), a 20-byte bfee header
|
||||||
|
(timestamp_low, bfee_count, Nrx, Ntx, RSSI a/b/c, noise, agc, antenna_sel,
|
||||||
|
len, rate), and a packed 10-bit-per-component complex CSI payload of
|
||||||
|
30 subcarrier groups. Invalid records are skipped with a warning, not a
|
||||||
|
panic — untrusted file input is validated at the boundary per CLAUDE.md.
|
||||||
|
- A `WidarDataset` implementing the existing `CsiDataset` trait, mapping
|
||||||
|
Widar's `Nrx × Ntx × 30` CSI into windowed `CsiSample`s via the existing
|
||||||
|
subcarrier interpolation, with domain metadata (user, room, orientation,
|
||||||
|
gesture) parsed from Widar's documented directory/file naming convention.
|
||||||
|
- No network access: the loader reads a local dataset root. Dataset download
|
||||||
|
remains a documented manual step.
|
||||||
|
|
||||||
|
### 2. Split protocols (`protocols`)
|
||||||
|
|
||||||
|
- A `SplitProtocol` type expressing the standard evaluations: cross-subject
|
||||||
|
(MM-Fi style), cross-environment/room, cross-orientation (Widar style), and
|
||||||
|
random-baseline (explicitly labelled as leakage-prone, for comparison only).
|
||||||
|
- Split assignment is a pure function of sample metadata + a seed — fully
|
||||||
|
deterministic, no RNG state.
|
||||||
|
|
||||||
|
### 3. Leakage guards (`protocols::leakage`)
|
||||||
|
|
||||||
|
- A structural `LeakageAudit` that, given a proposed train/test split,
|
||||||
|
verifies: (a) subject-disjointness, (b) environment-disjointness where the
|
||||||
|
protocol claims it, (c) no two windows from the same continuous recording
|
||||||
|
span both sides of the split. A failed audit is an `Err`, not a warning.
|
||||||
|
- PCK/accuracy reporting requires a `MeanPoseBaseline` computed from the
|
||||||
|
training split only, and reports model-vs-baseline together, enforcing the
|
||||||
|
CLAUDE.md rule in the type system rather than by convention.
|
||||||
|
- Evaluation output is an evidence-tagged report (`MEASURED` requires a
|
||||||
|
reproducer command line embedded in the report; anything else is emitted as
|
||||||
|
`SYNTHETIC` or `CLAIMED`).
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- RuView results become comparable to published numbers (Widar3.0 cross-domain
|
||||||
|
gesture; MM-Fi cross-subject pose) for the first time.
|
||||||
|
- The leakage audit will make some existing internal numbers look worse. That
|
||||||
|
is the point.
|
||||||
|
- Parsing a legacy binary format adds maintenance surface; mitigated by
|
||||||
|
fixture-based tests with synthetic, deterministically generated `.dat`
|
||||||
|
bytes (no dataset redistribution).
|
||||||
|
- Widar's raw distribution is Intel 5300-specific; ESP32-captured data
|
||||||
|
continues through existing loaders. The protocols/leakage machinery is
|
||||||
|
loader-agnostic.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
- `cargo test -p wifi-densepose-train` — unit tests for the bfee parser
|
||||||
|
(truncated, corrupt, and valid synthetic fixtures), split determinism,
|
||||||
|
leakage-audit rejection cases, and mean-pose baseline math.
|
||||||
|
- `cargo bench -p wifi-densepose-train` — criterion benchmark for parser
|
||||||
|
throughput and split assignment on synthetic corpora.
|
||||||
|
- No accuracy numbers are claimed by this ADR; it delivers the machinery to
|
||||||
|
produce MEASURED ones.
|
||||||
91
docs/adr/ADR-292-wideband-80211ax-csi-ingest.md
Normal file
91
docs/adr/ADR-292-wideband-80211ax-csi-ingest.md
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
# ADR-292: Wideband 802.11ax CSI ingest — FeitCSI/AX210 adapter and subcarrier-agnostic plumbing
|
||||||
|
|
||||||
|
- **Status**: Accepted — initial implementation (this PR)
|
||||||
|
- **Date**: 2026-08-10
|
||||||
|
- **Deciders**: ruv
|
||||||
|
- **Tags**: hardware, csi, 80211ax, ax210, feitcsi, ingest, mat
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
RuView's CSI ingest (`wifi-densepose-mat/src/integration/hardware_adapter.rs`)
|
||||||
|
supports ESP32 serial streams, the legacy Intel 5300 tool, and Atheros/Nexmon
|
||||||
|
paths. All of these are 802.11n-class: ≤40 MHz bandwidth, ≤114 subcarriers,
|
||||||
|
2.4/5 GHz.
|
||||||
|
|
||||||
|
The 2026 research sweep found the field's center of gravity has moved to
|
||||||
|
Intel AX200/AX210 NICs via PicoScenes (closed-source core) and FeitCSI
|
||||||
|
(open-source, GPL): 802.11ax CSI at up to 160 MHz / 1992 subcarriers,
|
||||||
|
including the 6 GHz band. This is both the research-grade tier today and the
|
||||||
|
shape of the data 802.11bf silicon will deliver from ~2026 onward. RuView's
|
||||||
|
`wifi-densepose-hardware` crate already models 802.11bf session types, but no
|
||||||
|
ingest path can carry wideband CSI into the pipeline.
|
||||||
|
|
||||||
|
Without a wideband path, RuView cannot develop against the best available
|
||||||
|
signal, cannot compare ESP32-grade results to wideband upper bounds, and will
|
||||||
|
meet 802.11bf silicon with no tested plumbing for >114-subcarrier frames.
|
||||||
|
|
||||||
|
## Options considered
|
||||||
|
|
||||||
|
1. **PicoScenes `.csi` ingest.** Rejected for now: the format is produced by a
|
||||||
|
closed-source core and is versioned/complex; parsing it without a
|
||||||
|
maintained spec invites silent corruption.
|
||||||
|
2. **Raw pcap + radiotap parsing.** Rejected: duplicates what FeitCSI already
|
||||||
|
does on-device, and pulls a packet-capture dependency into the pipeline.
|
||||||
|
3. **FeitCSI file/stream ingest.** Chosen: FeitCSI is open-source (its header
|
||||||
|
layout is auditable against the source), targets AX200/AX210, covers
|
||||||
|
20–160 MHz including 6 GHz, and emits a compact binary record per frame.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Extend `v2/crates/wifi-densepose-mat/src/integration` with:
|
||||||
|
|
||||||
|
### 1. `feitcsi` record parser
|
||||||
|
|
||||||
|
- A validated parser for FeitCSI's binary CSI record layout (header with
|
||||||
|
CSI buffer length, rate/bandwidth/channel metadata, antenna counts, RSSI,
|
||||||
|
timestamp, followed by interleaved complex CSI). The parser is written
|
||||||
|
against the documented layout, is version-checked, and rejects
|
||||||
|
records whose declared dimensions disagree with the buffer length —
|
||||||
|
untrusted file/stream input is validated at the boundary.
|
||||||
|
- Bounded allocation: a hard cap on subcarrier count (4096) and antenna
|
||||||
|
count (8) so a corrupt length field cannot cause unbounded allocation.
|
||||||
|
|
||||||
|
### 2. `DeviceType::FeitCsi` in the hardware adapter
|
||||||
|
|
||||||
|
- File-replay mode (read a recorded FeitCSI capture deterministically) and a
|
||||||
|
streaming mode fed by an external process writing to a path/pipe. No
|
||||||
|
privileged operations inside the crate: RuView does not configure the NIC;
|
||||||
|
FeitCSI's own tooling owns that, per least-authority.
|
||||||
|
|
||||||
|
### 3. Subcarrier-agnostic plumbing
|
||||||
|
|
||||||
|
- Ingest carries native subcarrier dimensionality end-to-end and converts to
|
||||||
|
pipeline width explicitly via the existing interpolation/decimation stage,
|
||||||
|
recording the native → pipeline mapping in frame metadata so downstream
|
||||||
|
consumers know the true spectral resolution. Bandwidth (20–160 MHz) and
|
||||||
|
band (2.4/5/6 GHz) become first-class frame metadata.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- RuView gains a research-grade wideband development path and a tested
|
||||||
|
ingest shape for future 802.11bf reporting (truncated CIR is a natural
|
||||||
|
extension of the same plumbing).
|
||||||
|
- GPL FeitCSI is used as an external tool, never linked: only its output
|
||||||
|
format is parsed. No licensing contamination of the MIT workspace.
|
||||||
|
- The parser tracks an external project's format; version checks fail loudly
|
||||||
|
on mismatch rather than misparse.
|
||||||
|
- ESP32 remains the deployed sensor tier; wideband is a development/
|
||||||
|
validation tier. Accuracy claims from wideband captures must be tagged with
|
||||||
|
the capture hardware.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
- `cargo test -p wifi-densepose-mat` — parser tests over synthetic fixtures:
|
||||||
|
valid records at 20/80/160 MHz shapes, truncated buffer, dimension
|
||||||
|
mismatch, version mismatch, allocation-cap enforcement; adapter replay
|
||||||
|
determinism.
|
||||||
|
- `cargo bench -p wifi-densepose-mat` — criterion benchmark for record parse
|
||||||
|
throughput at 1992-subcarrier frames.
|
||||||
|
- Hardware validation on real AX210 silicon is explicitly out of scope for
|
||||||
|
this PR and remains required (per CLAUDE.md) before any capture-path
|
||||||
|
hardware claim; the file-replay path is testable without silicon.
|
||||||
93
docs/adr/ADR-293-vitals-ground-truth-rig.md
Normal file
93
docs/adr/ADR-293-vitals-ground-truth-rig.md
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
# ADR-293: Vitals ground-truth rig — reference ingest, time alignment, and agreement metrics
|
||||||
|
|
||||||
|
- **Status**: Accepted — initial implementation (this PR)
|
||||||
|
- **Date**: 2026-08-10
|
||||||
|
- **Deciders**: ruv
|
||||||
|
- **Tags**: vitals, validation, ground-truth, bland-altman, evidence, honesty
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
`wifi-densepose-vitals` (ADR-021) extracts breathing (0.1–0.5 Hz) and heart
|
||||||
|
rate (0.8–2.0 Hz) from CSI. The 2026 research sweep found that every credible
|
||||||
|
vitals result in the literature ships with reference-sensor ground truth
|
||||||
|
(chest strap, pulse oximeter, ECG, or PSG), and that WiFi heart-rate numbers
|
||||||
|
without stated scope (single person, static, line-of-sight, short range) are
|
||||||
|
systematically misleading. RuView currently has no way to produce a MEASURED
|
||||||
|
vitals number: there is no reference-signal ingest, no time alignment between
|
||||||
|
CSI-derived estimates and a reference device, and no agreement statistics.
|
||||||
|
|
||||||
|
CLAUDE.md requires accuracy statements to be tagged MEASURED (with a
|
||||||
|
reproducer), CLAIMED, or SYNTHETIC. For vitals, MEASURED is currently
|
||||||
|
unreachable.
|
||||||
|
|
||||||
|
## Options considered
|
||||||
|
|
||||||
|
1. **Live BLE/ANT+ integration with reference devices.** Rejected for now:
|
||||||
|
drivers and pairing are a hardware/product concern; the blocking gap is
|
||||||
|
the evaluation math, not the radio link.
|
||||||
|
2. **File-based reference ingest + offline agreement analysis.** Chosen:
|
||||||
|
every consumer reference device (Polar, Garmin, oximeters) exports
|
||||||
|
timestamped series; a file boundary keeps the crate dependency-free and
|
||||||
|
the pipeline deterministic.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Add a `groundtruth` module to `v2/crates/wifi-densepose-vitals`:
|
||||||
|
|
||||||
|
### 1. Reference series ingest
|
||||||
|
|
||||||
|
- `ReferenceSeries`: timestamped samples (unix millis + value) for one
|
||||||
|
measurand (`HeartRateBpm` or `BreathingRateBrpm`), with device metadata
|
||||||
|
(make/model, measurement principle). Parsed from CSV (`timestamp_ms,value`
|
||||||
|
with a header line); malformed rows are rejected with row-numbered errors —
|
||||||
|
untrusted file input validated at the boundary. Non-monotonic timestamps
|
||||||
|
are an error, not silently sorted.
|
||||||
|
|
||||||
|
### 2. Time alignment
|
||||||
|
|
||||||
|
- Constant-offset estimation by maximizing normalized cross-correlation of
|
||||||
|
the estimate series against the reference over a bounded lag window
|
||||||
|
(default ±30 s), on a common resampled grid (nearest-sample, no
|
||||||
|
interpolation of physiological values across gaps larger than a
|
||||||
|
configurable limit).
|
||||||
|
- Optional linear clock-drift fit (offset + rate) for long sessions.
|
||||||
|
Alignment parameters are reported, never silently applied.
|
||||||
|
|
||||||
|
### 3. Agreement metrics
|
||||||
|
|
||||||
|
- `AgreementReport`: n paired samples, coverage fraction (time where both
|
||||||
|
series had valid samples), MAE, RMSE, mean error (bias), Bland–Altman
|
||||||
|
95% limits of agreement, and percentage-within-tolerance (configurable,
|
||||||
|
default ±2 bpm HR / ±1 brpm breathing).
|
||||||
|
- Session scope is mandatory metadata: subject count, motion state
|
||||||
|
(static/moving), line-of-sight (LOS/NLOS/through-wall), distance band.
|
||||||
|
A report without scope cannot be constructed.
|
||||||
|
|
||||||
|
### 4. Evidence tagging
|
||||||
|
|
||||||
|
- `EvidenceGrade::Measured` is only constructible when the report carries a
|
||||||
|
reference device, non-zero paired samples, minimum coverage, and a
|
||||||
|
reproducer command string; otherwise the report grades as `Claimed` (real
|
||||||
|
data, no reference) or `Synthetic` (generated input). This mirrors
|
||||||
|
ADR-291's enforcement-in-types approach and the CLAUDE.md tagging rule.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- RuView can convert vitals claims from CLAIMED to MEASURED with a
|
||||||
|
reproducible offline analysis, session by session, scope by scope.
|
||||||
|
- Honest reporting will likely show heart-rate performance below marketing
|
||||||
|
intuition, especially NLOS/moving — that is the purpose.
|
||||||
|
- CSV ingest means a manual export step per session; acceptable at current
|
||||||
|
scale, and the format is the de-facto export of consumer reference gear.
|
||||||
|
- No clinical claim is implied: agreement statistics against consumer
|
||||||
|
reference devices are engineering evidence, not medical validation.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
- `cargo test -p wifi-densepose-vitals` — CSV rejection cases, alignment
|
||||||
|
recovery of known synthetic offsets/drifts, agreement metrics against
|
||||||
|
hand-computed fixtures, evidence-grade constructibility rules.
|
||||||
|
- `cargo bench -p wifi-densepose-vitals` — criterion benchmark for alignment
|
||||||
|
over hour-scale synthetic sessions.
|
||||||
|
- Real-session validation (ESP32 capture + chest strap) remains a follow-up
|
||||||
|
requiring hardware evidence per CLAUDE.md.
|
||||||
83
docs/adr/ADR-294-wifi-veil-integration.md
Normal file
83
docs/adr/ADR-294-wifi-veil-integration.md
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
# ADR-294: WiFi Veil integration — emission-shaping countermeasure as an advisory BFLD dependency
|
||||||
|
|
||||||
|
- **Status**: Accepted — initial implementation (this PR)
|
||||||
|
- **Date**: 2026-08-10
|
||||||
|
- **Deciders**: ruv
|
||||||
|
- **Tags**: privacy, bfld, bfi, wifi-veil, countermeasure, dependency
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
RuView's BFLD layer (ADR-118, ADR-141) senses via beamforming feedback while
|
||||||
|
enforcing structural privacy invariants on data entering the node. The 2026
|
||||||
|
research sweep identified the complementary, unaddressed surface: a node's own
|
||||||
|
*outgoing* BFI is unencrypted and enables passive third-party
|
||||||
|
re-identification (BFId, ACM CCS 2025); IEEE 802.11bf-2025 shipped with no
|
||||||
|
privacy mechanism; and no commercial product occupies the countermeasure
|
||||||
|
category.
|
||||||
|
|
||||||
|
[`wifi-veil`](https://github.com/ruvnet/wifi-veil) (codename VEIL, extracted
|
||||||
|
from this monorepo as a standalone crate) models a compliant emission-shaping
|
||||||
|
defense: keyed Givens rotations over the fine subspace of compressed
|
||||||
|
beamforming reports, energy-preserving (never jamming), reversible by a
|
||||||
|
keyed legitimate receiver. The crate is dependency-free, deterministic,
|
||||||
|
std-only, WASM-ready, dual MIT/Apache-2.0, and explicitly SYNTHETIC/L0: it
|
||||||
|
models waveform controls and never drives a radio.
|
||||||
|
|
||||||
|
RuView should consume this capability rather than re-implement it, giving the
|
||||||
|
sensing stack a defensive counterpart under one evidence regime.
|
||||||
|
|
||||||
|
## Options considered
|
||||||
|
|
||||||
|
1. **Vendor the veil sources into a RuView crate.** Rejected: forks the
|
||||||
|
witness-pinned upstream and duplicates maintenance.
|
||||||
|
2. **crates.io dependency.** Not yet available (v0.1.0 unpublished at
|
||||||
|
decision time); revisit when released.
|
||||||
|
3. **Git dependency pinned to an exact rev, feature-gated in
|
||||||
|
`wifi-densepose-bfld`.** Chosen.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
- Add `wifi-veil` to `v2/Cargo.toml` `[workspace.dependencies]` as a git
|
||||||
|
dependency pinned to rev `018468b5d2bf41f35c552910f35659830af0eb91`
|
||||||
|
(v0.1.0). Exact-rev pinning preserves provenance and reproducibility for a
|
||||||
|
pre-release upstream; bumping the rev is an explicit, reviewable change.
|
||||||
|
- Gate it in `wifi-densepose-bfld` behind a new `veil` feature
|
||||||
|
(`veil = ["std", "dep:wifi-veil"]`), off by default — the default build
|
||||||
|
remains dependency-light and unchanged.
|
||||||
|
- New `bfld::veil` module (advisory-only):
|
||||||
|
- `ShieldAssessment`: stable projection of wifi-veil's deterministic
|
||||||
|
attacker-vs-protector `ExperimentReport` (re-ID accuracy shield-off/on,
|
||||||
|
chance level, throughput ratio, energy-conservation audit), always
|
||||||
|
carrying the `SYNTHETIC/L0` evidence label.
|
||||||
|
- `assess` / `assess_default`: run the deterministic experiment.
|
||||||
|
- `optimized_shield`: wrap `hyper_optimize` to derive the
|
||||||
|
optimizer-shipped shield config plus its verifying assessment.
|
||||||
|
- Boundaries, stated structurally and in docs:
|
||||||
|
- **Advisory only.** Nothing in the integration emits RF, alters frames,
|
||||||
|
or relaxes any BFLD gate/invariant (I1–I3 untouched).
|
||||||
|
- **Evidence honesty.** Every veil-derived figure is labeled
|
||||||
|
`SYNTHETIC/L0`; no MEASURED claim is possible from this path (hardware
|
||||||
|
validation lives in wifi-veil's own P5 roadmap).
|
||||||
|
- ESP32 nodes cannot shield their own feedback (per wifi-veil's platform
|
||||||
|
matrix); the integration therefore informs posture and reporting, not
|
||||||
|
on-node emission control.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- RuView gains a sense-and-defend posture no commercial offering has, under
|
||||||
|
a single claim taxonomy.
|
||||||
|
- First git dependency in the workspace: builds now fetch one pinned
|
||||||
|
external rev. Acceptable: the crate is dependency-free, small, witness-
|
||||||
|
pinned upstream, and license-compatible (MIT OR Apache-2.0 into MIT).
|
||||||
|
- Feature-gated consumers (e.g. sensing-server privacy reporting, the
|
||||||
|
desktop UI) can surface shield assessments later without new deps.
|
||||||
|
- When wifi-veil publishes to crates.io, switch the workspace entry to a
|
||||||
|
version requirement in a follow-up ADR amendment.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
- `cargo test -p wifi-densepose-bfld --features veil` — determinism,
|
||||||
|
shield-reduces-re-ID, compliance (energy conservation), chance-band
|
||||||
|
attainment, evidence labeling, optimizer wrapper.
|
||||||
|
- `cargo test -p wifi-densepose-bfld` (default features) — unchanged
|
||||||
|
behavior with the feature off.
|
||||||
64
docs/adr/ADR-295-source-provenance-state-machine.md
Normal file
64
docs/adr/ADR-295-source-provenance-state-machine.md
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
# ADR-295: Source provenance state machine — synthetic can never present as live
|
||||||
|
|
||||||
|
- **Status**: Accepted — initial implementation (this PR)
|
||||||
|
- **Date**: 2026-08-11
|
||||||
|
- **Deciders**: ruv
|
||||||
|
- **Tags**: provenance, honesty, ui, sensing-server, security
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
An August 2026 external review found two provenance defects on the release
|
||||||
|
path:
|
||||||
|
|
||||||
|
1. The pose-fusion simulator starts in demo mode; on any page port other than
|
||||||
|
3000 the WebSocket target falls back to `localhost:8765`, and if the
|
||||||
|
connection fails the simulator keeps running while the status still reads
|
||||||
|
"ready" — producing a convincing moving visualization with no live CSI
|
||||||
|
(issue 1557).
|
||||||
|
2. The main sensing client labels the source **live** when the authenticated
|
||||||
|
status endpoint returns an error for lack of authorization, until a real
|
||||||
|
frame happens to correct it (issue 1526).
|
||||||
|
|
||||||
|
The common root cause: source state is a boolean (live vs not), so "unknown"
|
||||||
|
collapses to "live". CLAUDE.md requires MEASURED/CLAIMED/SYNTHETIC labeling
|
||||||
|
and forbids presenting synthetic output as real.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Define one canonical, mutually exclusive `SourceState` enum shared by the
|
||||||
|
sensing server and every UI/client that renders a source:
|
||||||
|
|
||||||
|
- `Synthetic` — generated data (simulator/replay of synthetic fixtures).
|
||||||
|
- `LiveVerified` — frames from an authenticated, attested source.
|
||||||
|
- `LiveUnverified` — frames arriving but provenance not yet confirmed.
|
||||||
|
- `Stale` — last frame older than a configured freshness window.
|
||||||
|
- `Disconnected` — no source.
|
||||||
|
|
||||||
|
Rules enforced structurally:
|
||||||
|
|
||||||
|
- **`Unknown` is not a state.** Any ambiguous condition resolves to
|
||||||
|
`LiveUnverified`, `Stale`, or `Disconnected` — never `LiveVerified`.
|
||||||
|
- A status-endpoint error resolves to `Disconnected`/`LiveUnverified`, never
|
||||||
|
live-verified.
|
||||||
|
- The simulator constructs `Synthetic` and cannot transition to any `Live*`
|
||||||
|
state without a verified frame.
|
||||||
|
- `Synthetic` is watermarked in every view and every export.
|
||||||
|
- Transitions are a pure function of (last-frame-age, auth-status,
|
||||||
|
source-kind) so they are unit-testable without a clock or a socket.
|
||||||
|
|
||||||
|
Scope of this PR: the shared `SourceState` type + transition function + tests
|
||||||
|
in the sensing server, and wiring of the two identified surfaces (pose-fusion
|
||||||
|
simulator status, sensing client source label). Broader UI adoption follows.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- Closes the "synthetic shown as live" and "unknown shown as live" classes.
|
||||||
|
- A small breaking change to any consumer currently reading a boolean source
|
||||||
|
flag; mitigated by exposing a compatibility accessor during migration.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
- Unit tests for every transition, especially: auth-error → not-live;
|
||||||
|
simulator → never live without a verified frame; freshness expiry → `Stale`;
|
||||||
|
watermark present on synthetic export.
|
||||||
|
- `cargo test -p wifi-densepose-sensing-server`.
|
||||||
59
docs/adr/ADR-296-sensor-data-plane-bind-hardening.md
Normal file
59
docs/adr/ADR-296-sensor-data-plane-bind-hardening.md
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
# ADR-296: Sensor data-plane hardening — UDP bind control and source allowlist (step one)
|
||||||
|
|
||||||
|
- **Status**: Accepted — initial implementation (this PR)
|
||||||
|
- **Date**: 2026-08-11
|
||||||
|
- **Deciders**: ruv
|
||||||
|
- **Tags**: security, udp, sensor-ingest, sensing-server
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The CSI UDP receiver binds `0.0.0.0:{udp_port}` unconditionally
|
||||||
|
(`main.rs:5706`), with no equivalent of the HTTP `--bind-addr` flag (which
|
||||||
|
correctly defaults to `127.0.0.1`), no source allowlist, no message
|
||||||
|
authentication, no device identity, and no replay defense. Any host that can
|
||||||
|
reach the UDP port can inject a valid-shaped frame, flip an auto-detecting
|
||||||
|
server into a live source state, and influence presence/vital/automation
|
||||||
|
outputs (issue 1394).
|
||||||
|
|
||||||
|
An IP allowlist does not stop LAN spoofing, but bind control plus an allowlist
|
||||||
|
is the correct, shippable first step; per-device keys + authenticated
|
||||||
|
encryption + monotonic sequence + freshness window + replay rejection is the
|
||||||
|
full fix and is larger.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**This PR (step one):**
|
||||||
|
|
||||||
|
- Add `--udp-bind` (env `RUVIEW_UDP_BIND`), **defaulting to `127.0.0.1`**.
|
||||||
|
Binding to a routable address is now an explicit operator choice, mirroring
|
||||||
|
the HTTP path. Desktop/appliance defaults stay loopback.
|
||||||
|
- Add an optional source IP/CIDR allowlist (`--udp-allow`); when set, frames
|
||||||
|
from other sources are dropped and counted. Loopback is always allowed.
|
||||||
|
- Emit a startup security log line stating the bind scope and whether an
|
||||||
|
allowlist is active; refuse a routable bind without an allowlist unless an
|
||||||
|
explicit `--udp-insecure-lan` override is passed (parallel to the existing
|
||||||
|
Docker HTTP refusal).
|
||||||
|
- Publish a `SECURITY.md`/advisory note describing the threat model and safe
|
||||||
|
deployment.
|
||||||
|
|
||||||
|
**Explicitly deferred to a follow-up ADR (step two):** per-device provisioned
|
||||||
|
keys, MAC/AEAD, device identifiers, monotonic sequence numbers, freshness
|
||||||
|
window, and replay rejection. This ADR documents that gap rather than
|
||||||
|
implying the data plane is authenticated.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- Removes the default open-to-LAN exposure with a one-line-safe default.
|
||||||
|
- Not spoof-proof on a trusted LAN — the advisory says so plainly, and the
|
||||||
|
override name (`--udp-insecure-lan`) makes the residual risk legible.
|
||||||
|
- A behavior change for anyone relying on the old implicit `0.0.0.0` default;
|
||||||
|
called out in the changelog and the startup log.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
- Unit tests: default bind is loopback; routable bind without allowlist is
|
||||||
|
refused unless overridden; allowlist accept/drop with counting; loopback
|
||||||
|
always allowed.
|
||||||
|
- `cargo test -p wifi-densepose-sensing-server`.
|
||||||
|
- Real-silicon validation of the LAN path remains required before any
|
||||||
|
deployment claim.
|
||||||
58
docs/adr/ADR-297-multi-node-semantic-correctness.md
Normal file
58
docs/adr/ADR-297-multi-node-semantic-correctness.md
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
# ADR-297: Multi-node semantic correctness — per-node inference, node-keyed rate limiting, stale state
|
||||||
|
|
||||||
|
- **Status**: Accepted — initial implementation (this PR)
|
||||||
|
- **Date**: 2026-08-11
|
||||||
|
- **Deciders**: ruv
|
||||||
|
- **Tags**: multi-node, mqtt, home-assistant, correctness, sensing-server
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The external review confirmed three defects on the multi-node path — the core
|
||||||
|
mechanism RuView uses to reduce blind spots and room dependence:
|
||||||
|
|
||||||
|
1. The active `NodeInfo` payload carries RSSI/position/subcarrier/sync but **no
|
||||||
|
per-node classification**; the MQTT mapper reads `node.classification` and
|
||||||
|
falls back to the room aggregate when absent, so every node can publish the
|
||||||
|
same aggregate presence value (issues 1540, 1554).
|
||||||
|
2. The MQTT `RateLimiter` is keyed by `EntityKind` only
|
||||||
|
(`mqtt/state.rs:65`), so one node consumes the numeric publish slot and the
|
||||||
|
others are suppressed until the interval expires, while availability still
|
||||||
|
says online (issue 1541).
|
||||||
|
3. In the UDP vital path, top-level classification is taken from the
|
||||||
|
latest-arriving node while other features are fused, so with disagreeing
|
||||||
|
nodes room presence can flip at packet frequency (issue 1555).
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
- **Separate the types.** Introduce `NodeInference` (per-node classification +
|
||||||
|
confidence + freshness) distinct from `RoomInference` (the fused room
|
||||||
|
aggregate). `NodeInfo` carries a `NodeInference`; the room aggregate is
|
||||||
|
computed explicitly and never overwrites node state. No silent fallback from
|
||||||
|
node to room.
|
||||||
|
- **Key the rate limiter by (node, entity).** `RateLimiter` becomes keyed on
|
||||||
|
`(NodeId, EntityKind)` so nodes no longer starve each other; per-entity
|
||||||
|
behavior per node is preserved.
|
||||||
|
- **Deterministic fusion.** Room classification is a pure function of the set
|
||||||
|
of current per-node inferences (e.g. freshness-weighted vote), not
|
||||||
|
last-writer-wins; identical inputs yield identical room state.
|
||||||
|
- **Stale entities cannot stay online.** An entity whose backing node has not
|
||||||
|
reported within N expected publish intervals transitions to unavailable/
|
||||||
|
stale rather than holding a frozen value while availability says online.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- Multi-node HA/MQTT output becomes semantically correct; distinct nodes
|
||||||
|
report distinct state and no longer suppress one another.
|
||||||
|
- Schema change to `NodeInfo`/the MQTT contract; existing single-node
|
||||||
|
deployments keep working (one node = one inference). Consumers reading the
|
||||||
|
old aggregate-only shape need the migration accessor.
|
||||||
|
- Aligns with ADR-295 (freshness) and the review's call for one canonical
|
||||||
|
`NodeInference`/`RoomInference` contract.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
- Unit/integration tests: per-node classification round-trips through the MQTT
|
||||||
|
mapper with no room fallback; two nodes with different rates both publish
|
||||||
|
(no starvation); disagreeing nodes produce deterministic, non-flapping room
|
||||||
|
state; a silent node's entities go stale, not frozen-online.
|
||||||
|
- `cargo test -p wifi-densepose-sensing-server`.
|
||||||
60
docs/adr/ADR-298-model-release-sanity-gates.md
Normal file
60
docs/adr/ADR-298-model-release-sanity-gates.md
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
# ADR-298: Model release sanity gates — block degenerate and mislabeled model artifacts
|
||||||
|
|
||||||
|
- **Status**: Accepted — initial implementation (this PR)
|
||||||
|
- **Date**: 2026-08-11
|
||||||
|
- **Deciders**: ruv
|
||||||
|
- **Tags**: models, evaluation, release-gate, honesty, presence
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The external review (corroborating issue 1521) showed the published presence
|
||||||
|
head is mathematically degenerate: with L2-normalized embeddings, a weight
|
||||||
|
norm ≈ 3.67 against a bias ≈ 8.19 makes the smallest possible logit positive,
|
||||||
|
so predicted presence probability is ≥ ~0.989 for every valid input — the
|
||||||
|
decision boundary is unreachable and the head is effectively constant. The
|
||||||
|
README then labeled a temporal-triplet accuracy (a representation-ordering
|
||||||
|
metric) as "presence accuracy" — a category error.
|
||||||
|
|
||||||
|
Nothing in the release path catches a constant classifier, an unreachable
|
||||||
|
boundary, or a metric-name mismatch. A machine check would have.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Add a `model_gates` module (in `wifi-densepose-train`) plus a CI gate that,
|
||||||
|
for any classifier artifact proposed for release, fails on:
|
||||||
|
|
||||||
|
- **Constant output** — output variance below a threshold across a diverse
|
||||||
|
probe set (including the degenerate-embedding probe from issue 1521).
|
||||||
|
- **Unreachable decision boundary** — for a normalized-embedding linear head,
|
||||||
|
check whether `bias` sign dominates `‖weight‖` so the logit cannot change
|
||||||
|
sign; fail if the boundary is analytically unreachable.
|
||||||
|
- **Degenerate class balance** — predicted-positive rate at/above a ceiling
|
||||||
|
(e.g. > 99%) on a balanced probe set.
|
||||||
|
- **Missing/blank baseline** — a report without a paired mean-pose/majority
|
||||||
|
baseline (ties into ADR-291 `EvaluationReport`).
|
||||||
|
- **Metric-name provenance** — a metric may not be surfaced under a task name
|
||||||
|
that does not match its computed kind (temporal-triplet ≠ presence);
|
||||||
|
enforced by making the metric carry its kind and the label derive from it.
|
||||||
|
|
||||||
|
Each gate emits a structured, human-readable failure explaining the defect and
|
||||||
|
the offending numbers.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- The specific degenerate presence head cannot ship again, and the
|
||||||
|
temporal-triplet-as-presence mislabel is structurally prevented.
|
||||||
|
- Some existing artifacts will fail the gate on introduction — intended; they
|
||||||
|
should fail.
|
||||||
|
- The gate is heuristic, not a correctness proof; it catches the known
|
||||||
|
failure shapes, not all bad models.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
- Unit tests: the issue-1521 weights fail the unreachable-boundary and
|
||||||
|
constant-output gates; a healthy synthetic head passes; a temporal-triplet
|
||||||
|
metric cannot be constructed with a presence label.
|
||||||
|
- `cargo test -p wifi-densepose-train`; the CI gate runs in the model-check
|
||||||
|
workflow.
|
||||||
|
- This ADR does **not** withdraw the already-published artifact (an
|
||||||
|
outward-facing action requiring maintainer sign-off) — it prevents
|
||||||
|
recurrence and documents the model-card correction.
|
||||||
50
docs/adr/ADR-299-csi-data-incident-repo-controls.md
Normal file
50
docs/adr/ADR-299-csi-data-incident-repo-controls.md
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
# ADR-299: Repository CSI data-incident controls — ignore rules and a pre-commit/CI policy check
|
||||||
|
|
||||||
|
- **Status**: Accepted — controls implemented; tree remediation gated on owner sign-off
|
||||||
|
- **Date**: 2026-08-11
|
||||||
|
- **Deciders**: ruv
|
||||||
|
- **Tags**: privacy, data-governance, ci, security, incident
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The external review found ~64.6 MB of tracked raw CSI recordings under
|
||||||
|
`data/recordings/` and `v2/data/recordings/` (largest an ~61.8 MB overnight
|
||||||
|
capture). CLAUDE.md explicitly prohibits committing CSI or person data. The
|
||||||
|
`.gitignore` rule pointed only at a pre-rename path
|
||||||
|
(`rust-port/wifi-densepose-rs/data/recordings/`) and did not cover the active
|
||||||
|
directories, which is how the captures were committed. Raw CSI is person data
|
||||||
|
(it encodes breathing, movement, presence), so this is a data incident, not a
|
||||||
|
formatting nit.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**Implemented now (mechanical, no data-ownership judgment):**
|
||||||
|
|
||||||
|
- Fix `.gitignore` to cover `data/recordings/`, `v2/data/recordings/`, the
|
||||||
|
legacy path, and `*.csi.jsonl` / `*.csi.meta.json` globs (done in this PR).
|
||||||
|
- Add a policy check (pre-commit hook + CI job) that fails when CSI-format
|
||||||
|
files (`*.csi.jsonl`, `*.csi.meta.json`) or large JSONL captures are staged
|
||||||
|
or present as tracked files, with a message pointing here. Tests may use
|
||||||
|
only synthetic or expressly-consented minimal fixtures.
|
||||||
|
|
||||||
|
**Explicitly gated on data-owner sign-off (NOT done autonomously):**
|
||||||
|
|
||||||
|
- Removing the existing recordings from the tree, and any history rewrite, are
|
||||||
|
outward-facing/destructive and require the data owner to first establish
|
||||||
|
provenance, consent, purpose, retention authority, and redistribution
|
||||||
|
rights. The review is correct that rewriting `origin` does not erase forks
|
||||||
|
and clones; coordination is required. This ADR records the controls and the
|
||||||
|
required follow-up; it does not delete the data.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- No new CSI captures can be committed (ignore + policy check).
|
||||||
|
- The existing tracked recordings remain until the owner decides; the incident
|
||||||
|
is documented and the guard prevents worsening it.
|
||||||
|
- CI gains one fast policy job; contributors get a local pre-commit check.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
- Policy-check unit tests: a staged `*.csi.jsonl` fails; a synthetic fixture
|
||||||
|
under an allowed test path passes; the check is deterministic and offline.
|
||||||
|
- Manual confirmation that the new ignore globs cover both active directories.
|
||||||
189
docs/adr/ADR-300-perception-substrate-program.md
Normal file
189
docs/adr/ADR-300-perception-substrate-program.md
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
# ADR-300: RuView perception substrate — a phased program for the calibration, evidence, trust, and deployment layer
|
||||||
|
|
||||||
|
- **Status**: Accepted — program framing; child ADRs carry their own status
|
||||||
|
- **Date**: 2026-08-11
|
||||||
|
- **Deciders**: ruv
|
||||||
|
- **Tags**: program, architecture, calibration, evidence, provenance, fusion, fleet, epic
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Three independent analyses converged on the same conclusion in 2026: a deep
|
||||||
|
research sweep of the WiFi-sensing state of the art, an external technical and
|
||||||
|
industry review, and an internal strategic assessment. All three found that
|
||||||
|
RuView's gap is **not another sensing modality** but the horizontal layer that
|
||||||
|
turns RF research into repeatable spatial infrastructure — measurement,
|
||||||
|
calibration, out-of-distribution awareness, evidence accounting, authenticated
|
||||||
|
identity, a canonical spatial model, and fleet deployment.
|
||||||
|
|
||||||
|
Several of these primitives already have foundations in the tree and should be
|
||||||
|
**unified and made to produce signed, expiring certificates**, not rebuilt:
|
||||||
|
|
||||||
|
- `wifi-densepose-calibration` (enrollment, bank, anchor, runtime, specialist).
|
||||||
|
- `frame::EvidenceLevel` L0–L5 as mandatory policy (ADR-282).
|
||||||
|
- AetherArena benchmark infrastructure — v0 complete, CI-gated, witness ledger,
|
||||||
|
live HF Space (ADR-149); board intentionally empty (benchmark-first).
|
||||||
|
- RuField provenance/signature types (ADR-260/262/277/279) and BFLD
|
||||||
|
attestation (ADR-141).
|
||||||
|
- `worldgraph` crate; `wifi-densepose-mat/tracking` (tracker, fingerprint).
|
||||||
|
- The in-flight ADR-295 (provenance state machine), ADR-296 (authenticated
|
||||||
|
data plane, step one), ADR-298 (model sanity gates) — the first bricks.
|
||||||
|
|
||||||
|
## What RuView is optimizing for
|
||||||
|
|
||||||
|
Not inference capability — **epistemic reliability**:
|
||||||
|
|
||||||
|
```
|
||||||
|
signal → observation → calibration → inference → uncertainty → evidence
|
||||||
|
→ certificate → policy → governed action
|
||||||
|
```
|
||||||
|
|
||||||
|
That pipeline is the product. The defensible category is not "RuView perceives
|
||||||
|
the physical world" but "RuView determines what machines are justified in
|
||||||
|
believing about it, proves why, and constrains what they may do with that
|
||||||
|
belief."
|
||||||
|
|
||||||
|
### Four non-negotiable program rules
|
||||||
|
|
||||||
|
Every child ADR and implementation is bound by these:
|
||||||
|
|
||||||
|
1. **UNKNOWN is a first-class output, never an error condition.** A surface that
|
||||||
|
cannot answer says UNKNOWN and stays legible; it does not throw, default to a
|
||||||
|
confident class, or silently hold a stale value.
|
||||||
|
2. **Capability certificates bind cryptographically.** Hardware, environment,
|
||||||
|
model, calibration, metrics, expiry, and evidence level are bound under one
|
||||||
|
signature (ADR-318/ADR-305). An unsigned or partially-bound certificate is
|
||||||
|
not a certificate.
|
||||||
|
3. **One canonical semantics downstream.** Every surface (MQTT, REST, WebSocket,
|
||||||
|
RuField, Matter, agents, UI) consumes the same Observation → Inference →
|
||||||
|
GovernedEvent types (ADR-306). No transport- or UI-specific reinterpretation.
|
||||||
|
4. **Benchmarks expose worst-domain performance and confidence intervals.**
|
||||||
|
Pooled accuracy is never sufficient for promotion (ADR-317).
|
||||||
|
|
||||||
|
### Certificate conditionality (the staleness guard)
|
||||||
|
|
||||||
|
The central architectural risk is **certificate staleness**: a room can remain
|
||||||
|
syntactically calibrated while its RF distribution has drifted enough to
|
||||||
|
invalidate the certificate. Therefore a capability certificate is **conditional
|
||||||
|
on a continuously evaluated domain signature** (ADR-302), not a one-time stamp.
|
||||||
|
Crossing the OOD threshold automatically degrades state and triggers
|
||||||
|
recalibration rather than silently continuing:
|
||||||
|
|
||||||
|
```
|
||||||
|
VALID → DEGRADED → UNKNOWN (auto-degrade on domain drift; triggers recalibration)
|
||||||
|
```
|
||||||
|
|
||||||
|
This binds ADR-301 (calibration), ADR-302 (OOD), ADR-318 (certificate), and
|
||||||
|
ADR-321 (policy): a degraded/unknown domain must invalidate the affected
|
||||||
|
capability *before* a false confident inference reaches an actuator.
|
||||||
|
|
||||||
|
### Commercial framing — three primitives, not one product
|
||||||
|
|
||||||
|
- **RuView Runtime** — provides perception.
|
||||||
|
- **RuView Certify** — establishes what a deployment can legitimately claim
|
||||||
|
(calibration + evidence + capability certificate + policy).
|
||||||
|
- **RuView Trust / Fleet** — keeps that claim valid across hardware, firmware,
|
||||||
|
models, and environmental drift (ADR-316).
|
||||||
|
|
||||||
|
Certify and Trust are the parts that are hard to commoditize; presence
|
||||||
|
detection alone is not.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Adopt a **21-primitive phased program**. Each primitive gets a child ADR
|
||||||
|
(ADR-301…ADR-321) that owns its detailed decision, status, and validation.
|
||||||
|
This ADR owns the framing, the dependency order, and the phase assignment.
|
||||||
|
|
||||||
|
### Primitive → ADR map
|
||||||
|
|
||||||
|
| # | Primitive | ADR | Phase |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | Automatic domain calibration | ADR-301 | 1 |
|
||||||
|
| 2 | Out-of-distribution detection | ADR-302 | 1 |
|
||||||
|
| 3 | Ground-truth synchronization | ADR-303 | 2 |
|
||||||
|
| 4 | Evidence engine | ADR-304 | 1 |
|
||||||
|
| 5 | Authenticated sensor identity | ADR-305 | 1 |
|
||||||
|
| 6 | Canonical spatial ontology | ADR-306 | 1 |
|
||||||
|
| 7 | Persistent identity & tracking | ADR-307 | 2 |
|
||||||
|
| 8 | Sensor placement optimizer | ADR-308 | 3 |
|
||||||
|
| 9 | Active sensing | ADR-309 | 3 |
|
||||||
|
| 10 | 802.11bf-native architecture | ADR-310 | 2 |
|
||||||
|
| 11 | Real sensor fusion | ADR-311 | 2 |
|
||||||
|
| 12 | Long-term spatial memory | ADR-312 | 3 |
|
||||||
|
| 13 | Counterfactual inference | ADR-313 | 3 |
|
||||||
|
| 14 | Information-gain scheduler | ADR-314 | 3 |
|
||||||
|
| 15 | Digital RF twin | ADR-315 | 3 |
|
||||||
|
| 16 | Fleet control plane | ADR-316 | 2 |
|
||||||
|
| 17 | Real benchmark service (multi-domain scorecard) | ADR-317 | 1 |
|
||||||
|
| 18 | Capability certificates | ADR-318 | 1 |
|
||||||
|
| 19 | Witness chain | ADR-319 | 1 |
|
||||||
|
| 20 | RuView sensor HAL | ADR-320 | 2 |
|
||||||
|
| 21 | Decision policy — action authorization | ADR-321 | 1 |
|
||||||
|
|
||||||
|
### Dependency order (why phase, not score, drives sequencing)
|
||||||
|
|
||||||
|
```
|
||||||
|
ADR-306 spatial ontology ──┐
|
||||||
|
ADR-305 auth identity ─────┼──► ADR-301 calibration cert ──► ADR-302 OOD gating
|
||||||
|
│ │ │
|
||||||
|
└──► ADR-319 witness chain │ (VALID→DEGRADED→UNKNOWN)
|
||||||
|
│ ▼
|
||||||
|
ADR-304 evidence engine ──► ADR-318 capability certificate
|
||||||
|
│ │ (conditional on domain signature)
|
||||||
|
│ ▼
|
||||||
|
│ ADR-321 decision policy ──► governed action
|
||||||
|
└──► ADR-317 benchmark scorecard (per-PR gate)
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Phase 1 (the certificate spine, built now):** foundational roots 303, 302,
|
||||||
|
301, 298 (implemented first, in their own crates); then the dependent wave
|
||||||
|
316, 299, 315, 314, 318. This set is exactly the acceptance test decomposed
|
||||||
|
and is buildable without new hardware (types, logic, signatures, tests). The
|
||||||
|
dependent wave adds the staleness guard (299 auto-degrades 315) and the
|
||||||
|
action gate (318) that denies at the actuator on a degraded/unknown domain.
|
||||||
|
- **Phase 2 (integration & operations):** 300 ground truth, 304 tracking, 307
|
||||||
|
802.11bf-native, 308 fusion, 313 fleet, 317 HAL. Depends on the spine.
|
||||||
|
- **Phase 3 (higher-ceiling, research-forward):** 305 placement optimizer, 306
|
||||||
|
active sensing, 309 spatial memory, 310 counterfactual, 311 info-gain
|
||||||
|
scheduler, 312 RF twin. Sit on top of the fused world state.
|
||||||
|
|
||||||
|
Phase-2 and phase-3 child ADRs are authored as **Proposed** (design intent,
|
||||||
|
validation plan) and are not implemented by the phase-1 swarm.
|
||||||
|
|
||||||
|
### Acceptance test A — onboarding (from the strategic assessment)
|
||||||
|
|
||||||
|
> Connect a new sensor type in an unseen room. Within 30 minutes RuView should
|
||||||
|
> identify the hardware (HAL, ADR-320), calibrate the environment (ADR-301),
|
||||||
|
> quantify whether it can reliably sense the requested phenomenon (ADR-302),
|
||||||
|
> generate a signed capability certificate (ADR-318), expose governed spatial
|
||||||
|
> events (ADR-306), and return UNKNOWN whenever evidence falls outside that
|
||||||
|
> certificate (ADR-302).
|
||||||
|
|
||||||
|
### Acceptance test B — drift invalidation (the staleness guard)
|
||||||
|
|
||||||
|
> Deliberately change the room after certification — move furniture, change the
|
||||||
|
> AP channel, or substitute hardware. RuView should detect distribution drift
|
||||||
|
> (ADR-302), invalidate the affected capability (ADR-318) **before** a false
|
||||||
|
> confident inference reaches an actuator (ADR-321 denies with the specific
|
||||||
|
> failed condition), emit UNKNOWN, preserve the complete witness chain
|
||||||
|
> (ADR-319), and explain exactly which certificate condition failed.
|
||||||
|
|
||||||
|
Test B is the load-bearing one: it proves the substrate fails safe, not just
|
||||||
|
that it perceives well. Phase 1 makes every clause except HAL testable in
|
||||||
|
software; HAL (phase 2)
|
||||||
|
closes the "identify the hardware" clause.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- One coherent substrate replaces overlapping ad-hoc schemas; every surface
|
||||||
|
(MQTT, REST, WebSocket, RuField, Matter, agents) eventually consumes the
|
||||||
|
ADR-306 ontology and the ADR-318 certificate.
|
||||||
|
- Headline applications (pose/vitals/pointcloud models) are explicitly **not**
|
||||||
|
the investment focus during this program, per the strategic direction.
|
||||||
|
- Later ADRs may be revised as the spine lands; that is expected for a phased
|
||||||
|
program and is why phase-2/3 ADRs ship as Proposed.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
- Each child ADR defines its own tests. The program-level exit is the
|
||||||
|
acceptance test above, run end-to-end once phase 1 lands, and encoded as an
|
||||||
|
AetherArena scenario (ADR-317).
|
||||||
149
docs/adr/ADR-301-automatic-domain-calibration.md
Normal file
149
docs/adr/ADR-301-automatic-domain-calibration.md
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
# ADR-301: Automatic domain calibration — signed, versioned, invalidatable room fingerprint
|
||||||
|
|
||||||
|
- **Status**: Accepted — initial implementation planned (ADR-300 phase 1)
|
||||||
|
- **Date**: 2026-08-11
|
||||||
|
- **Deciders**: ruv
|
||||||
|
- **Tags**: calibration, provenance, drift, evidence, honesty, substrate
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
This ADR is primitive 1 of the perception-substrate program (ADR-300) and the
|
||||||
|
first brick of that program's "certificate spine" (ADR-300 phase 1). It depends
|
||||||
|
on the canonical spatial ontology (ADR-306) to name *which space* it
|
||||||
|
characterizes, on authenticated sensor identity (ADR-305) to bind a fingerprint
|
||||||
|
to *which signed device* produced it, and on the witness chain (ADR-319) to
|
||||||
|
anchor the resulting artifact. Its output is consumed directly by
|
||||||
|
out-of-distribution detection (ADR-302).
|
||||||
|
|
||||||
|
WiFi sensing is only reproducible inside the environment it was tuned for.
|
||||||
|
Multipath, furniture geometry, transceiver placement, and AP channel all shape
|
||||||
|
the CSI distribution, so a model that reads a room correctly one week can drift
|
||||||
|
silently the next. RuView already has the raw ingredients for room-aware
|
||||||
|
sensing but not a single portable, signed, expiring artifact that says "this is
|
||||||
|
the room, here is when it was measured, and here is the evidence that it is
|
||||||
|
still the same room."
|
||||||
|
|
||||||
|
Existing scaffolding to build on, not rebuild (`v2/crates/wifi-densepose-calibration`):
|
||||||
|
|
||||||
|
- `enrollment` / `anchor` — guided human anchors with an adaptive quality gate.
|
||||||
|
- `bank` / `specialist` / `runtime` — a versioned bank of small specialist
|
||||||
|
models and a confidence-gated mixture runtime (`RoomState`), including the
|
||||||
|
crate's existing honest `STALE` degradation when the ADR-135 empty-room
|
||||||
|
baseline drifts.
|
||||||
|
- `geometry` / `geometry_embedding` — transceiver-geometry record and its
|
||||||
|
fixed-length conditioning featurization (ADR-152).
|
||||||
|
|
||||||
|
What is missing is (a) an *automatic* observe-only characterization phase that
|
||||||
|
does not require a human enrollment ritual, (b) empty-vs-occupied baseline
|
||||||
|
separation as a first-class pair, (c) a signed, versioned, comparable
|
||||||
|
`CalibrationCertificate` artifact, and (d) explicit invalidation on drift rather
|
||||||
|
than a soft `STALE` flag buried in the runtime.
|
||||||
|
|
||||||
|
## Options considered
|
||||||
|
|
||||||
|
1. **Keep calibration internal to the runtime (status quo).** Rejected: the
|
||||||
|
room characterization exists only as in-process state; it cannot be signed,
|
||||||
|
shipped, compared across time, or presented as evidence to ADR-302/ADR-318.
|
||||||
|
2. **Build a new calibration crate.** Rejected: `wifi-densepose-calibration`
|
||||||
|
already owns enrollment, the specialist bank, geometry embedding, and the
|
||||||
|
baseline-drift concept. A parallel crate would fork the room model.
|
||||||
|
3. **Extend `wifi-densepose-calibration` with an automatic characterization
|
||||||
|
phase and a signed certificate artifact.** Chosen.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Extend `v2/crates/wifi-densepose-calibration` with an `autocal` characterization
|
||||||
|
phase and a `certificate` artifact module. The target UX is:
|
||||||
|
|
||||||
|
> install → observe (~10 min) → room fingerprint → calibration certificate →
|
||||||
|
> sensing.
|
||||||
|
|
||||||
|
### 1. Automatic characterization (`autocal`)
|
||||||
|
|
||||||
|
- An observe-only pass (default ~10 minutes, configurable) that collects CSI
|
||||||
|
without requiring guided human anchors, reusing the `anchor` quality gate to
|
||||||
|
reject frames it cannot trust. It layers on the existing ADR-135 empty-room
|
||||||
|
baseline rather than replacing it.
|
||||||
|
- Produces a `RoomFingerprint`: a bounded, fixed-length statistical summary of
|
||||||
|
the room's CSI distribution (subcarrier amplitude/phase moments, multipath
|
||||||
|
structure, occupancy-band energy), plus the `geometry_embedding` when a
|
||||||
|
geometry record is present. The fingerprint is the distance-comparable object
|
||||||
|
ADR-302 measures against; its schema is versioned.
|
||||||
|
|
||||||
|
### 2. Empty / occupied baseline pair
|
||||||
|
|
||||||
|
- Characterization establishes a paired baseline: an **empty** distribution
|
||||||
|
(no occupant motion) and an **occupied** distribution (motion present),
|
||||||
|
separated by the existing occupancy signal rather than a manual label. Both
|
||||||
|
are stored on the fingerprint so downstream OOD gating can distinguish "the
|
||||||
|
empty room changed" (furniture/geometry drift) from "occupancy statistics
|
||||||
|
changed" (different subject dynamics).
|
||||||
|
|
||||||
|
### 3. `CalibrationCertificate` artifact
|
||||||
|
|
||||||
|
- A serializable `CalibrationCertificate` binding: the `RoomFingerprint`; a
|
||||||
|
space identifier from the ADR-306 ontology; the signing sensor identity from
|
||||||
|
ADR-305; `captured_at_unix_s`; a monotonic `version`; a schema version; the
|
||||||
|
calibration `tier`; and an `EvidenceLevel` (L0–L5, ADR-282) — an automatic
|
||||||
|
characterization on real captured CSI is at most L1/L2 and is labelled as
|
||||||
|
such, never L3+.
|
||||||
|
- The certificate is **signed** using RuField provenance/signature types
|
||||||
|
(ADR-260/262/277/279) and anchored in the witness chain (ADR-319). Signature
|
||||||
|
and witness anchoring are mandatory: an unsigned certificate is not a valid
|
||||||
|
certificate.
|
||||||
|
- Two certificates for the same space are **comparable**: `distance(a, b)`
|
||||||
|
returns a bounded fingerprint distance, which is the primitive ADR-302 uses
|
||||||
|
to gate KNOWN → DEGRADED → UNKNOWN.
|
||||||
|
|
||||||
|
### 4. Invalidation and continuous drift compensation
|
||||||
|
|
||||||
|
- A certificate carries an explicit validity policy: it is invalidated when
|
||||||
|
fingerprint distance against live traffic exceeds a threshold, when the AP
|
||||||
|
channel or transceiver geometry changes, when the signing device identity
|
||||||
|
changes, or on age expiry. Invalidation is an explicit state transition that
|
||||||
|
emits a witness record (ADR-319), not a silent `STALE` flag.
|
||||||
|
- Continuous drift compensation runs as a bounded online update of the
|
||||||
|
fingerprint within a **compatibility envelope**: small drift is absorbed and
|
||||||
|
logged; drift beyond the envelope invalidates the certificate and forces
|
||||||
|
re-characterization. Compensation never silently rewrites a signed
|
||||||
|
certificate — it produces a new version, preserving the append-only history.
|
||||||
|
|
||||||
|
### Provenance and honesty discipline
|
||||||
|
|
||||||
|
- No accuracy number is claimed by this ADR; it delivers the artifact and the
|
||||||
|
distance/invalidation machinery. Any certificate produced from generated CSI
|
||||||
|
is L0/`Synthetic` by construction; the constructor rejects labelling
|
||||||
|
synthetic characterization as measured (ADR-279 invariant 6, ADR-282 ladder).
|
||||||
|
- Certificates never leave the edge except through the governed control plane
|
||||||
|
(ADR-277); a room fingerprint is treated as potentially sensitive spatial
|
||||||
|
data, not free telemetry.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- Room characterization becomes a portable, signed, versioned artifact that
|
||||||
|
ADR-302 (OOD), ADR-318 (capability certificates), and ADR-317 (benchmark)
|
||||||
|
can consume without re-deriving room state.
|
||||||
|
- The automatic observe-only path lowers deployment friction (no mandatory
|
||||||
|
enrollment ritual) but yields a weaker evidence level than guided enrollment;
|
||||||
|
the certificate states which path produced it so consumers can weight it.
|
||||||
|
- Explicit invalidation means RuView will sometimes refuse to sense a changed
|
||||||
|
room until re-characterization. That refusal is the intended honest behavior,
|
||||||
|
surfaced by ADR-302, not a regression.
|
||||||
|
- The existing enrollment/bank/runtime path is preserved; `autocal` is an
|
||||||
|
additional entry point that produces the same `RoomFingerprint` object the
|
||||||
|
guided path can also emit.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
- `cargo test -p wifi-densepose-calibration` — fingerprint determinism from
|
||||||
|
fixed synthetic CSI; empty/occupied separation on synthetic occupancy;
|
||||||
|
certificate signing/verification round-trip and tamper rejection;
|
||||||
|
`distance()` monotonicity on progressively perturbed fixtures; invalidation
|
||||||
|
transitions (channel change, geometry change, age, drift-envelope breach)
|
||||||
|
each emit the expected witness record; constructor rejects synthetic→measured
|
||||||
|
mislabeling.
|
||||||
|
- Cross-ADR: an ADR-302 test consumes a certificate and asserts the gating
|
||||||
|
state transitions on a drifted fingerprint.
|
||||||
|
- Real-silicon characterization (ESP32 capture over a real 10-minute window)
|
||||||
|
remains a follow-up requiring hardware evidence per CLAUDE.md; a successful
|
||||||
|
build or synthetic run is not hardware evidence.
|
||||||
135
docs/adr/ADR-302-out-of-distribution-detection.md
Normal file
135
docs/adr/ADR-302-out-of-distribution-detection.md
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
# ADR-302: Out-of-distribution detection — KNOWN / DEGRADED / UNKNOWN gating
|
||||||
|
|
||||||
|
- **Status**: Accepted — initial implementation planned (ADR-300 phase 1)
|
||||||
|
- **Date**: 2026-08-11
|
||||||
|
- **Deciders**: ruv
|
||||||
|
- **Tags**: ood, calibration, uncertainty, quality, evidence, honesty, substrate
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
This ADR is primitive 2 of the perception-substrate program (ADR-300) and part
|
||||||
|
of the phase-1 certificate spine. It sits directly downstream of automatic
|
||||||
|
domain calibration (ADR-301): the `CalibrationCertificate` and its
|
||||||
|
`RoomFingerprint` are the reference distribution this ADR measures against. It
|
||||||
|
reuses fusion-layer quality scoring (ADR-137) as one of its inputs and feeds
|
||||||
|
its state into the evidence engine (ADR-304) and capability certificates
|
||||||
|
(ADR-318).
|
||||||
|
|
||||||
|
The central unsolved problem of WiFi sensing is cross-domain generalization: a
|
||||||
|
model trained (or calibrated) in one room degrades unpredictably in another, or
|
||||||
|
in the same room after furniture moves, the AP changes channel, or the radio
|
||||||
|
hardware is swapped. A model that keeps returning confident classifications
|
||||||
|
under these conditions is the single most misleading failure mode in the field,
|
||||||
|
and it is the failure the strategic assessment (ADR-300) named explicitly.
|
||||||
|
Confidence alone is insufficient: a softmax head is perfectly capable of being
|
||||||
|
confidently wrong on out-of-distribution input. RuView must be able to say
|
||||||
|
"I do not recognize this situation" instead of guessing.
|
||||||
|
|
||||||
|
Today RuView has partial signals but no unified gate:
|
||||||
|
|
||||||
|
- ADR-301 produces a comparable `RoomFingerprint` and a `distance()` metric.
|
||||||
|
- ADR-137 `QualityScore` carries fusion coherence, evidence references, and
|
||||||
|
contradiction flags per fused frame.
|
||||||
|
- Model heads emit confidence/uncertainty, but nothing combines domain
|
||||||
|
distance, signal quality, calibration compatibility, and uncertainty into a
|
||||||
|
single decision, and nothing forces a model to stop emitting confident labels
|
||||||
|
when it leaves its calibrated domain.
|
||||||
|
|
||||||
|
## Options considered
|
||||||
|
|
||||||
|
1. **Threshold on model confidence alone.** Rejected: confidently-wrong OOD
|
||||||
|
predictions are exactly the failure mode; confidence is necessary but not
|
||||||
|
sufficient.
|
||||||
|
2. **A per-model bespoke OOD check inside each task head.** Rejected:
|
||||||
|
duplicates logic, cannot be audited uniformly, and does not compose with the
|
||||||
|
calibration certificate or the evidence engine.
|
||||||
|
3. **A shared OOD gate that every inference passes through, fusing four signals
|
||||||
|
against the ADR-301 certificate.** Chosen.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Add an out-of-distribution gate — implemented in a shared crate consumed by the
|
||||||
|
task-head runtime (`wifi-densepose-calibration::runtime` and the model serving
|
||||||
|
path) — that attaches a `DomainState` to **every** inference.
|
||||||
|
|
||||||
|
### 1. Four inputs, one decision
|
||||||
|
|
||||||
|
Each inference carries four measured quantities:
|
||||||
|
|
||||||
|
1. **Domain distance** — fingerprint distance (ADR-301 `distance()`) between
|
||||||
|
live traffic and the active `CalibrationCertificate`, split into the
|
||||||
|
empty-baseline and occupied-baseline components so geometry drift and
|
||||||
|
occupancy-statistics drift are distinguishable.
|
||||||
|
2. **Signal quality** — reuse the ADR-137 quality scoring signals (fusion
|
||||||
|
coherence, contradiction flags) plus per-frame SNR/validity.
|
||||||
|
3. **Calibration compatibility** — is a valid, non-invalidated certificate
|
||||||
|
present for this space (ADR-306) and this signed device (ADR-305)? An
|
||||||
|
expired, invalidated, or device-mismatched certificate is itself a
|
||||||
|
compatibility failure.
|
||||||
|
4. **Uncertainty** — the model head's own predictive uncertainty.
|
||||||
|
|
||||||
|
### 2. State machine: KNOWN → DEGRADED → UNKNOWN
|
||||||
|
|
||||||
|
- **KNOWN** — domain distance within the certificate's compatibility envelope,
|
||||||
|
quality above threshold, certificate valid and compatible, uncertainty low.
|
||||||
|
Confident classifications are returned.
|
||||||
|
- **DEGRADED** — one or more signals crossed a soft threshold (e.g. moderate
|
||||||
|
fingerprint drift within the envelope, elevated uncertainty, a tolerated
|
||||||
|
ADR-137 contradiction flag). Classifications are returned but flagged
|
||||||
|
degraded with the specific reason; downstream consumers must treat them as
|
||||||
|
lower-evidence.
|
||||||
|
- **UNKNOWN** — the room changed materially (empty-baseline drift beyond the
|
||||||
|
envelope, AP channel change, transceiver-geometry change, hardware/device
|
||||||
|
change, or an invalidated/absent certificate). RuView **stops returning
|
||||||
|
confident classifications** and returns UNKNOWN with the triggering cause.
|
||||||
|
This is the required behavior, not an error.
|
||||||
|
|
||||||
|
State transitions are hysteretic (separate enter/exit thresholds) so the gate
|
||||||
|
does not flap on noise. The state, the four input values, and the triggering
|
||||||
|
cause are all reported — never a bare label.
|
||||||
|
|
||||||
|
### 3. Certificate-bound, honest by construction
|
||||||
|
|
||||||
|
- The gate is meaningless without a certificate: with no valid ADR-301
|
||||||
|
certificate for the current space/device, the default state is UNKNOWN, not
|
||||||
|
KNOWN. Absence of evidence is treated as absence of capability.
|
||||||
|
- The `DomainState` and its inputs are emitted to the evidence engine
|
||||||
|
(ADR-304) as part of every inference record, and are an input to the ADR-318
|
||||||
|
capability certificate (a model's capability is bounded by the domain it can
|
||||||
|
hold KNOWN in).
|
||||||
|
- No accuracy number is claimed here; the ADR delivers the gating machinery.
|
||||||
|
The gate's own thresholds are calibration parameters, reported with each
|
||||||
|
decision.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- RuView gains a uniform, auditable answer to "should I trust this inference?"
|
||||||
|
that combines domain, quality, calibration, and uncertainty rather than
|
||||||
|
confidence alone.
|
||||||
|
- Deployments will see more DEGRADED/UNKNOWN results than a
|
||||||
|
confidence-only system, especially right after a room changes. That increase
|
||||||
|
is the product working: it is the difference between honest RF perception and
|
||||||
|
confidently-wrong output.
|
||||||
|
- Every task head that opts into the substrate must route through the gate;
|
||||||
|
heads that bypass it cannot claim a KNOWN state or earn an ADR-318
|
||||||
|
certificate.
|
||||||
|
- The gate couples model serving to the presence of a live calibration
|
||||||
|
certificate, making ADR-301 a hard dependency of confident inference — the
|
||||||
|
intended coupling.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
- `cargo test` on the OOD crate — state-machine transitions on synthetic
|
||||||
|
fixtures: in-envelope drift stays KNOWN; soft-threshold breach → DEGRADED;
|
||||||
|
empty-baseline drift beyond envelope, channel change, geometry change,
|
||||||
|
device mismatch, and invalidated/absent certificate each → UNKNOWN;
|
||||||
|
hysteresis prevents flapping under injected noise; missing certificate
|
||||||
|
defaults to UNKNOWN.
|
||||||
|
- Cross-ADR: consumes an ADR-301 certificate and asserts a drifted fingerprint
|
||||||
|
drives the expected transition; asserts the `DomainState` is present on every
|
||||||
|
emitted inference record consumed by ADR-304.
|
||||||
|
- No confident classification is emitted in the UNKNOWN state in any test —
|
||||||
|
enforced as an assertion, not a convention.
|
||||||
|
- Real-silicon OOD behavior (moving furniture / changing AP channel on a live
|
||||||
|
ESP32 capture and observing the transition) remains a follow-up requiring
|
||||||
|
hardware evidence per CLAUDE.md.
|
||||||
125
docs/adr/ADR-303-ground-truth-synchronization.md
Normal file
125
docs/adr/ADR-303-ground-truth-synchronization.md
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
# ADR-303: Ground-truth synchronization — reference sensors as a formal validation plane
|
||||||
|
|
||||||
|
- **Status**: Accepted — initial implementation (ADR-300 phase 2)
|
||||||
|
- **Date**: 2026-08-11
|
||||||
|
- **Deciders**: ruv
|
||||||
|
- **Tags**: ground-truth, validation, fusion, evidence, benchmark, honesty, substrate
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
This ADR is primitive 3 of the perception-substrate program (ADR-300), authored
|
||||||
|
as **Proposed** in phase 2: it is design intent and a validation plan, not
|
||||||
|
implemented by the phase-1 swarm. It sits on top of the phase-1 certificate
|
||||||
|
spine and feeds the evidence engine (ADR-304) and the real benchmark service
|
||||||
|
(ADR-317). It generalizes the vitals ground-truth rig (ADR-293) from a single
|
||||||
|
measurand to a modality-agnostic plane.
|
||||||
|
|
||||||
|
RuView's evidence discipline (CLAUDE.md; ADR-282 ladder) requires MEASURED
|
||||||
|
accuracy claims to be backed by an independent reference. ADR-293 built exactly
|
||||||
|
this for vitals: reference-series ingest, time alignment (cross-correlation
|
||||||
|
lag + optional clock-drift fit), and agreement statistics (MAE/RMSE/bias/
|
||||||
|
Bland–Altman/within-tolerance), with an `EvidenceGrade` that is only
|
||||||
|
constructible as `Measured` when a real reference, non-zero paired samples,
|
||||||
|
minimum coverage, and a reproducer are present. That machinery is measurand- and
|
||||||
|
device-shaped: it knows about heart rate and breathing rate.
|
||||||
|
|
||||||
|
The substrate needs the same discipline for *every* phenomenon RuView senses —
|
||||||
|
presence, count, localization, pose, posture, activity — and for reference
|
||||||
|
sources of many modalities (cameras, mmWave, pressure mats, wearables, pulse
|
||||||
|
oximeters, microphones, manual labels). The critical design decision is that
|
||||||
|
these reference sensors form a **validation plane**, not additional inference
|
||||||
|
inputs.
|
||||||
|
|
||||||
|
## Options considered
|
||||||
|
|
||||||
|
1. **Fuse reference sensors as extra inference inputs.** Rejected on principle:
|
||||||
|
folding cameras/mmWave into the estimator would make RuView's RF claims
|
||||||
|
unfalsifiable — the reference would be training the thing it is meant to
|
||||||
|
check, and a camera-fed result is no longer a camera-free RF result. It
|
||||||
|
would also violate the ADR-282 layering (RuView is probabilistic
|
||||||
|
exteroception, never ground truth) and the honesty rule against presenting
|
||||||
|
fused-with-camera output as WiFi sensing.
|
||||||
|
2. **One-off rigs per measurand (extend ADR-293 ad hoc each time).** Rejected:
|
||||||
|
duplicates alignment/agreement code per phenomenon and never yields a shared
|
||||||
|
validation surface for the benchmark.
|
||||||
|
3. **A first-class, modality-agnostic `GroundTruth` API that is strictly a
|
||||||
|
validation plane.** Chosen.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Introduce a `GroundTruth` API — a modality-agnostic validation plane that
|
||||||
|
compares RF inference against independent observation and never feeds it.
|
||||||
|
|
||||||
|
### 1. Modality-agnostic reference ingest
|
||||||
|
|
||||||
|
- A `ReferenceObservation` generalizing ADR-293's `ReferenceSeries`: a
|
||||||
|
timestamped, typed observation of a `Phenomenon` (presence, count,
|
||||||
|
localization, pose keypoints, posture, activity, heart rate, breathing rate)
|
||||||
|
from a `ReferenceModality` (camera, mmWave, pressure, wearable, pulse
|
||||||
|
oximeter, microphone, manual label), with device/source metadata and the
|
||||||
|
measurement principle recorded.
|
||||||
|
- Untrusted reference files are validated at the boundary (row-numbered
|
||||||
|
rejections, non-monotonic timestamps are errors), reusing ADR-293's ingest
|
||||||
|
discipline. Camera/mmWave references arrive as exported label/keypoint
|
||||||
|
streams, not live model feeds.
|
||||||
|
|
||||||
|
### 2. Synchronization
|
||||||
|
|
||||||
|
- Generalize ADR-293's time alignment (bounded-lag normalized cross-correlation
|
||||||
|
+ optional linear clock-drift fit) to arbitrary measurands on a common
|
||||||
|
resampled grid, with no interpolation across gaps beyond a configurable
|
||||||
|
limit. Alignment parameters are always reported, never silently applied.
|
||||||
|
- Spatial synchronization where relevant: reference observations are expressed
|
||||||
|
in the ADR-306 spatial ontology so an RF localization/pose result and a
|
||||||
|
camera/mmWave observation are compared in one coordinate frame.
|
||||||
|
|
||||||
|
### 3. Agreement as validation, not fusion
|
||||||
|
|
||||||
|
- A modality-appropriate `AgreementReport` per phenomenon: continuous
|
||||||
|
measurands reuse ADR-293's MAE/RMSE/bias/Bland–Altman/within-tolerance;
|
||||||
|
categorical/detection phenomena (presence, activity) report confusion-matrix
|
||||||
|
metrics; spatial phenomena report localization error percentiles and pose
|
||||||
|
PCK **with the mandatory mean-pose baseline and leakage-free split**
|
||||||
|
(CLAUDE.md; ADR-291).
|
||||||
|
- Session scope is mandatory metadata (subject count, motion state, LOS/NLOS/
|
||||||
|
through-wall, distance band) — a report without scope cannot be constructed,
|
||||||
|
as in ADR-293.
|
||||||
|
|
||||||
|
### 4. Evidence and isolation guarantees
|
||||||
|
|
||||||
|
- The plane is one-directional by type: the inference path has no read access
|
||||||
|
to `GroundTruth` at runtime. A build/test-time isolation check (and the type
|
||||||
|
boundary) prevents a reference observation from becoming an estimator input.
|
||||||
|
- Reports carry an `EvidenceLevel` (ADR-282) and an `EvidenceGrade`
|
||||||
|
constructible as `Measured` only with a real reference, paired samples,
|
||||||
|
coverage, and a reproducer (ADR-293 rule). Reports feed the ADR-304 evidence
|
||||||
|
engine and are the substrate ADR-317 scores against.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- Every phenomenon RuView senses gets the same MEASURED-vs-independent-observer
|
||||||
|
discipline vitals already has, in one shared surface.
|
||||||
|
- Keeping references strictly as validation preserves the falsifiability and
|
||||||
|
the camera-free identity of RF results; it costs the (tempting) accuracy a
|
||||||
|
camera-fused estimator would show, which is the correct trade.
|
||||||
|
- Reference capture is an operational burden (a camera/mmWave rig per validated
|
||||||
|
session); acceptable because it is a validation activity, not a runtime
|
||||||
|
requirement, and it is what turns CLAIMED into MEASURED.
|
||||||
|
- Because this is Proposed (phase 2), the API shape may be revised once the
|
||||||
|
phase-1 spine (ADR-301/299/301/303) lands and the benchmark (ADR-317)
|
||||||
|
exercises it.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
- Unit tests (planned): modality-agnostic ingest rejection cases; alignment
|
||||||
|
recovery of known synthetic offsets/drifts across measurands; agreement math
|
||||||
|
per phenomenon against hand-computed fixtures; pose PCK path requires a
|
||||||
|
mean-pose baseline and rejects leaky splits; evidence-grade constructibility;
|
||||||
|
the isolation check fails a build that wires a reference into the inference
|
||||||
|
path.
|
||||||
|
- Cross-ADR: an ADR-317 benchmark scenario consumes `GroundTruth` reports as
|
||||||
|
its scored reference; ADR-304 ingests the agreement reports as evidence
|
||||||
|
records.
|
||||||
|
- Real-session validation (RF capture synchronized with a real camera/mmWave/
|
||||||
|
pressure/wearable reference) is the phase-2 exit and requires hardware
|
||||||
|
evidence per CLAUDE.md; a synthetic run is not hardware evidence.
|
||||||
117
docs/adr/ADR-304-evidence-engine.md
Normal file
117
docs/adr/ADR-304-evidence-engine.md
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
# ADR-304: Evidence engine — MLflow for physical sensing
|
||||||
|
|
||||||
|
- **Status**: Accepted — initial implementation planned (ADR-300 phase 1)
|
||||||
|
- **Date**: 2026-08-11
|
||||||
|
- **Deciders**: ruv
|
||||||
|
- **Tags**: evidence, provenance, ledger, accuracy, drift, benchmark, honesty, substrate
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
This ADR is primitive 4 of the perception-substrate program (ADR-300) and a
|
||||||
|
central pillar of the phase-1 certificate spine. It consumes the domain state
|
||||||
|
from out-of-distribution detection (ADR-302) and the calibration age from the
|
||||||
|
calibration certificate (ADR-301), it is the store that capability certificates
|
||||||
|
(ADR-318) are minted from, and it is the accuracy source the real benchmark
|
||||||
|
service (ADR-317) reads. In phase 2 it ingests agreement reports from the
|
||||||
|
ground-truth plane (ADR-303).
|
||||||
|
|
||||||
|
The strategic assessment (ADR-300) judged this primitive **more commercially
|
||||||
|
important than another pose architecture**: what unblocks OEM and integrator
|
||||||
|
conversations is not a higher headline number but a defensible, auditable record
|
||||||
|
of how a model actually performs, per room, per device, per subject, over time.
|
||||||
|
MLflow made ML experiments trackable; physical sensing needs the equivalent for
|
||||||
|
deployed accuracy, drift, and evidence level — an append-only ledger, not a
|
||||||
|
dashboard that overwrites yesterday's number.
|
||||||
|
|
||||||
|
RuView already has the constituent evidence types; what is missing is the ledger
|
||||||
|
that unifies them per deployment context:
|
||||||
|
|
||||||
|
- RuField provenance/signature types (ADR-260/262/277/279) — the signed,
|
||||||
|
provenance-bearing record types to reuse rather than reinvent.
|
||||||
|
- The AetherArena witness-ledger pattern (ADR-149) — an append-only,
|
||||||
|
witness-anchored ledger of scored results, the structural template here.
|
||||||
|
- `frame::EvidenceLevel` L0–L5 (ADR-282) — the mandatory evidence tag every
|
||||||
|
record carries.
|
||||||
|
- ADR-302 `DomainState`, ADR-137 `QualityScore`, ADR-301 certificate version
|
||||||
|
and age — the per-inference signals to accumulate.
|
||||||
|
|
||||||
|
## Options considered
|
||||||
|
|
||||||
|
1. **Log accuracy to flat files / metrics dashboards.** Rejected: mutable,
|
||||||
|
un-signed, un-scoped, and not comparable over time — the exact gap.
|
||||||
|
2. **Reuse a general experiment tracker (MLflow itself).** Rejected: it is
|
||||||
|
experiment-time, not deployment-time; it has no notion of room/device/
|
||||||
|
subject context, calibration age, evidence level, or signed provenance, and
|
||||||
|
it would add an external service dependency contrary to the substrate's
|
||||||
|
edge-first, dependency-light direction.
|
||||||
|
3. **A native append-only evidence ledger reusing RuField record types and the
|
||||||
|
AetherArena ledger pattern.** Chosen.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Build an **evidence engine**: a per-`(room, device, subject)` append-only
|
||||||
|
accuracy ledger that every model automatically writes to.
|
||||||
|
|
||||||
|
### 1. The evidence record
|
||||||
|
|
||||||
|
- An `EvidenceRecord` keyed by context — space id (ADR-306), signed device id
|
||||||
|
(ADR-305), and subject id where consented and available — carrying: model
|
||||||
|
version; calibration certificate version and **age** (ADR-301); the ADR-302
|
||||||
|
`DomainState` (KNOWN/DEGRADED/UNKNOWN) and its four inputs; the ADR-137
|
||||||
|
quality signals; predictive uncertainty; and, when a reference is present
|
||||||
|
(ADR-303), the agreement result (accuracy, false-positive rate). Each record
|
||||||
|
carries exactly one `EvidenceLevel` (L0–L5, ADR-282).
|
||||||
|
- Records are **append-only** and signed with RuField signature types
|
||||||
|
(ADR-260/262/277/279); the ledger is anchored in the witness chain (ADR-319),
|
||||||
|
following the AetherArena witness-ledger pattern (ADR-149). No record is ever
|
||||||
|
mutated in place — a correction is a new record.
|
||||||
|
|
||||||
|
### 2. Per-context accuracy accounting
|
||||||
|
|
||||||
|
- The engine maintains, per `(room, device, subject)` context: measured
|
||||||
|
accuracy (only where an ADR-303 reference backs it — otherwise the record is
|
||||||
|
CLAIMED/SYNTHETIC, never MEASURED), false-positive rate, drift trajectory
|
||||||
|
(fingerprint distance over time from ADR-301), the fraction of inferences in
|
||||||
|
each domain state, calibration age distribution, and model-version history.
|
||||||
|
- Aggregation is a pure function over the append-only log at a queried time —
|
||||||
|
the ledger is the source of truth; summaries are derived, never authoritative
|
||||||
|
(mirroring CLAUDE.md's "source over summaries" rule).
|
||||||
|
|
||||||
|
### 3. Honesty enforced in the record
|
||||||
|
|
||||||
|
- The engine cannot upgrade an evidence level; a level is set by the record's
|
||||||
|
provenance at write time (synthetic input → L0/`Synthetic`; no reference →
|
||||||
|
CLAIMED; reference + reproducer → MEASURED), reusing the ADR-282/ADR-291/
|
||||||
|
ADR-293 constructor discipline. A benchmark or certificate reading the ledger
|
||||||
|
gets the honest level, not an optimistic rollup.
|
||||||
|
- No benchmark numbers are invented by this ADR; it delivers the ledger and the
|
||||||
|
accounting. Empty contexts report "no evidence," which downstream (ADR-318)
|
||||||
|
must treat as no capability.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- RuView gains a single auditable answer to "how well does this model actually
|
||||||
|
work, here, on this device, for this subject, and how fresh is the
|
||||||
|
calibration?" — the artifact OEM/integrator diligence actually asks for.
|
||||||
|
- ADR-318 capability certificates become derivable (a certificate is a signed
|
||||||
|
attestation over a slice of the ledger) and ADR-317 gains a real accuracy
|
||||||
|
source per PR instead of self-reported numbers.
|
||||||
|
- The append-only, signed design has storage and key-management cost; bounded
|
||||||
|
by per-context retention policy and by reusing the existing RuField/witness
|
||||||
|
infrastructure rather than a new store.
|
||||||
|
- Some contexts will show sparse or unflattering evidence. Surfacing that is the
|
||||||
|
point; the engine must never paper over a thin context with a global average.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
- `cargo test` on the evidence-engine crate — append-only invariant (no
|
||||||
|
in-place mutation; corrections are new records); per-context aggregation math
|
||||||
|
against fixtures; evidence-level is set by provenance and cannot be upgraded;
|
||||||
|
signature round-trip and tamper rejection; witness anchoring; empty-context
|
||||||
|
queries return "no evidence" not a fabricated number.
|
||||||
|
- Cross-ADR: ingests ADR-302 `DomainState` and (phase 2) ADR-303 agreement
|
||||||
|
reports; an ADR-318 test mints a certificate from a ledger slice and an
|
||||||
|
ADR-317 test reads accuracy from the ledger.
|
||||||
|
- Real-deployment evidence (a populated ledger from live ESP32 captures with
|
||||||
|
ADR-303 references) is the maturity milestone and requires hardware evidence
|
||||||
|
per CLAUDE.md; a synthetic ledger is L0 by construction.
|
||||||
147
docs/adr/ADR-305-authenticated-sensor-identity.md
Normal file
147
docs/adr/ADR-305-authenticated-sensor-identity.md
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
# ADR-305: Authenticated sensor identity — RF chain of custody
|
||||||
|
|
||||||
|
- **Status**: Accepted — initial implementation planned (ADR-300 phase 1)
|
||||||
|
- **Date**: 2026-08-11
|
||||||
|
- **Deciders**: ruv
|
||||||
|
- **Tags**: security, identity, provenance, sensor-ingest, attestation, phase-1
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
This ADR is a child of **ADR-300** (perception substrate program) and owns
|
||||||
|
primitive #5, *authenticated sensor identity*. In the ADR-300 dependency DAG it
|
||||||
|
is a spine root that, together with **ADR-306** (canonical spatial ontology),
|
||||||
|
feeds **ADR-301** (calibration certificate) and **ADR-319** (witness chain).
|
||||||
|
|
||||||
|
RuView's inference outputs are only as trustworthy as the measurements that
|
||||||
|
produced them, yet today a measurement's origin is essentially assertional. The
|
||||||
|
UDP data plane accepts frames from any reachable host: **ADR-296** shipped step
|
||||||
|
one — a loopback-default bind (`--udp-bind`) and an optional source
|
||||||
|
IP/CIDR allowlist — and explicitly deferred to a follow-up ADR "per-device
|
||||||
|
provisioned keys, MAC/AEAD, device identifiers, monotonic sequence numbers,
|
||||||
|
freshness window, and replay rejection." **This ADR is that step two.** ADR-296
|
||||||
|
correctly documented that an IP allowlist does not stop LAN spoofing; a
|
||||||
|
cryptographic device identity is what closes that gap.
|
||||||
|
|
||||||
|
Foundations already exist in the tree and must be reused rather than rebuilt:
|
||||||
|
|
||||||
|
- `wifi-densepose-rufield` provides `DeviceId`, `Signature`, `SignatureBlock`,
|
||||||
|
`FrameProvenance`, `ProvenanceClass`, and `SignatureVerifyError` — the type
|
||||||
|
vocabulary for a signed frame.
|
||||||
|
- `wifi-densepose-bfld` provides `CapabilityAttestation` and
|
||||||
|
`PrivacyAttestationProof` (BFLD attestation, ADR-141) — the device-side
|
||||||
|
attestation surface.
|
||||||
|
- **ADR-295** defines the source-provenance state machine and freshness
|
||||||
|
(`SpatialStateFreshness`); a monotonic sequence and freshness window slot
|
||||||
|
into that machine rather than duplicating it.
|
||||||
|
|
||||||
|
The gap is not new primitives but an **end-to-end chain of custody**: a frame
|
||||||
|
must be traceable as `device → signed measurement → sequence → timestamp →
|
||||||
|
calibration → inference → signed event`, with every link verified at the
|
||||||
|
ingest boundary per CLAUDE.md ("validate untrusted input at every network,
|
||||||
|
hardware, and FFI boundary; default to least authority").
|
||||||
|
|
||||||
|
## Options considered
|
||||||
|
|
||||||
|
1. **Stop at ADR-296 (bind + IP allowlist).** Rejected: ADR-296 itself names
|
||||||
|
this insufficient on a trusted LAN; any on-subnet host can still spoof a
|
||||||
|
device.
|
||||||
|
2. **TLS/DTLS transport authentication only.** Rejected: authenticates the
|
||||||
|
*channel*, not the *measurement*. It does not survive store-and-forward,
|
||||||
|
does not bind a sequence number into the signed object, and gives the
|
||||||
|
downstream evidence/witness layers nothing to re-verify offline.
|
||||||
|
3. **Per-device signing keys with a signed measurement envelope, monotonic
|
||||||
|
sequence, and freshness window, reusing the RuField/BFLD types.** Chosen.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Introduce an **authenticated frame envelope** carried through the sensing
|
||||||
|
server, built from existing RuField/BFLD types.
|
||||||
|
|
||||||
|
### 1. Per-device provisioned identity
|
||||||
|
|
||||||
|
- Each radio (ESP32-S3/C6 node or adapter) is provisioned with a keypair; the
|
||||||
|
device holds the private key, the server holds the enrolled public key bound
|
||||||
|
to a `DeviceId`. Provisioning is an explicit, authorized enrollment step — a
|
||||||
|
device is untrusted until an operator enrolls its public key. Private keys are
|
||||||
|
never logged or committed (CLAUDE.md credential rule); the ESP32 side follows
|
||||||
|
`firmware/esp32-csi-node` key-handling notes.
|
||||||
|
- The enrollment record binds `DeviceId → public key → capabilities`
|
||||||
|
(via `CapabilityAttestation`, ADR-141), so a device can only assert
|
||||||
|
measurements for phenomena it is attested to sense. This is what **ADR-318**
|
||||||
|
(capability certificate) later consumes.
|
||||||
|
|
||||||
|
### 2. Signed measurement envelope
|
||||||
|
|
||||||
|
- A frame on the wire becomes a `SignatureBlock` over the canonical
|
||||||
|
serialization of `{DeviceId, sequence, timestamp, measurement-hash}`. The
|
||||||
|
measurement itself (CSI/CIR payload) is covered by the hash so tampering is
|
||||||
|
detectable without embedding the whole payload twice.
|
||||||
|
- Verification uses `Signature`/`SignatureVerifyError` from
|
||||||
|
`wifi-densepose-rufield`. A frame that fails signature verification is
|
||||||
|
dropped and counted, exactly as ADR-296 drops disallowed sources — an `Err`
|
||||||
|
at the boundary, never a warning that proceeds.
|
||||||
|
|
||||||
|
### 3. Monotonic sequence + freshness (replay defense)
|
||||||
|
|
||||||
|
- Each device maintains a strictly monotonic per-device sequence number. The
|
||||||
|
server tracks the last accepted sequence per `DeviceId`; a non-increasing
|
||||||
|
sequence is rejected as a replay.
|
||||||
|
- A freshness window bounds `timestamp` against the server clock skew budget;
|
||||||
|
stale frames are rejected. This reuses ADR-295's `SpatialStateFreshness`
|
||||||
|
rather than inventing a parallel notion of staleness, and composes with
|
||||||
|
ADR-297's stale-node handling.
|
||||||
|
|
||||||
|
### 4. Chain of custody into the event
|
||||||
|
|
||||||
|
- On successful verification the frame's `FrameProvenance` records the verified
|
||||||
|
`DeviceId`, sequence, and timestamp. Calibration (ADR-301) and inference
|
||||||
|
annotate their transforms, and the emitted spatial event (ADR-306 ontology)
|
||||||
|
carries a signed provenance lineage. `ProvenanceClass` still enforces the
|
||||||
|
synthetic/measured invariant from ADR-282/ADR-279 (invariant 6): a measured
|
||||||
|
chain of custody can never be aliased to synthetic and vice-versa.
|
||||||
|
- This end-to-end signed lineage is the substrate the **ADR-319** witness chain
|
||||||
|
serializes and the **ADR-318** capability certificate points at as evidence.
|
||||||
|
|
||||||
|
### Compatibility
|
||||||
|
|
||||||
|
- The envelope is **opt-in per deployment** and negotiated at enrollment. An
|
||||||
|
un-enrolled single-node desktop deployment keeps working unauthenticated
|
||||||
|
behind ADR-296's loopback default; a routable, multi-node, or fleet
|
||||||
|
deployment (ADR-316) requires enrolled identities. The startup security log
|
||||||
|
(ADR-296) is extended to state whether frame authentication is active.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- LAN spoofing and replay — the residual risks ADR-296 named plainly — are
|
||||||
|
closed for enrolled deployments. The measurement, not merely the channel, is
|
||||||
|
authenticated, so the guarantee survives store-and-forward into the witness
|
||||||
|
chain.
|
||||||
|
- Enrollment/key-management is now an operational responsibility (provisioning,
|
||||||
|
rotation, revocation). This is documented as a deployment step; key rotation
|
||||||
|
and revocation lists are specified here but their fleet distribution is
|
||||||
|
owned by ADR-316.
|
||||||
|
- Signature verification adds per-frame CPU cost at ingest; bounded and
|
||||||
|
measured in validation below. It is a deliberate cost for a verifiable chain
|
||||||
|
of custody.
|
||||||
|
- A schema addition to the frame contract; un-enrolled deployments are
|
||||||
|
unaffected, and the migration accessor mirrors ADR-297's approach.
|
||||||
|
- **No spoof-resistance claim is MEASURED until validated on real silicon**
|
||||||
|
(CLAUDE.md hardware rule): a passing unit/integration suite demonstrates the
|
||||||
|
logic, not the fielded device path.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
- Unit tests (`cargo test -p wifi-densepose-sensing-server`,
|
||||||
|
`-p wifi-densepose-rufield`): valid envelope accepted; bad signature
|
||||||
|
rejected and counted; non-monotonic sequence rejected as replay; out-of-
|
||||||
|
window timestamp rejected; un-enrolled `DeviceId` rejected; measured/synthetic
|
||||||
|
provenance aliasing rejected (ADR-279 invariant 6).
|
||||||
|
- Integration test: a captured/synthesized multi-frame stream produces a
|
||||||
|
verifiable `device → … → signed event` lineage that ADR-319 can serialize and
|
||||||
|
re-verify offline.
|
||||||
|
- Benchmark (`cargo bench`): per-frame verification cost, to bound ingest
|
||||||
|
overhead.
|
||||||
|
- **Real-silicon evidence required** before any deployment-grade
|
||||||
|
authentication claim: a captured boot/runtime log from an enrolled ESP32 node
|
||||||
|
signing frames end-to-end. A successful build or simulator run is not
|
||||||
|
hardware evidence.
|
||||||
142
docs/adr/ADR-306-canonical-spatial-ontology.md
Normal file
142
docs/adr/ADR-306-canonical-spatial-ontology.md
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
# ADR-306: Canonical spatial ontology — one Site→…→Event model for every surface
|
||||||
|
|
||||||
|
- **Status**: Accepted — initial implementation planned (ADR-300 phase 1)
|
||||||
|
- **Date**: 2026-08-11
|
||||||
|
- **Deciders**: ruv
|
||||||
|
- **Tags**: ontology, worldgraph, schema, mqtt, matter, rufield, phase-1
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
This ADR is a child of **ADR-300** and owns primitive #6, *canonical spatial
|
||||||
|
ontology*. In the ADR-300 DAG it is a spine root alongside **ADR-305**
|
||||||
|
(authenticated identity) and feeds every downstream primitive that must speak
|
||||||
|
about *where* and *what*: **ADR-301** (calibration), **ADR-307** (tracking,
|
||||||
|
consumes `Track`/`Person`), **ADR-319** (witness chain), and every external
|
||||||
|
surface named in the ADR-300 consequences (MQTT, REST, WebSocket, RuField,
|
||||||
|
Matter, agents).
|
||||||
|
|
||||||
|
RuView currently expresses "where something is" in several overlapping,
|
||||||
|
per-surface schemas: the MQTT/Home-Assistant mapper has its own node/room
|
||||||
|
shapes (**ADR-297** just introduced `NodeInference`/`RoomInference` to
|
||||||
|
disambiguate node vs. room state); the `worldgraph` crate models a spatial
|
||||||
|
graph; RuField carries `SemanticProvenance`; Matter/HomeKit has its own area
|
||||||
|
model. The same physical fact — "a person is in the kitchen" — is re-encoded
|
||||||
|
differently on each surface, and the review called for "one canonical
|
||||||
|
`NodeInference`/`RoomInference` contract" (ADR-297 consequences). Without a
|
||||||
|
single semantic model, every new surface multiplies the translation matrix and
|
||||||
|
each translation is a place where provenance and evidence level (ADR-282) can
|
||||||
|
be silently dropped.
|
||||||
|
|
||||||
|
Substantial scaffolding already exists and must be **reused/extended, not
|
||||||
|
rebuilt**. `v2/crates/worldgraph/wifi-densepose-worldgraph` already defines:
|
||||||
|
|
||||||
|
- `WorldNode` variants including `Room { area_id, name, bounds_enu, floor }`,
|
||||||
|
`Zone { parent_room, … }`, `Wall { rf_attenuation_db }`, and `Doorway`.
|
||||||
|
- `WorldEdge` variants including `Observes { quality, last_seen_unix_ms }`,
|
||||||
|
`LocatedIn { since_unix_ms }`, `AdjacentTo { via_doorway }`, and `Supports`.
|
||||||
|
- `WorldGraph`, `WorldGraphSnapshot`, `WorldId`, `SemanticProvenance`,
|
||||||
|
`PersonPosition`, and a HomeCore `area_id` linkage join key (ADR-127).
|
||||||
|
|
||||||
|
The `worldgraph` crate is therefore the natural home for the canonical model.
|
||||||
|
What is missing is (a) the full `Site → Building → Floor → Space → Zone`
|
||||||
|
containment spine above `Room`, (b) first-class `Sensor`, `Object`,
|
||||||
|
`Observation`, `Track`, and `Event` node types, (c) one canonical serialization
|
||||||
|
that every surface consumes, and (d) a documented migration path from the
|
||||||
|
existing per-surface schemas.
|
||||||
|
|
||||||
|
## Options considered
|
||||||
|
|
||||||
|
1. **Leave each surface with its own schema; add adapters pairwise.** Rejected:
|
||||||
|
O(surfaces²) translations, and provenance/evidence loss at each hop.
|
||||||
|
2. **Invent a new top-level ontology crate.** Rejected: `worldgraph` already
|
||||||
|
models rooms, zones, walls, doorways, observation edges, and HomeCore
|
||||||
|
linkage; a parallel crate would fork the world model.
|
||||||
|
3. **Extend `worldgraph` into the canonical ontology and make every surface a
|
||||||
|
projection of it.** Chosen.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Adopt **one canonical spatial ontology**, hosted in the `worldgraph` crate,
|
||||||
|
that every RuView surface reads from and writes to.
|
||||||
|
|
||||||
|
### 1. The containment spine and entity types
|
||||||
|
|
||||||
|
Define the full node taxonomy as an extension of the existing `WorldNode`:
|
||||||
|
|
||||||
|
```
|
||||||
|
Site ▸ Building ▸ Floor ▸ Space ▸ Zone
|
||||||
|
└─▸ { Sensor, Person, Object,
|
||||||
|
Observation, Track, Event }
|
||||||
|
```
|
||||||
|
|
||||||
|
- `Site`, `Building`, `Floor`, `Space` are new containment `WorldNode`
|
||||||
|
variants above the existing `Room` (mapped to `Space`, keeping its `area_id`
|
||||||
|
and `bounds_enu`) and `Zone`. `Wall`/`Doorway` remain as topological
|
||||||
|
elements. Containment reuses the existing `LocatedIn`/`AdjacentTo` edge
|
||||||
|
vocabulary; a new `PartOf` edge expresses the pure hierarchy
|
||||||
|
(Zone `PartOf` Space `PartOf` Floor …).
|
||||||
|
- `Sensor` is the entity **ADR-305** authenticates (`DeviceId` as its stable
|
||||||
|
identity) and **ADR-320** (HAL, phase 2) describes the hardware of. `Person`,
|
||||||
|
`Object`, `Observation`, `Track`, and `Event` are first-class nodes.
|
||||||
|
`Observes`/`LocatedIn` edges already carry quality and dwell timestamps.
|
||||||
|
- `Track` and `Person` are defined **here** as the ontology contract that
|
||||||
|
**ADR-307** (persistent tracking) produces and updates. `Observation` is what
|
||||||
|
an authenticated frame (ADR-305) becomes after calibration (ADR-301), and
|
||||||
|
`Event` is the governed output that ADR-318 certifies and ADR-319 witnesses.
|
||||||
|
|
||||||
|
### 2. Canonical serialization
|
||||||
|
|
||||||
|
- A single, versioned serialization (serde-based, stable field names) is the
|
||||||
|
one wire/at-rest representation. Every surface — MQTT/Home-Assistant, REST,
|
||||||
|
WebSocket, RuField observations, Matter/HomeKit, agent queries — is a
|
||||||
|
**projection** of this model, not an independent schema. `NodeInference` and
|
||||||
|
`RoomInference` (ADR-297) become projections of `Sensor→Observes` and the
|
||||||
|
`Space`-level fused inference respectively, so ADR-297's node/room separation
|
||||||
|
is preserved by construction rather than re-encoded per surface.
|
||||||
|
- Every node and edge carries `SemanticProvenance` and exactly one
|
||||||
|
`EvidenceLevel` (L0–L5, ADR-282 policy): the evidence ladder travels *with*
|
||||||
|
the fact across every projection, so no surface can silently upgrade or drop
|
||||||
|
it.
|
||||||
|
|
||||||
|
### 3. Migration path
|
||||||
|
|
||||||
|
- Each existing per-surface schema gets a documented, tested bidirectional
|
||||||
|
mapping to/from the canonical model, plus a migration accessor for consumers
|
||||||
|
reading the old shape (mirroring ADR-297's migration accessor). Surfaces are
|
||||||
|
cut over one at a time; a surface is "canonical" once its projection is the
|
||||||
|
only encoder it uses. Until cutover, the mapping layer is authoritative and
|
||||||
|
round-trip-tested so no fact is lost in translation.
|
||||||
|
- The `worldgraph` HomeCore `area_id` linkage (ADR-127) remains the join key
|
||||||
|
between the ontology's `Space` and external area registries.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- The translation matrix collapses from O(surfaces²) to O(surfaces): each
|
||||||
|
surface implements one projection. New surfaces (ROS 2, OpenUSD, OPC UA per
|
||||||
|
ADR-282's roadmap) plug in as additional projections.
|
||||||
|
- Provenance and evidence level are carried uniformly; a fact cannot cross a
|
||||||
|
surface boundary and lose its lineage or its L-level.
|
||||||
|
- A schema change reaching every surface; managed by the versioned
|
||||||
|
serialization and per-surface migration accessors. Single-node deployments
|
||||||
|
keep working (one `Sensor`, one `Space`).
|
||||||
|
- The ontology is a *representation*, not an inference engine: it says nothing
|
||||||
|
about *how* a `Track` or `Event` is produced — that is owned by ADR-307,
|
||||||
|
ADR-301, ADR-302, and the model layer. This ADR does not itself make any
|
||||||
|
accuracy claim to grade.
|
||||||
|
- Extending `worldgraph` grows one crate's surface rather than forking a second
|
||||||
|
world model; the geo/worldmodel sub-crates continue to build on the same node
|
||||||
|
vocabulary.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
- Unit tests (`cargo test -p wifi-densepose-worldgraph`): containment-spine
|
||||||
|
construction and invariants (a `Zone` is `PartOf` exactly one `Space`, a
|
||||||
|
`Space` on exactly one `Floor`, etc.); round-trip serialization of every node
|
||||||
|
and edge type; every node/edge carries exactly one `EvidenceLevel`.
|
||||||
|
- Migration tests: each per-surface schema maps to the canonical model and back
|
||||||
|
with no loss of provenance or evidence level; `NodeInference`/`RoomInference`
|
||||||
|
(ADR-297) project and re-project identically.
|
||||||
|
- Contract test: a single canonical `Event` renders correctly through the MQTT,
|
||||||
|
REST, and WebSocket projections from one source of truth.
|
||||||
|
- No accuracy numbers are claimed; this ADR delivers the shared representation
|
||||||
|
the rest of the phase-1 spine writes into.
|
||||||
135
docs/adr/ADR-307-persistent-identity-tracking.md
Normal file
135
docs/adr/ADR-307-persistent-identity-tracking.md
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
# ADR-307: Persistent identity & tracking — privacy-preserving probabilistic tracks
|
||||||
|
|
||||||
|
- **Status**: Accepted — initial implementation (ADR-300 phase 2)
|
||||||
|
- **Date**: 2026-08-11
|
||||||
|
- **Deciders**: ruv
|
||||||
|
- **Tags**: tracking, identity, privacy, fusion, worldgraph, phase-2
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
This ADR is a child of **ADR-300** and owns primitive #7, *persistent identity
|
||||||
|
& tracking*. In the ADR-300 DAG it is a phase-2 primitive sitting on the
|
||||||
|
phase-1 spine: it **consumes the ADR-306 ontology** (producing and updating the
|
||||||
|
`Track` and `Person` node types defined there), it relies on **ADR-305**
|
||||||
|
authenticated identity so that the observations it associates have a verified
|
||||||
|
origin, and its outputs are governed `Event`s that ADR-318/ADR-319 can certify
|
||||||
|
and witness.
|
||||||
|
|
||||||
|
The product need is to reason about *persistent entities* — "person_7 entered
|
||||||
|
the kitchen, then the hallway, then the bedroom" — across radios, modalities,
|
||||||
|
rooms, and time. The hard constraint is that this must happen **without
|
||||||
|
establishing civil identity**. RuView is camera-free (ADR-282), and a
|
||||||
|
persistent pseudonymous track must never become, or be joinable to, a real-
|
||||||
|
world named individual. This is a privacy property to be enforced *by
|
||||||
|
construction*, not a policy footnote.
|
||||||
|
|
||||||
|
Substantial scaffolding already exists in
|
||||||
|
`v2/crates/wifi-densepose-mat/src/tracking` and must be **reused/extended, not
|
||||||
|
rebuilt**:
|
||||||
|
|
||||||
|
- `SurvivorTracker`, `TrackedSurvivor`, `TrackId`, `TrackerConfig`,
|
||||||
|
`TrackLifecycle`, and `TrackState` — a multi-target tracker with lifecycle
|
||||||
|
(tentative/active/lost/terminal) and a `TrackId` backed by a UUID
|
||||||
|
(`as_uuid`).
|
||||||
|
- `KalmanState` with `predict`/`update`, `position`, `velocity`,
|
||||||
|
`position_uncertainty`, and `mahalanobis_distance_sq` — the motion model and
|
||||||
|
gating distance.
|
||||||
|
- `CsiFingerprint`, `DetectionObservation`, `AssociationResult`, and the
|
||||||
|
`can_reidentify`/`matches`/`mark_rescued`/`rescue` re-identification surface —
|
||||||
|
the appearance/fingerprint channel for track continuity.
|
||||||
|
|
||||||
|
What is missing is (a) continuity **across radios, modalities, and rooms** (the
|
||||||
|
tracker today reasons within a node/room context), (b) a **persistent** entity
|
||||||
|
that survives track loss and hand-off between spaces, and (c) an explicit
|
||||||
|
**privacy boundary** that guarantees no civil-identity binding.
|
||||||
|
|
||||||
|
## Options considered
|
||||||
|
|
||||||
|
1. **Per-room independent trackers, no cross-room identity.** Rejected: cannot
|
||||||
|
express "person_7 moved kitchen → hallway → bedroom"; loses the entity at
|
||||||
|
every room boundary.
|
||||||
|
2. **Global identity keyed on a strong biometric fingerprint.** Rejected: a
|
||||||
|
fingerprint strong enough to re-identify across long gaps trends toward a
|
||||||
|
civil-identity-grade biometric — exactly what the privacy constraint
|
||||||
|
forbids.
|
||||||
|
3. **Probabilistic persistent tracks with bounded, decaying pseudonymous
|
||||||
|
association, built on the existing MAT tracker.** Chosen.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Extend `wifi-densepose-mat/tracking` into a **cross-domain persistent track
|
||||||
|
layer** that produces ADR-306 `Track`/`Person` nodes.
|
||||||
|
|
||||||
|
### 1. Persistent probabilistic entity
|
||||||
|
|
||||||
|
- A persistent entity is a pseudonymous `Person` node (ADR-306) with a stable
|
||||||
|
synthetic id (e.g. `person_7`) backed by the existing `TrackId`/UUID. It
|
||||||
|
aggregates one or more `SurvivorTracker` tracks over time and space and holds
|
||||||
|
a **probabilistic** continuity belief — association is never asserted as
|
||||||
|
certain, and every hand-off carries a confidence.
|
||||||
|
- Continuity across a track-loss gap reuses the existing re-identification
|
||||||
|
surface (`can_reidentify`, `CsiFingerprint`, `AssociationResult`), extended
|
||||||
|
with a **time- and distance-decayed** association prior so that confidence in
|
||||||
|
"same entity" falls with the size of the gap. Beyond a bounded horizon the
|
||||||
|
association is dropped and a new pseudonym is minted rather than forcing a
|
||||||
|
join — under-linking is the privacy-safe failure mode.
|
||||||
|
|
||||||
|
### 2. Cross-radio / cross-modality / cross-room continuity
|
||||||
|
|
||||||
|
- Association operates over the ADR-306 ontology graph: `Observes` edges from
|
||||||
|
multiple `Sensor`s and `AdjacentTo`/`Doorway` topology constrain plausible
|
||||||
|
hand-offs (a person can only move between adjacent spaces). The existing
|
||||||
|
`mahalanobis_distance_sq` gating extends to a fused observation across
|
||||||
|
modalities rather than a single node's detections.
|
||||||
|
- Fusion here is track-level association; the underlying multi-modality fusion
|
||||||
|
(radar/mmWave per ADR-063, multistatic per ADR-029, and real sensor fusion
|
||||||
|
per ADR-311) supplies the observations. This ADR depends on those for the raw
|
||||||
|
cross-modality evidence and does not re-implement sensor fusion.
|
||||||
|
|
||||||
|
### 3. Privacy boundary (by construction)
|
||||||
|
|
||||||
|
- **No civil-identity binding.** The persistent id is a synthetic pseudonym
|
||||||
|
with no field, edge, or join key to any name, account, phone, MAC, or other
|
||||||
|
civil identifier. The type carries no such field, so binding is impossible in
|
||||||
|
the schema, not merely discouraged.
|
||||||
|
- The `CsiFingerprint` used for re-identification is **bounded and decaying**:
|
||||||
|
it is scoped to short-horizon continuity, is not persisted as a long-term
|
||||||
|
biometric template, and expires. This keeps re-identification useful for
|
||||||
|
"same person across the hallway" while structurally unable to serve "this is
|
||||||
|
the same person who visited last month."
|
||||||
|
- Every `Track`/`Person`/`Event` produced carries `SemanticProvenance` and an
|
||||||
|
`EvidenceLevel` (ADR-282), and honors the ADR-277/ADR-280 edge governance and
|
||||||
|
ADR-141 attestation — a pseudonymous track is still governed P-class data.
|
||||||
|
Tracking accuracy is a per-domain claim to be tagged MEASURED/CLAIMED/
|
||||||
|
SYNTHETIC with a reproducer; **this ADR claims no accuracy number.**
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- RuView can express persistent, cross-room trajectories for automation and
|
||||||
|
analytics while remaining camera-free and civil-identity-free.
|
||||||
|
- The privacy-safe failure mode is **under-linking** (mint a fresh pseudonym
|
||||||
|
when unsure), which will fragment a trajectory across long gaps or sparse
|
||||||
|
coverage. This is a deliberate trade: a fragmented pseudonym is safe, a
|
||||||
|
wrong civil-identity join is not.
|
||||||
|
- Extends an existing tracker rather than forking one; single-room single-radio
|
||||||
|
deployments keep the current behavior (one entity = one track).
|
||||||
|
- Cross-modality quality depends on ADR-311/ADR-063/ADR-029 landing; until then
|
||||||
|
continuity is WiFi-primary and its limits are stated, not hidden.
|
||||||
|
- Being phase 2, this ADR is design intent; it will be revised as the ADR-306
|
||||||
|
ontology and ADR-305 identity spine finalize.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
- Unit tests (`cargo test -p wifi-densepose-mat`): decayed association prior
|
||||||
|
(confidence falls with gap; drops beyond horizon → new pseudonym);
|
||||||
|
topology-constrained hand-off (no association across non-adjacent spaces);
|
||||||
|
schema check that a `Person`/`Track` carries no civil-identifier field.
|
||||||
|
- Integration test against a synthetic multi-room, multi-radio scenario:
|
||||||
|
a scripted walk kitchen → hallway → bedroom yields one persistent pseudonym
|
||||||
|
with per-hand-off confidence, and a deliberately ambiguous crossing produces
|
||||||
|
two pseudonyms rather than a false join.
|
||||||
|
- Evidence discipline: any tracking-continuity accuracy is reported only with
|
||||||
|
the ADR-291 leakage-free protocol and an evidence tag; no number is asserted
|
||||||
|
here.
|
||||||
|
- Privacy review: confirm no persisted long-term biometric template and no
|
||||||
|
civil-identity join path, as an explicit checklist item before any pilot.
|
||||||
138
docs/adr/ADR-308-sensor-placement-optimizer.md
Normal file
138
docs/adr/ADR-308-sensor-placement-optimizer.md
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
# ADR-308: Sensor placement optimizer — floorplan + inventory → recommended positions
|
||||||
|
|
||||||
|
- **Status**: Accepted — initial implementation (ADR-300 phase 3)
|
||||||
|
- **Date**: 2026-08-11
|
||||||
|
- **Deciders**: ruv
|
||||||
|
- **Tags**: placement, planning, rf-twin, coverage, worldgraph, phase-3
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
This ADR is a child of **ADR-300** and owns primitive #8, *sensor placement
|
||||||
|
optimizer*. In the ADR-300 DAG it is a phase-3, research-forward primitive that
|
||||||
|
sits on top of the fused world state and is tightly coupled to **ADR-315**
|
||||||
|
(digital RF twin): the twin provides the propagation simulation this optimizer
|
||||||
|
plans against. It reads the **ADR-306** canonical ontology for the physical
|
||||||
|
scene and, after install, compares its predictions against ADR-302 observability
|
||||||
|
and the ADR-318 capability certificate.
|
||||||
|
|
||||||
|
The problem it solves is the single most common cause of a bad RuView
|
||||||
|
deployment: sensors placed by guesswork. Whether a room can be reliably sensed
|
||||||
|
depends on AP/sensor geometry relative to walls, Fresnel-zone clearance,
|
||||||
|
multipath structure, and where people actually move. Today an installer has no
|
||||||
|
principled way to answer "where do I put the two nodes I have so the kitchen is
|
||||||
|
observable?" — and no way, after install, to know whether reality matched the
|
||||||
|
plan. This is a genuine **differentiator**: it turns RuView from "sense
|
||||||
|
whatever the given placement happens to allow" into "recommend the placement
|
||||||
|
that makes the requested sensing feasible."
|
||||||
|
|
||||||
|
Relevant existing assets to build on rather than duplicate:
|
||||||
|
|
||||||
|
- The `worldgraph` crate models the physical scene the optimizer plans over:
|
||||||
|
`Room`/`Space` with `bounds_enu`, `Wall { rf_attenuation_db }` (drywall ≈ 3
|
||||||
|
dB, brick ≈ 12 dB), `Doorway`, and `Zone` — enough geometry and coarse RF
|
||||||
|
attenuation to seed a coverage model, plus `Sensor` nodes (ADR-306) for
|
||||||
|
candidate positions.
|
||||||
|
- **ADR-315** (RF twin, phase 3) is the propagation/multipath simulator; this
|
||||||
|
optimizer is a *consumer* of the twin, not a second simulator.
|
||||||
|
- **ADR-302** (OOD/observability) and **ADR-318** (capability certificate)
|
||||||
|
define what "reliably sense the requested phenomenon" means, so the optimizer
|
||||||
|
can optimize against the same observability metric the runtime later gates on.
|
||||||
|
- **ADR-029** (multistatic) and **ADR-063** (mmWave fusion) inform which link
|
||||||
|
geometries are useful for which phenomena.
|
||||||
|
|
||||||
|
## Options considered
|
||||||
|
|
||||||
|
1. **Static placement guidelines in docs (e.g. "one node per room, opposite
|
||||||
|
the door").** Rejected: ignores the specific floorplan, wall materials, and
|
||||||
|
the actual hardware inventory; gives no uncertainty and no post-install
|
||||||
|
feedback.
|
||||||
|
2. **Full electromagnetic solver per site.** Rejected for the default path:
|
||||||
|
too heavy for an installer workflow and overkill relative to the coarse
|
||||||
|
`rf_attenuation_db` scene RuView actually has; reserved as an optional
|
||||||
|
high-fidelity backend inside ADR-315.
|
||||||
|
3. **A coverage optimizer that consumes the ADR-315 RF twin over the ADR-306
|
||||||
|
scene, then validates predicted vs. measured observability after install.**
|
||||||
|
Chosen.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Define a **placement optimizer** that takes a floor plan (ADR-306 scene) and a
|
||||||
|
hardware inventory and recommends sensor positions, then closes the loop after
|
||||||
|
install.
|
||||||
|
|
||||||
|
### 1. Inputs
|
||||||
|
|
||||||
|
- The ADR-306 canonical scene: `Space`/`Zone` bounds, `Wall` segments with
|
||||||
|
`rf_attenuation_db`, `Doorway` topology, and any already-placed `Sensor`
|
||||||
|
nodes.
|
||||||
|
- A hardware inventory: the count and type of available radios (ESP32-S3/C6
|
||||||
|
nodes, mmWave, adapters) with their capability envelopes (what each can
|
||||||
|
sense, per ADR-318 / ADR-320 HAL descriptors).
|
||||||
|
- A sensing objective: which phenomenon must be observable in which
|
||||||
|
`Space`/`Zone` (presence, vitals, pose), expressed against the ADR-302
|
||||||
|
observability metric.
|
||||||
|
|
||||||
|
### 2. Prediction
|
||||||
|
|
||||||
|
- For a candidate placement, query the **ADR-315 RF twin** for simulated RF
|
||||||
|
coverage: path loss through `Wall` attenuation, **Fresnel-zone clearance**
|
||||||
|
between link endpoints, and coarse **multipath** structure. From that derive
|
||||||
|
an **expected observability** and an **uncertainty** for each objective in
|
||||||
|
each space — reusing the same observability definition ADR-302 gates on so the
|
||||||
|
plan and the runtime speak one language.
|
||||||
|
- Search over candidate positions (the inventory bounds the count; the scene
|
||||||
|
bounds the geometry) to recommend the placement that maximizes objective
|
||||||
|
observability, reporting expected observability **and its uncertainty** per
|
||||||
|
space — never a single confident number for a simulated result.
|
||||||
|
|
||||||
|
### 3. Post-install loop
|
||||||
|
|
||||||
|
- After install, compare **predicted vs. measured** observability using the
|
||||||
|
ADR-302 runtime observability signal from the freshly enrolled (ADR-305),
|
||||||
|
calibrated (ADR-301) sensors. Where measurement disagrees with prediction,
|
||||||
|
recommend adjustments (move, re-aim, add a node) and feed the residual back
|
||||||
|
to improve the ADR-315 twin's scene parameters (e.g. a wall's effective
|
||||||
|
attenuation).
|
||||||
|
|
||||||
|
### Evidence discipline
|
||||||
|
|
||||||
|
- Predicted coverage is a **simulation** (evidence level L0 per ADR-282) and is
|
||||||
|
labelled `SYNTHETIC`; it is a *recommendation*, never a sensing claim.
|
||||||
|
- The predicted-vs-measured comparison is the only place a `MEASURED` statement
|
||||||
|
appears, and only with a reproducer and real-silicon observability data
|
||||||
|
(CLAUDE.md hardware rule). The optimizer never presents a simulated coverage
|
||||||
|
map as evidence that a room *is* being sensed.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- Installers get a principled, floorplan-specific placement plan and, crucially,
|
||||||
|
a post-install check that says whether reality matched the plan — a
|
||||||
|
differentiating capability over guess-and-check deployment.
|
||||||
|
- Quality is bounded by the fidelity of the ADR-315 RF twin and the coarseness
|
||||||
|
of the `worldgraph` scene (2D walls, coarse attenuation). The optimizer
|
||||||
|
reports uncertainty rather than overstating a coarse model; higher fidelity
|
||||||
|
is an ADR-315 concern.
|
||||||
|
- Hard dependency on ADR-315 (twin), ADR-302 (observability metric), and
|
||||||
|
ADR-306 (scene); this ADR does not build a simulator or an observability
|
||||||
|
metric of its own.
|
||||||
|
- Being phase 3, this is design intent sitting on the fused world state; it is
|
||||||
|
expected to be revised as ADR-315 and the phase-1 spine land.
|
||||||
|
- No claim that recommended placement *guarantees* sensing — it maximizes
|
||||||
|
modelled observability subject to inventory and geometry, with explicit
|
||||||
|
uncertainty.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
- Unit tests: coverage/observability prediction is a deterministic function of
|
||||||
|
scene + placement + twin parameters; Fresnel-zone and wall-attenuation math
|
||||||
|
against known analytic cases; search returns the modelled-optimal placement on
|
||||||
|
small synthetic scenes.
|
||||||
|
- Integration test: on a synthetic floorplan with a known-good and a
|
||||||
|
known-bad placement, the optimizer ranks them correctly and reports higher
|
||||||
|
uncertainty for the marginal case.
|
||||||
|
- Post-install loop test: injected predicted-vs-measured disagreement produces a
|
||||||
|
sensible adjustment recommendation and a twin-parameter residual.
|
||||||
|
- Field validation (deferred, real-silicon): predicted vs. measured
|
||||||
|
observability on an instrumented real site, reported as `MEASURED` with a
|
||||||
|
reproducer. Until then all coverage output is `SYNTHETIC`/L0. No coverage or
|
||||||
|
accuracy number is asserted by this ADR.
|
||||||
152
docs/adr/ADR-309-active-sensing.md
Normal file
152
docs/adr/ADR-309-active-sensing.md
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
# ADR-309: Active sensing — closed-loop RF experiment control
|
||||||
|
|
||||||
|
- **Status**: Accepted — initial implementation (ADR-300 phase 3)
|
||||||
|
- **Date**: 2026-08-11
|
||||||
|
- **Deciders**: ruv
|
||||||
|
- **Tags**: active-sensing, control-plane, closed-loop, information-gain, actuation, phase-3
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
This ADR is a child of **ADR-300** and owns primitive #9, *active sensing*. In
|
||||||
|
the ADR-300 phasing it is a phase-3 primitive that sits on top of the fused
|
||||||
|
world state produced by **ADR-311** (real sensor fusion) and is driven by the
|
||||||
|
information budget of **ADR-314** (information-gain scheduler). It is authored
|
||||||
|
as **Proposed**: design intent and validation plan, not a phase-1 build.
|
||||||
|
|
||||||
|
The default posture of every current RuView path is **passive**: RF traffic
|
||||||
|
happens for its own reasons (a device transmits, a beacon fires), RuView
|
||||||
|
observes whatever CSI/CIR arrives, and the pipeline extracts what it can from
|
||||||
|
that incidental signal. The strategic assessment behind ADR-300 named the next
|
||||||
|
step: move from *RF-happens → observe* to **RuView-controls-RF → observe the
|
||||||
|
response → optimize the next measurement**. That turns sensing into a
|
||||||
|
closed-loop experiment — the system chooses what to measure to resolve the
|
||||||
|
uncertainty it currently has, rather than accepting the measurements the
|
||||||
|
environment happens to offer.
|
||||||
|
|
||||||
|
Substantial control-plane scaffolding already exists and must be
|
||||||
|
**reused/extended, not rebuilt**:
|
||||||
|
|
||||||
|
- **ADR-280** (active sensing / programmable perception, *implemented* in
|
||||||
|
`ruview-unified/src/control.rs`) already defines the governed control surface
|
||||||
|
this ADR closes the loop over: `SensingTask` (evidence-aware, fail-closed
|
||||||
|
admission), `SensingAction` + `InformationGoal` (a deliberate act of
|
||||||
|
evidence-gathering against a stated hypothesis, bounded by a `PrivacyClass`
|
||||||
|
P0–P5 ceiling), `ActiveSensingPlanner` (age-of-information scheduler),
|
||||||
|
`CoherentSensorGroup` (coherent fusion fails closed), and `request_actuation`
|
||||||
|
→ `ActuationReceipt` for governed RIS/movable/fluid-antenna actuation.
|
||||||
|
- ADR-280 explicitly recorded that **information-gain *estimation* is not
|
||||||
|
implemented** — "the planner uses staleness heuristics, not mutual
|
||||||
|
information; RIS drivers, actual multi-AP coherence measurement, and OTFS
|
||||||
|
waveform control are hardware-dependent roadmap items." ADR-309 is the ADR
|
||||||
|
that closes exactly those gaps, in coordination with ADR-314.
|
||||||
|
|
||||||
|
The missing piece is not the actuation surface — ADR-280 built that and made it
|
||||||
|
fail closed — but the **loop**: a controller that reads the current fused-state
|
||||||
|
uncertainty, selects a *controllable measurement configuration* expected to
|
||||||
|
reduce it most, requests it through the ADR-280 governed surface, observes the
|
||||||
|
response, and updates its belief before choosing the next measurement.
|
||||||
|
|
||||||
|
## Options considered
|
||||||
|
|
||||||
|
1. **Stay passive; only schedule which incidental observations to keep.** This
|
||||||
|
is roughly today's `ActiveSensingPlanner` (staleness-priority over regions).
|
||||||
|
Rejected as the endpoint: it optimizes *attention* over uncontrolled RF, not
|
||||||
|
the *measurement* itself. It remains the fallback when nothing is
|
||||||
|
controllable.
|
||||||
|
2. **Open-loop measurement scripting** (a fixed sweep of channels/bandwidths).
|
||||||
|
Rejected: a fixed sweep spends the RF/energy/privacy budget the same way
|
||||||
|
regardless of what is already known; it cannot concentrate measurement where
|
||||||
|
uncertainty actually is.
|
||||||
|
3. **Closed-loop experiment control** — read uncertainty, pick the controllable
|
||||||
|
configuration with highest expected information gain per unit cost/privacy,
|
||||||
|
actuate through the ADR-280 governed surface, observe, update, repeat.
|
||||||
|
Chosen.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Adopt **closed-loop RF experiment control** as a phase-3 controller layered on
|
||||||
|
the ADR-280 surface. RuView selects and drives the controllable degrees of
|
||||||
|
freedom of the RF measurement, then optimizes the next measurement from the
|
||||||
|
observed response.
|
||||||
|
|
||||||
|
### 1. Controllable degrees of freedom
|
||||||
|
|
||||||
|
Define an `ExperimentControl` vocabulary over the configuration axes RuView can
|
||||||
|
influence on hardware that exposes them (each axis is optional and
|
||||||
|
capability-gated by ADR-320's HAL, so an ESP32-only deployment simply has an
|
||||||
|
empty controllable set and degrades to the passive planner):
|
||||||
|
|
||||||
|
- **Channel / band** and **bandwidth** (which spectrum to probe; reuses the
|
||||||
|
ADR-292 wideband subcarrier-agnostic metadata).
|
||||||
|
- **Packet timing / cadence** (when to solicit a sounding, and at what rate).
|
||||||
|
- **Antenna / chain selection** (which subset of a distributed aperture to
|
||||||
|
activate — bounded by the ADR-280 `CoherentSensorGroup` compatibility proof).
|
||||||
|
- **Beam / RIS configuration** (which rooms and people become observable —
|
||||||
|
governed exactly as ADR-280 §6 requires, via `request_actuation` and an
|
||||||
|
`ActuationReceipt`).
|
||||||
|
- **802.11bf measurement parameters** (TB/non-TB, reporting config) once
|
||||||
|
ADR-310 exposes standardized sensing as a native measurement type.
|
||||||
|
|
||||||
|
### 2. The loop
|
||||||
|
|
||||||
|
```
|
||||||
|
fused-state uncertainty (ADR-311)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
info-gain ranking of ExperimentControl options (ADR-314)
|
||||||
|
│ select argmax E[ΔI] / (cost, energy, privacy ceiling)
|
||||||
|
▼
|
||||||
|
governed request (ADR-280 admit_task / request_actuation, fail-closed)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
observe response → update belief (ADR-311) → repeat
|
||||||
|
```
|
||||||
|
|
||||||
|
The controller never bypasses the ADR-280 admission and actuation gates: every
|
||||||
|
solicited measurement is a `SensingTask`/`SensingAction`, every environment
|
||||||
|
change is an `ActuationReceipt`, and every step composes with the ADR-277
|
||||||
|
policy engine. Information gain is what **ADR-314** supplies (the mutual-
|
||||||
|
information estimate ADR-280 deferred); ADR-309 owns the *control loop* that
|
||||||
|
consumes that estimate and drives the hardware.
|
||||||
|
|
||||||
|
### 3. Governance and honesty boundary
|
||||||
|
|
||||||
|
- Actuation and solicitation stay fail-closed and privacy-ceilinged: a
|
||||||
|
closed-loop experiment cannot widen the P0–P5 ceiling of the task it serves,
|
||||||
|
and cannot steer a beam into a zone that does not grant the purpose (ADR-280
|
||||||
|
`actuation_requires_policy_authorization`).
|
||||||
|
- Any accuracy or "traffic-reduction" claim from the closed loop is tagged
|
||||||
|
**MEASURED** only with a named reproducer over a stated scenario, **SYNTHETIC**
|
||||||
|
for simulated apertures, and **CLAIMED** otherwise. Real multi-AP coherent
|
||||||
|
measurement and RIS actuation remain **hardware-dependent** and require
|
||||||
|
real-silicon evidence (a captured runtime log) before any hardware claim, per
|
||||||
|
CLAUDE.md. No number is invented here.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- Sensing becomes an experiment: RuView spends its RF/energy/privacy budget on
|
||||||
|
the measurements that most reduce current uncertainty, instead of processing
|
||||||
|
whatever incidental traffic arrives.
|
||||||
|
- The loop is only as strong as its two dependencies: ADR-311 must expose a
|
||||||
|
usable uncertainty surface and ADR-314 must produce trustworthy information-
|
||||||
|
gain estimates. Where either is absent, the controller degrades to the
|
||||||
|
ADR-280 staleness planner rather than acting on a fabricated gain estimate.
|
||||||
|
- Controllability is hardware-bounded. On commodity ESP32 sensors the
|
||||||
|
controllable set may be limited to cadence; the full loop (bandwidth, antenna,
|
||||||
|
beam) needs NICs/RIS that expose those axes, surfaced through ADR-320.
|
||||||
|
- This ADR adds a controller; it does not re-open ADR-280's raw-export or
|
||||||
|
actuation-governance decisions, which remain authoritative and fail-closed.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
- Design-level acceptance (phase 3): a simulated closed loop over a synthetic
|
||||||
|
scene reduces terminal fused-state uncertainty faster than (a) the passive
|
||||||
|
ADR-280 staleness planner and (b) an open-loop fixed sweep, at equal
|
||||||
|
measurement budget — reported **SYNTHETIC**, with the scenario and seed named.
|
||||||
|
- Governance tests: every solicited measurement and actuation in the loop is
|
||||||
|
admitted through the ADR-280 fail-closed path; a loop step that would exceed
|
||||||
|
the task's privacy ceiling or steer into an ungranted zone is denied.
|
||||||
|
- Degradation test: with an empty controllable set (ESP32-only), the controller
|
||||||
|
falls back to the staleness planner with no error and no fabricated gain.
|
||||||
|
- Hardware validation of bandwidth/antenna/beam actuation is explicitly out of
|
||||||
|
scope until real silicon exposes those axes and produces a captured log.
|
||||||
147
docs/adr/ADR-310-80211bf-native-architecture.md
Normal file
147
docs/adr/ADR-310-80211bf-native-architecture.md
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
# ADR-310: 802.11bf-native architecture — standardized WLAN sensing as native measurement types
|
||||||
|
|
||||||
|
- **Status**: Proposed (ADR-300 phase 2)
|
||||||
|
- **Date**: 2026-08-11
|
||||||
|
- **Deciders**: ruv
|
||||||
|
- **Tags**: 80211bf, wlan-sensing, standards, measurement-types, hal, phase-2
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
This ADR is a child of **ADR-300** and owns primitive #10, *802.11bf-native
|
||||||
|
architecture*. In the ADR-300 phasing it is a phase-2 integration primitive: it
|
||||||
|
sits on the phase-1 spine (authenticated identity ADR-305, spatial ontology
|
||||||
|
ADR-306, evidence engine ADR-304) and **feeds ADR-320** (the RuView sensor HAL),
|
||||||
|
which is the clause of the acceptance test that "identifies the hardware." It is
|
||||||
|
authored as **Proposed**.
|
||||||
|
|
||||||
|
**IEEE 802.11bf-2025 ("WLAN Sensing") was published 2025-09-26** — verified
|
||||||
|
against the IEEE SA record in `wifi-densepose-hardware` (`ieee80211bf/mod.rs`
|
||||||
|
header, "evidence grade MEASURED", ADR-152 §1.1). Standardization is complete
|
||||||
|
for sub-7 GHz and >45 GHz (DMG) bands: formal sensing measurement setup,
|
||||||
|
measurement instances, feedback/reporting, and sensing-by-proxy (SBP). This
|
||||||
|
changes RuView's strategic frame: rather than treating every WiFi measurement as
|
||||||
|
an *opportunistic* extraction from incidental traffic, RuView can be the **open
|
||||||
|
reference sensing stack around the standard** — the day commodity silicon
|
||||||
|
exposes it.
|
||||||
|
|
||||||
|
Substantial scaffolding already exists and must be **reused/extended, not
|
||||||
|
rebuilt**. `v2/crates/wifi-densepose-hardware/src/ieee80211bf/` already models
|
||||||
|
the standardized procedure surface as forward-compatible types (ADR-152/153):
|
||||||
|
|
||||||
|
- `types` — `SpecProfile` version gates, `SensingRole`/`TransceiverRole`,
|
||||||
|
`MeasurementSetupParams`, `SensingCapabilities` negotiation, and required
|
||||||
|
`ConsentMode` governance metadata on every setup.
|
||||||
|
- `messages` — `SensingMeasurementSetupRequest/Response`,
|
||||||
|
`SensingMeasurementInstance`, `SensingMeasurementReport`, `CsiReportPayload`,
|
||||||
|
`SbpRequest/Response`, `SensingSessionTermination`.
|
||||||
|
- `session` — a deterministic FSM (`Idle → SetupNegotiating → Active →
|
||||||
|
Terminating → Idle`) with rejection paths, single-role enforcement, and SBP
|
||||||
|
proxy mode; `table` (responder-side setup registry); `transport` (the
|
||||||
|
`SensingTransport` seam, a `SimTransport` test double, and an
|
||||||
|
`OpportunisticCsiBridge` that maps today's opportunistic CSI onto the
|
||||||
|
standardized report path).
|
||||||
|
|
||||||
|
The module's own honesty note is authoritative and carried forward here: it is
|
||||||
|
**not a certified 802.11bf implementation**, and **no commodity silicon — ESP32
|
||||||
|
included — implements the standard yet**; the OTA frame binding lands when a
|
||||||
|
chipset exposes it. Wideband ingest plumbing is already in place too: **ADR-292**
|
||||||
|
(FeitCSI/AX210) carries native subcarrier dimensionality end-to-end and records
|
||||||
|
the native→pipeline mapping, and noted that "truncated CIR is a natural
|
||||||
|
extension of the same plumbing."
|
||||||
|
|
||||||
|
What is missing is architectural, not protocol scaffolding: normalized CSI is
|
||||||
|
still treated as *the* WiFi input. The standardized sensing measurements
|
||||||
|
(TB/non-TB soundings, truncated CIR / PDP reports) are modeled as protocol
|
||||||
|
messages but are **not yet first-class native measurement types** that flow
|
||||||
|
through calibration (ADR-301), fusion (ADR-311), and the ontology (ADR-306) on
|
||||||
|
equal footing with normalized CSI.
|
||||||
|
|
||||||
|
## Options considered
|
||||||
|
|
||||||
|
1. **Keep 802.11bf as a protocol model only; always down-convert its reports to
|
||||||
|
normalized CSI at ingest.** Rejected: truncated CIR/PDP carry range-resolved
|
||||||
|
multipath structure that flattening to a CSI matrix discards; it also wastes
|
||||||
|
the standard's native report semantics.
|
||||||
|
2. **Fork a parallel "bf pipeline" alongside the CSI pipeline.** Rejected:
|
||||||
|
duplicates calibration, fusion, ontology, and evidence plumbing, and re-opens
|
||||||
|
the O(surfaces²) translation problem ADR-306 exists to close.
|
||||||
|
3. **Promote standardized sensing measurements to native measurement types
|
||||||
|
inside the existing pipeline**, with normalized CSI as one measurement type
|
||||||
|
among several. Chosen.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Adopt an **802.11bf-native architecture**: standardized WLAN sensing
|
||||||
|
measurements become **additional native measurement types**, alongside — not
|
||||||
|
replacing — normalized CSI.
|
||||||
|
|
||||||
|
### 1. Native measurement types
|
||||||
|
|
||||||
|
- Define the standardized reports the `ieee80211bf` module already models
|
||||||
|
(TB and non-TB soundings; truncated CIR; PDP) as first-class
|
||||||
|
`MeasurementType` variants that the pipeline carries end-to-end, each tagged
|
||||||
|
with its `SpecProfile` and band. Normalized CSI remains one such type; the
|
||||||
|
`OpportunisticCsiBridge` remains the path for silicon that only offers
|
||||||
|
incidental CSI.
|
||||||
|
- Truncated CIR/PDP reuse the **ADR-292** subcarrier-agnostic / native-
|
||||||
|
dimensionality plumbing (truncated CIR is the stated natural extension); the
|
||||||
|
native→pipeline mapping is recorded in frame metadata so downstream stages
|
||||||
|
know the true range/spectral resolution of a bf report vs. an interpolated CSI
|
||||||
|
frame.
|
||||||
|
|
||||||
|
### 2. Ontology and governance binding
|
||||||
|
|
||||||
|
- Each standardized measurement becomes an ADR-306 `Observation` node from an
|
||||||
|
ADR-305-authenticated `Sensor`, carrying `SemanticProvenance` and exactly one
|
||||||
|
`EvidenceLevel` (L0–L5, ADR-282). The `ieee80211bf` `ConsentMode` metadata —
|
||||||
|
required on every setup — composes with the ADR-277 policy engine, so a
|
||||||
|
standardized session is admitted under the same governance as any other
|
||||||
|
sensing task (ADR-280).
|
||||||
|
- SBP (sensing-by-proxy) sessions attribute the report to the proxying and the
|
||||||
|
sensing entities distinctly, so provenance is not laundered through the proxy.
|
||||||
|
|
||||||
|
### 3. HAL feed (ADR-320)
|
||||||
|
|
||||||
|
- The capability set a device advertises — which `MeasurementType`s, bands,
|
||||||
|
bandwidths, roles, and `SpecProfile` it supports — is exactly the descriptor
|
||||||
|
**ADR-320** (HAL) needs to "identify the hardware." ADR-310 defines that
|
||||||
|
capability descriptor as the projection of `SensingCapabilities`; ADR-320
|
||||||
|
consumes it. A device that implements no bf profile advertises only the
|
||||||
|
opportunistic-CSI capability.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- RuView is positioned as the open reference stack *around* the standard: when a
|
||||||
|
chipset exposes 802.11bf, its native reports flow through calibration, fusion,
|
||||||
|
ontology, and evidence with no bespoke pipeline — the plumbing is already
|
||||||
|
tested against `SimTransport` and synthetic fixtures.
|
||||||
|
- Normalized CSI is demoted from "the WiFi input" to "one measurement type,"
|
||||||
|
which is the correct framing for a multi-measurement future and prevents the
|
||||||
|
bf path from being a second-class citizen.
|
||||||
|
- **No hardware claim is made or implied.** No commodity silicon implements
|
||||||
|
802.11bf yet; this ADR wires the *types and flow*, tested in simulation. Any
|
||||||
|
OTA/native-report accuracy claim requires real silicon evidence (a captured
|
||||||
|
log) per CLAUDE.md, and any wideband number must be tagged with the capture
|
||||||
|
hardware (ADR-292). No benchmark number is invented here.
|
||||||
|
- This ADR does not re-open ADR-152/153's decision to avoid OTA frame binding
|
||||||
|
until silicon exists; it consumes that surface and adds the pipeline
|
||||||
|
integration.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
- `cargo test -p wifi-densepose-hardware` — existing `ieee80211bf` FSM,
|
||||||
|
table, and transport tests continue to pass; new tests assert that a
|
||||||
|
`SensingMeasurementReport` (TB and non-TB) and a truncated-CIR/PDP report
|
||||||
|
round-trip through the pipeline as native `MeasurementType`s.
|
||||||
|
- `cargo test -p wifi-densepose-mat` — truncated CIR ingest reuses the ADR-292
|
||||||
|
subcarrier-agnostic path and records the native→pipeline mapping; dimension/
|
||||||
|
version validation on standardized reports mirrors the FeitCSI parser gates.
|
||||||
|
- Ontology/governance tests: each standardized measurement becomes an ADR-306
|
||||||
|
`Observation` from an ADR-305-authenticated `Sensor` with one `EvidenceLevel`;
|
||||||
|
`ConsentMode` composes with ADR-277 admission; SBP attributes proxy vs. sensor
|
||||||
|
provenance distinctly.
|
||||||
|
- HAL contract test: the ADR-320 capability descriptor is derivable from
|
||||||
|
`SensingCapabilities`; a bf-less device advertises only opportunistic CSI.
|
||||||
|
- All measurement-type flows are simulation-tested (`SimTransport`, synthetic
|
||||||
|
fixtures); OTA binding and any hardware accuracy claim remain out of scope
|
||||||
|
until real silicon exposes the standard.
|
||||||
140
docs/adr/ADR-311-real-sensor-fusion.md
Normal file
140
docs/adr/ADR-311-real-sensor-fusion.md
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
# ADR-311: Real sensor fusion — uncertainty-aware, multiple observations → one world state
|
||||||
|
|
||||||
|
- **Status**: Accepted — initial implementation (ADR-300 phase 2)
|
||||||
|
- **Date**: 2026-08-11
|
||||||
|
- **Deciders**: ruv
|
||||||
|
- **Tags**: fusion, uncertainty, multimodal, world-state, ontology, phase-2
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
This ADR is a child of **ADR-300** and owns primitive #11, *real sensor fusion*.
|
||||||
|
In the ADR-300 DAG it is a phase-2 integration primitive: it **consumes ADR-306**
|
||||||
|
(canonical spatial ontology) and **produces the single fused world state** that
|
||||||
|
the phase-3 primitives build on — **ADR-312** (long-term spatial memory),
|
||||||
|
**ADR-313** (counterfactual inference), and **ADR-315** (digital RF twin). It is
|
||||||
|
authored as **Proposed**.
|
||||||
|
|
||||||
|
The defining invariant is not "support more modalities" but the *shape of the
|
||||||
|
output*: **multiple observations must resolve to one probabilistic world state,
|
||||||
|
not many feeds into a visualization.** A dashboard that shows a WiFi layer, a
|
||||||
|
mmWave layer, and a BLE layer side by side is not fusion; it pushes the
|
||||||
|
reconciliation onto the human. Real fusion produces one uncertainty-aware state
|
||||||
|
that every downstream consumer reads, with each contributing observation's
|
||||||
|
provenance and confidence still recoverable.
|
||||||
|
|
||||||
|
Substantial scaffolding already exists and must be **reused/extended, not
|
||||||
|
rebuilt**:
|
||||||
|
|
||||||
|
- **ADR-063** (60 GHz mmWave ↔ WiFi CSI fusion, *Proposed*) established the
|
||||||
|
first cross-modal fusion case: pairing noisy CSI-derived vitals with clinical-
|
||||||
|
grade mmWave FMCW radar (Seeed MR60BHA2 over UART, with a **live hardware
|
||||||
|
capture** logged on 2026-03-15). ADR-311 generalizes that pairwise case into
|
||||||
|
an N-modality, uncertainty-aware fusion.
|
||||||
|
- **ADR-137** (fusion-engine quality scoring, *Accepted — partial*) already
|
||||||
|
built the auditable-quality building block: it identified that the multistatic
|
||||||
|
fusers (`wifi-densepose-signal/src/ruvsense/multistatic.rs`,
|
||||||
|
`wifi-densepose-ruvector/src/viewpoint/fusion.rs`) discarded the evidence they
|
||||||
|
used, and specified a single auditable record — "this fused output is
|
||||||
|
trustworthy because X, Y, Z, but be aware of contradiction C" — with evidence
|
||||||
|
references and contradiction flags. ADR-311 reuses that record as the
|
||||||
|
provenance/quality carrier of the fused state.
|
||||||
|
- **ADR-280** `CoherentSensorGroup` (fail-closed coherent fusion) and
|
||||||
|
**ADR-306** `Observation`/`Track`/`Event` node types are the input and output
|
||||||
|
vocabulary respectively.
|
||||||
|
|
||||||
|
What is missing is the **uncertainty-aware combiner across heterogeneous
|
||||||
|
modalities**: a fusion stage that takes authenticated observations from WiFi,
|
||||||
|
BLE, UWB, mmWave, acoustic, IMU, lidar, and cameras (only where policy permits),
|
||||||
|
each with its own uncertainty, and emits one probabilistic `WorldState` — with
|
||||||
|
per-observation contradiction flags, not a stack of independent feeds.
|
||||||
|
|
||||||
|
## Options considered
|
||||||
|
|
||||||
|
1. **Per-modality feeds rendered together** (today's implicit model on some
|
||||||
|
surfaces). Rejected: it is visualization, not fusion; contradictions are
|
||||||
|
never reconciled and there is no single state to reason over.
|
||||||
|
2. **Hard-switch "best modality wins"** (e.g., always prefer mmWave vitals over
|
||||||
|
CSI vitals). Rejected: throws away corroborating evidence and cannot express
|
||||||
|
*disagreement* — the very thing ADR-137's contradiction flags exist to
|
||||||
|
surface — and degrades badly when the preferred modality is absent or OOD.
|
||||||
|
3. **Uncertainty-weighted probabilistic fusion into one world state**, reusing
|
||||||
|
ADR-137's auditable quality record and ADR-280's fail-closed coherence gate.
|
||||||
|
Chosen.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Adopt **uncertainty-aware multimodal fusion** whose invariant output is one
|
||||||
|
probabilistic world state.
|
||||||
|
|
||||||
|
### 1. Inputs: authenticated, ontology-typed observations
|
||||||
|
|
||||||
|
- Inputs are ADR-306 `Observation` nodes from **ADR-305-authenticated** sensors.
|
||||||
|
Supported modalities: WiFi (CSI / 802.11bf native reports via ADR-310), BLE,
|
||||||
|
UWB, mmWave (ADR-063), acoustic, IMU, lidar, and cameras. Cameras and any
|
||||||
|
higher privacy-class modality enter fusion **only where the ADR-277 policy
|
||||||
|
engine permits** — camera-free coverage is a RuView invariant (ADR-282), so
|
||||||
|
cameras are an opt-in, policy-gated input, never assumed present.
|
||||||
|
- Each observation carries its own uncertainty and exactly one `EvidenceLevel`
|
||||||
|
(ADR-282). An observation flagged out-of-distribution by **ADR-302** is
|
||||||
|
down-weighted or excluded per its OOD verdict rather than silently averaged in.
|
||||||
|
|
||||||
|
### 2. Combiner: uncertainty-weighted, contradiction-aware
|
||||||
|
|
||||||
|
- Observations are combined by their uncertainty into one probabilistic
|
||||||
|
`WorldState` over the ADR-306 entities (`Person`, `Object`, `Track`, and the
|
||||||
|
per-`Space` inference). The combiner does **not** collapse disagreement: when
|
||||||
|
modalities conflict beyond their stated uncertainty, the fused output carries
|
||||||
|
ADR-137 **contradiction flags** and the evidence references that produced
|
||||||
|
them, so a consumer can see *that* WiFi and mmWave disagree and *why*.
|
||||||
|
- Coherent multi-node fusion inherits ADR-280's fail-closed
|
||||||
|
`CoherentSensorGroup` gate: no coherent combination unless sync, phase, and
|
||||||
|
geometry compatibility are proven; otherwise the group degrades to incoherent
|
||||||
|
combination rather than producing confident nonsense.
|
||||||
|
|
||||||
|
### 3. Output: one world state, provenance preserved
|
||||||
|
|
||||||
|
- The output is a single `WorldState` written into the ADR-306 ontology, with
|
||||||
|
every fused value retaining recoverable per-observation provenance and the
|
||||||
|
ADR-137 quality record. This is the state ADR-312/310/312 consume; they read
|
||||||
|
one probabilistic world, not a modality stack.
|
||||||
|
- The fused state carries an aggregate uncertainty and an evidence level derived
|
||||||
|
from its inputs (never upgraded above the weakest contributing L-level for a
|
||||||
|
given claim).
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- Downstream primitives (spatial memory, counterfactual, RF twin) build on one
|
||||||
|
probabilistic world state with uniform uncertainty and provenance, instead of
|
||||||
|
re-implementing reconciliation per consumer.
|
||||||
|
- Contradictions become first-class signal, not noise: ADR-137's record means a
|
||||||
|
disagreement between mmWave and CSI is surfaced and auditable, which is also
|
||||||
|
what lets ADR-302 and the evidence engine (ADR-304) reason about reliability.
|
||||||
|
- Fusion is uncertainty-honest: an OOD or low-evidence observation is
|
||||||
|
down-weighted, not averaged in as if trustworthy; a fused claim never presents
|
||||||
|
a stronger evidence level than its weakest necessary input.
|
||||||
|
- **No accuracy or "camera-grade" claim is made.** ADR-063's mmWave path has a
|
||||||
|
real-silicon capture; the multimodal combiner's accuracy is not asserted here.
|
||||||
|
Any fused-accuracy number requires a named reproducer tagged MEASURED /
|
||||||
|
SYNTHETIC / CLAIMED, and WiFi sensing is never presented as camera-grade
|
||||||
|
(CLAUDE.md, ADR-282). No number is invented.
|
||||||
|
- Cameras remain a governed, opt-in input; enabling them does not weaken the
|
||||||
|
camera-free coverage guarantee for deployments that exclude them.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
- `cargo test -p wifi-densepose-ruvector` / `-p wifi-densepose-signal` — the
|
||||||
|
ADR-137 quality record and contradiction flags travel with the fused output;
|
||||||
|
the ADR-280 `CoherentSensorGroup` gate still fails closed under
|
||||||
|
clock/phase/geometry violation.
|
||||||
|
- Fusion invariant test: N modality observations over one scene resolve to a
|
||||||
|
single `WorldState` node in the ADR-306 ontology (not N feeds), with
|
||||||
|
per-observation provenance recoverable and one aggregate evidence level.
|
||||||
|
- Uncertainty tests: a high-uncertainty or ADR-302-flagged-OOD observation is
|
||||||
|
down-weighted/excluded; conflicting modalities produce a contradiction flag
|
||||||
|
rather than a silently averaged value; the fused evidence level never exceeds
|
||||||
|
the weakest necessary input.
|
||||||
|
- Governance test: a camera or higher-privacy modality is admitted into fusion
|
||||||
|
only when the ADR-277 policy engine permits; otherwise it is excluded and the
|
||||||
|
fused state notes the exclusion.
|
||||||
|
- Any accuracy comparison (e.g., fused vitals vs. mmWave-only) is reported with
|
||||||
|
its evidence tag and reproducer; none is asserted in this ADR.
|
||||||
146
docs/adr/ADR-312-long-term-spatial-memory.md
Normal file
146
docs/adr/ADR-312-long-term-spatial-memory.md
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
# ADR-312: Long-term spatial memory — learn the normal physics of a location
|
||||||
|
|
||||||
|
- **Status**: Accepted — initial implementation (ADR-300 phase 3)
|
||||||
|
- **Date**: 2026-08-11
|
||||||
|
- **Deciders**: ruv
|
||||||
|
- **Tags**: spatial-memory, ruvector, anomaly-detection, temporal, world-state, phase-3
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
This ADR is a child of **ADR-300** and owns primitive #12, *long-term spatial
|
||||||
|
memory*. In the ADR-300 phasing it is a phase-3 primitive that sits on the fused
|
||||||
|
world state produced by **ADR-311** (real sensor fusion) and **ties to ADR-315**
|
||||||
|
(digital RF twin): spatial memory is the *learned normal* that a twin can
|
||||||
|
simulate against and that anomaly detection compares against. It is authored as
|
||||||
|
**Proposed**.
|
||||||
|
|
||||||
|
The capability is to **learn the normal physics of a location** so anomalies
|
||||||
|
surface *without training a detector for every anomaly*. Concretely, the system
|
||||||
|
should learn statements like: "a chair is normally here"; "this bedroom is
|
||||||
|
usually occupied between these hours"; "the RF propagation of this space
|
||||||
|
changed"; "this machine's vibration signature changed"; "a new reflector
|
||||||
|
appeared." None of these is a labeled anomaly class — they are *deviations from
|
||||||
|
a learned baseline of normality*. This is the difference between supervised
|
||||||
|
anomaly detection (which needs examples of every failure) and **baseline-relative
|
||||||
|
anomaly detection** (which needs only a well-characterized normal).
|
||||||
|
|
||||||
|
Substantial substrate already exists and must be **reused/extended, not
|
||||||
|
rebuilt**:
|
||||||
|
|
||||||
|
- **RuVector** (`v2/crates/wifi-densepose-ruvector`) is the designated substrate
|
||||||
|
in the ADR-282 layer stack ("persistent objects, Gaussian fields, scene
|
||||||
|
graphs, temporal memory"). It already provides the vector/temporal machinery
|
||||||
|
this ADR needs — HNSW indexing (`hnsw.rs`, `hnsw_quantized.rs`), an event log
|
||||||
|
(`event_log.rs`), coverage and estimator surfaces, and the `crv`/`mat`
|
||||||
|
temporal sub-modules — so long-term spatial memory is a *consumer and
|
||||||
|
organizer* of RuVector primitives, not a new store.
|
||||||
|
- **ADR-306** supplies the entity vocabulary the memory is indexed by (`Space`,
|
||||||
|
`Object`, `Sensor`, `Track`, `Event`); **ADR-311** supplies the fused,
|
||||||
|
uncertainty-carrying `WorldState` snapshots that memory accumulates over time.
|
||||||
|
- **ADR-135** (empty-room baseline calibration) and **ADR-301** (automatic
|
||||||
|
domain calibration) already establish a *calibration-time* baseline of a
|
||||||
|
space; ADR-312 extends that from a one-shot baseline to a **continuously
|
||||||
|
learned, time-of-day-aware** model of normal.
|
||||||
|
|
||||||
|
What is missing is the **temporal normality model**: a per-`Space` learned
|
||||||
|
distribution of fused world states over time (including periodicity — hour of
|
||||||
|
day, day of week), plus RF-propagation and modality-signature baselines, against
|
||||||
|
which a live fused state is scored for deviation.
|
||||||
|
|
||||||
|
## Options considered
|
||||||
|
|
||||||
|
1. **Supervised anomaly classifiers per anomaly type.** Rejected: it needs
|
||||||
|
labeled examples of every anomaly (fall, intrusion, machine fault, moved
|
||||||
|
furniture), which do not exist for most spaces and do not transfer between
|
||||||
|
rooms; it also cannot catch a *novel* anomaly it was never trained on.
|
||||||
|
2. **Single static baseline** (the ADR-135 empty-room snapshot, used forever).
|
||||||
|
Rejected as the endpoint: it cannot express *when* a space is normally
|
||||||
|
occupied, cannot track slow legitimate drift (furniture rearranged on
|
||||||
|
purpose), and flags every diurnal change as anomalous.
|
||||||
|
3. **Continuously learned, time-aware normality model on the RuVector
|
||||||
|
substrate**, scoring live fused state against learned normal. Chosen.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Adopt a **long-term spatial memory** that learns each location's normal physics
|
||||||
|
on the RuVector substrate and scores live fused state against it.
|
||||||
|
|
||||||
|
### 1. What "normal" is learned over
|
||||||
|
|
||||||
|
Per ADR-306 `Space` (and the entities within it), accumulate the ADR-311 fused
|
||||||
|
`WorldState` over time into a learned normality model covering:
|
||||||
|
|
||||||
|
- **Occupancy / activity periodicity** — the distribution of presence and
|
||||||
|
activity by hour-of-day and day-of-week (the "bedroom usually occupied certain
|
||||||
|
hours" case).
|
||||||
|
- **Static scene layout** — persistent `Object` positions and the expected
|
||||||
|
reflector set (the "chair normally here" / "new reflector appeared" cases),
|
||||||
|
building on the ADR-135/298 baseline.
|
||||||
|
- **RF-propagation baseline** — the space's normal multipath/propagation
|
||||||
|
signature (the "RF propagation changed" case).
|
||||||
|
- **Per-modality signatures** — e.g., a machine's normal vibration/acoustic/IMU
|
||||||
|
signature (the "vibration signature changed" case).
|
||||||
|
|
||||||
|
Each learned baseline carries its own uncertainty and an `EvidenceLevel`
|
||||||
|
(ADR-282); a baseline learned from replay is L1, from a field pilot L4, and is
|
||||||
|
never presented above the evidence of the observations it was learned from.
|
||||||
|
|
||||||
|
### 2. Substrate: RuVector, temporally compressed
|
||||||
|
|
||||||
|
- The memory is stored and indexed on RuVector (HNSW for nearest-normal recall,
|
||||||
|
the event log for the temporal stream, the temporal sub-modules for
|
||||||
|
compression). Long-horizon history is temporally compressed — recent detail
|
||||||
|
retained, older history summarized — so memory cost is bounded rather than
|
||||||
|
growing linearly forever.
|
||||||
|
- The memory is *keyed by* the ADR-306 ontology, so "normal for this `Space` at
|
||||||
|
this hour" is a first-class query, and slow legitimate drift updates the
|
||||||
|
baseline (with provenance) instead of accumulating as permanent anomaly.
|
||||||
|
|
||||||
|
### 3. Anomaly = deviation from learned normal
|
||||||
|
|
||||||
|
- A live fused `WorldState` is scored against the applicable learned baseline
|
||||||
|
(matched by space and time context). A deviation beyond the baseline's
|
||||||
|
uncertainty is surfaced as an ADR-306 `Event` — *without* a per-anomaly
|
||||||
|
detector — carrying the baseline it deviated from, the deviation magnitude,
|
||||||
|
and its evidence level. Whether that event is actionable is a policy/consumer
|
||||||
|
decision (ADR-277), not this layer's.
|
||||||
|
- The learned normal is exactly what **ADR-315** (RF twin) can simulate against:
|
||||||
|
the twin proposes an expected state, spatial memory supplies the learned
|
||||||
|
actual-normal, and their divergence is a physically grounded anomaly signal.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- Anomaly detection generalizes: a space gets deviation detection from its own
|
||||||
|
learned normal, so a novel anomaly (never labeled anywhere) still registers as
|
||||||
|
a deviation, and the model transfers to a new room by *learning that room's*
|
||||||
|
normal rather than importing a foreign detector.
|
||||||
|
- Bounded memory: temporal compression keeps long-horizon memory finite; the
|
||||||
|
trade-off is that fine detail of old history is summarized, which is acceptable
|
||||||
|
for a normality baseline.
|
||||||
|
- Legitimate change is not a permanent false positive: slow drift updates the
|
||||||
|
baseline with provenance, distinguishing "furniture deliberately rearranged"
|
||||||
|
(baseline shifts) from "reflector appeared unexpectedly" (deviation event).
|
||||||
|
- **No accuracy claim is made.** Deviation-detection quality is not asserted
|
||||||
|
here; any detection-rate or false-positive number requires a named reproducer
|
||||||
|
tagged MEASURED / SYNTHETIC / CLAIMED, and a health/safety framing stays within
|
||||||
|
the ADR-282 bounded-claims discipline (decision support, not diagnosis). No
|
||||||
|
number is invented.
|
||||||
|
- The memory is governed: learned baselines are observations of a space, subject
|
||||||
|
to the same ADR-277 retention/privacy policy as the fused state they summarize;
|
||||||
|
no raw P0 RF is retained to build a baseline.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
- `cargo test -p wifi-densepose-ruvector` — the normality model builds on the
|
||||||
|
existing HNSW/event-log/temporal primitives; nearest-normal recall and
|
||||||
|
temporal-compression bounds are exercised on synthetic streams.
|
||||||
|
- Baseline/deviation tests: a synthetic scene with a known injected change (moved
|
||||||
|
`Object`, altered propagation, altered modality signature) produces a deviation
|
||||||
|
`Event` against the learned normal *without* a per-anomaly detector; an
|
||||||
|
unchanged diurnal cycle produces none (no false positive on normal periodicity).
|
||||||
|
- Drift test: a slow legitimate change updates the baseline (with provenance)
|
||||||
|
rather than emitting a persistent anomaly; an abrupt change does emit one.
|
||||||
|
- Evidence test: a learned baseline carries the evidence level of its source
|
||||||
|
observations and is never presented above it; retention honors ADR-277.
|
||||||
|
- Twin-linkage design check (with ADR-315): divergence between a twin-simulated
|
||||||
|
expected state and the learned normal is expressible as a deviation signal.
|
||||||
142
docs/adr/ADR-313-counterfactual-inference.md
Normal file
142
docs/adr/ADR-313-counterfactual-inference.md
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
# ADR-313: Counterfactual inference — generative spatial reasoning
|
||||||
|
|
||||||
|
- **Status**: Accepted — initial implementation (ADR-300 phase 3)
|
||||||
|
- **Date**: 2026-08-11
|
||||||
|
- **Deciders**: ruv
|
||||||
|
- **Tags**: inference, generative, counterfactual, rf-twin, fusion, uncertainty, phase-3
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
This ADR is a child of **ADR-300** (perception substrate program) and owns
|
||||||
|
primitive #13, *counterfactual inference*. In the ADR-300 DAG it is a phase-3,
|
||||||
|
research-forward primitive that sits on top of the fused world state: it
|
||||||
|
**consumes ADR-311** (real sensor fusion) for the current fused estimate and
|
||||||
|
**ADR-315** (digital RF twin) for the twin's expected measurement
|
||||||
|
distributions. It is design intent, authored as Proposed, and is expected to be
|
||||||
|
revised as the phase-1 spine and the phase-2 fusion layer land.
|
||||||
|
|
||||||
|
RuView today reasons discriminatively: a task head maps measurements to a label
|
||||||
|
or a pose. That answers "what does the classifier say?" but not the questions an
|
||||||
|
operator actually asks — *would these RF measurements still make sense if nobody
|
||||||
|
were present? Does one person explain the observation better than two?* Those
|
||||||
|
are counterfactual questions, and a classifier cannot answer them because it has
|
||||||
|
no model of what a measurement *should* look like under a hypothesized world
|
||||||
|
state. A discriminative head asked about an empty room simply emits its
|
||||||
|
best-effort label; it cannot say "the observation is better explained by
|
||||||
|
absence."
|
||||||
|
|
||||||
|
The step this ADR proposes is toward a **generative spatial model**: given a
|
||||||
|
hypothesized scene state (occupancy, count, coarse positions) and the ADR-315
|
||||||
|
twin's propagation model for the deployment, predict the *expected* measurement
|
||||||
|
distribution, then score how well each hypothesis explains the observed
|
||||||
|
measurement. The best-explaining hypothesis — including the *nobody-present*
|
||||||
|
null hypothesis — is the answer, and the margin between hypotheses is a
|
||||||
|
first-class uncertainty signal.
|
||||||
|
|
||||||
|
Relevant existing assets to build on rather than duplicate:
|
||||||
|
|
||||||
|
- **ADR-311** (fusion) already produces the fused world estimate and its
|
||||||
|
covariance; the counterfactual layer scores hypotheses *relative to* that
|
||||||
|
estimate rather than re-fusing raw measurements.
|
||||||
|
- **ADR-315** (RF twin) is the generative forward model — per-deployment
|
||||||
|
geometry, radio locations, and expected measurement distributions. This ADR
|
||||||
|
is a *consumer* of the twin's forward simulator, not a second simulator.
|
||||||
|
- **ADR-302** (OOD/observability) already owns the `UNKNOWN` verdict; the
|
||||||
|
null-hypothesis ("nobody present better explains this than any occupancy
|
||||||
|
hypothesis") and the "no hypothesis explains this" case route through ADR-302,
|
||||||
|
not a parallel gate.
|
||||||
|
- `frame::EvidenceLevel` L0–L5 (ADR-282) and the ADR-304 evidence engine
|
||||||
|
account for the resulting confidence.
|
||||||
|
|
||||||
|
## Options considered
|
||||||
|
|
||||||
|
1. **Keep only discriminative heads.** Rejected: cannot express absence,
|
||||||
|
cannot compare "one person vs. two" as competing explanations, and gives a
|
||||||
|
confident label even when no world state explains the data.
|
||||||
|
2. **A second, independently trained generative network with its own forward
|
||||||
|
model.** Rejected for the default path: duplicates the ADR-315 twin's
|
||||||
|
propagation model, invites the two models to disagree, and multiplies the
|
||||||
|
surface that must be validated. Reserved only if the twin's analytic forward
|
||||||
|
model proves insufficient for a phenomenon.
|
||||||
|
3. **A hypothesis-scoring layer that uses the ADR-315 twin as the forward model
|
||||||
|
and the ADR-311 fused state as the hypothesis prior, routing low-margin and
|
||||||
|
null-dominant cases to the ADR-302 UNKNOWN verdict.** Chosen.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Define a **counterfactual inference layer** that scores a small set of scene
|
||||||
|
hypotheses against observed measurements using the digital RF twin as the
|
||||||
|
generative forward model.
|
||||||
|
|
||||||
|
### 1. Hypothesis set
|
||||||
|
|
||||||
|
- Hypotheses are drawn from the ADR-311 fused state and its neighbourhood: the
|
||||||
|
current estimate, the **null hypothesis** (nobody present), and a bounded set
|
||||||
|
of nearby alternatives (±1 occupant, shifted position). The fused estimate
|
||||||
|
supplies the prior so the search stays small and grounded rather than
|
||||||
|
enumerating an open world.
|
||||||
|
- The hypothesis space is expressed over the **ADR-306** canonical ontology
|
||||||
|
(`Space`/`Zone`, occupant count, coarse position), so a counterfactual result
|
||||||
|
is a governed spatial statement, not an opaque score.
|
||||||
|
|
||||||
|
### 2. Forward model and scoring
|
||||||
|
|
||||||
|
- For each hypothesis, query the **ADR-315 twin** for the expected measurement
|
||||||
|
distribution given that scene state and the deployment's propagation model.
|
||||||
|
Score the observed measurement's likelihood under each hypothesis's expected
|
||||||
|
distribution.
|
||||||
|
- The answer is the maximum-likelihood hypothesis; the **margin** between the
|
||||||
|
top hypotheses (and between the top hypothesis and the null) is the
|
||||||
|
confidence signal, carried into the ADR-304 evidence engine.
|
||||||
|
|
||||||
|
### 3. Routing to UNKNOWN
|
||||||
|
|
||||||
|
- When the null hypothesis dominates, the layer reports *absence*, not a
|
||||||
|
low-confidence occupancy label.
|
||||||
|
- When **no** hypothesis explains the observation well (all likelihoods low, or
|
||||||
|
the winning margin below threshold), the result routes to the **ADR-302**
|
||||||
|
`UNKNOWN` verdict — the observation is outside what the twin can explain, and
|
||||||
|
the honest output is "I cannot account for this," never a forced label.
|
||||||
|
|
||||||
|
### Evidence discipline
|
||||||
|
|
||||||
|
- Twin-predicted distributions are a **simulation** (evidence level L0 per
|
||||||
|
ADR-282) labelled `SYNTHETIC`; a counterfactual verdict inherits the evidence
|
||||||
|
level of its weakest input and is never presented as camera-grade ground
|
||||||
|
truth (CLAUDE.md honesty rule).
|
||||||
|
- Any accuracy statement about counterfactual discrimination (e.g. "distinguishes
|
||||||
|
one occupant from two") requires the mean-pose-style baseline discipline of
|
||||||
|
CLAUDE.md, a leakage-free held-out split, and a reproducer before it may be
|
||||||
|
tagged `MEASURED`. This ADR asserts **no** such number.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- RuView gains the ability to answer absence and "which explanation is better"
|
||||||
|
questions that discriminative heads structurally cannot — a step toward
|
||||||
|
generative spatial reasoning and a differentiator for security and
|
||||||
|
facility-monitoring applications where *absence* is the valuable signal.
|
||||||
|
- Quality is bounded by the fidelity of the ADR-315 twin's forward model and the
|
||||||
|
ADR-311 fused prior; the layer reports margins and defers to ADR-302 UNKNOWN
|
||||||
|
rather than overstating a coarse model.
|
||||||
|
- Hard dependency on ADR-311 (fused state and covariance) and ADR-315 (forward
|
||||||
|
model); this ADR builds neither a fusion engine nor a propagation simulator of
|
||||||
|
its own.
|
||||||
|
- Being phase 3, this is design intent sitting on the fused world state; it is
|
||||||
|
expected to be revised as ADR-311 and ADR-315 land, and it is not implemented
|
||||||
|
by the phase-1 swarm.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
- Unit tests: hypothesis likelihood scoring is a deterministic function of
|
||||||
|
observed measurement + hypothesis + twin parameters; the null hypothesis wins
|
||||||
|
on a synthesized empty-room measurement; a two-occupant measurement scores the
|
||||||
|
two-occupant hypothesis above the one-occupant hypothesis on a controlled
|
||||||
|
synthetic case.
|
||||||
|
- Integration test: measurements the twin cannot explain (out-of-model
|
||||||
|
scattering) drive the layer to the ADR-302 UNKNOWN verdict rather than a
|
||||||
|
forced occupancy label; margins propagate into the ADR-304 evidence engine.
|
||||||
|
- Held-out discrimination (deferred, real-silicon): one-vs-two and
|
||||||
|
presence-vs-absence discrimination on a leakage-free held-out split with a
|
||||||
|
mean-pose baseline, reported as `MEASURED` with a reproducer. Until then all
|
||||||
|
counterfactual output is `SYNTHETIC`/L0. No discrimination accuracy number is
|
||||||
|
asserted by this ADR.
|
||||||
138
docs/adr/ADR-314-information-gain-scheduler.md
Normal file
138
docs/adr/ADR-314-information-gain-scheduler.md
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
# ADR-314: Information-gain scheduler — sample the most informative radios
|
||||||
|
|
||||||
|
- **Status**: Accepted — initial implementation (ADR-300 phase 3)
|
||||||
|
- **Date**: 2026-08-11
|
||||||
|
- **Deciders**: ruv
|
||||||
|
- **Tags**: scheduling, active-sensing, information-gain, edge, energy, fusion, phase-3
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
This ADR is a child of **ADR-300** (perception substrate program) and owns
|
||||||
|
primitive #14, *information-gain scheduler*. In the ADR-300 DAG it is a phase-3,
|
||||||
|
research-forward primitive that sits on top of the fused world state and
|
||||||
|
**pairs with ADR-309** (active sensing): ADR-309 decides *what to probe*
|
||||||
|
(waveform, sensing task); this ADR decides *which radios/modalities to spend
|
||||||
|
budget on next*. It is authored as Proposed and is not implemented by the
|
||||||
|
phase-1 swarm.
|
||||||
|
|
||||||
|
With multiple sensors, processing every stream at full rate is wasteful: many
|
||||||
|
radios are, at any moment, contributing little to the current estimate while
|
||||||
|
consuming compute, energy, and bandwidth — the three scarce resources on the
|
||||||
|
edge nodes RuView targets (ESP32-S3/C6 and small gateways). Treating all sensors
|
||||||
|
equally is precisely the design that does not survive a real deployment of
|
||||||
|
"hundreds of sensors."
|
||||||
|
|
||||||
|
The scheduler assigns each candidate sensor/modality a value
|
||||||
|
|
||||||
|
```
|
||||||
|
Value(sensor) ≈ expected uncertainty reduction / (compute + energy + bandwidth)
|
||||||
|
```
|
||||||
|
|
||||||
|
and spends the next sampling/processing budget on the highest-value sensors.
|
||||||
|
Expected uncertainty reduction is estimated *before* paying for the measurement,
|
||||||
|
which is why the scheduler needs a model of what each sensor is likely to tell
|
||||||
|
it — supplied by the fused state's covariance and the RF twin's forward model,
|
||||||
|
not by actually sampling.
|
||||||
|
|
||||||
|
Relevant existing assets to build on rather than duplicate:
|
||||||
|
|
||||||
|
- **ADR-311** (fusion) maintains the fused state and its covariance — the
|
||||||
|
current uncertainty the scheduler is trying to reduce. Expected uncertainty
|
||||||
|
reduction is computed against that covariance, not a private one.
|
||||||
|
- **ADR-315** (RF twin) provides the per-sensor forward model used to predict a
|
||||||
|
candidate measurement's expected informativeness before sampling.
|
||||||
|
- **ADR-320** (RuView sensor HAL, phase 2) exposes each radio's real
|
||||||
|
compute/energy/bandwidth cost descriptors; the denominator is read from the
|
||||||
|
HAL, not guessed per platform.
|
||||||
|
- **ADR-309** (active sensing) is the paired actuator: the scheduler ranks
|
||||||
|
sensors, ADR-309 chooses the probe on the chosen sensor.
|
||||||
|
- **ADR-302** (observability) defines the phenomenon the estimate is *for*, so
|
||||||
|
the scheduler prioritizes uncertainty reduction on the objective that matters,
|
||||||
|
not on nuisance dimensions.
|
||||||
|
|
||||||
|
## Options considered
|
||||||
|
|
||||||
|
1. **Round-robin / process-everything scheduling.** Rejected: burns edge
|
||||||
|
compute and energy on redundant streams and does not scale to large fleets;
|
||||||
|
the strategic and external reviews named exactly this as an edge-deployment
|
||||||
|
blocker.
|
||||||
|
2. **Static priority per sensor type (e.g. always prefer mmWave).** Rejected:
|
||||||
|
ignores that a sensor's *current* informativeness depends on the scene and
|
||||||
|
the present uncertainty — a well-placed WiFi link can dominate an occluded
|
||||||
|
mmWave node in a given moment.
|
||||||
|
3. **A value-of-information scheduler that ranks sensors by expected uncertainty
|
||||||
|
reduction per unit cost, using the ADR-311 covariance and ADR-315 forward
|
||||||
|
model, with costs from the ADR-320 HAL.** Chosen.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Define an **information-gain scheduler** that allocates the next
|
||||||
|
sampling/processing budget across available radios by value of information.
|
||||||
|
|
||||||
|
### 1. Value function
|
||||||
|
|
||||||
|
- For each candidate sensor/modality, estimate **expected uncertainty
|
||||||
|
reduction** on the ADR-302 objective by evaluating how much a predicted
|
||||||
|
measurement (via the **ADR-315** forward model) would shrink the **ADR-311**
|
||||||
|
fused-state covariance — a value-of-information estimate made *before* paying
|
||||||
|
for the measurement.
|
||||||
|
- Divide by the sensor's **cost** — compute + energy + bandwidth — read from the
|
||||||
|
**ADR-320** HAL descriptors. The exact weighting of the three cost terms is a
|
||||||
|
deployment policy (a battery node weights energy heavily; a wired gateway
|
||||||
|
weights bandwidth), configured, not hardcoded.
|
||||||
|
|
||||||
|
### 2. Allocation
|
||||||
|
|
||||||
|
- Rank candidates by value and spend the budget on the top set, subject to a
|
||||||
|
configurable floor that guarantees each sensor is sampled at least
|
||||||
|
occasionally (so a sensor whose value is currently low is not starved into
|
||||||
|
permanent blindness and can be re-evaluated as the scene changes).
|
||||||
|
- The scheduler emits an allocation, not a measurement; **ADR-309** active
|
||||||
|
sensing chooses the probe/waveform on each selected sensor, and the fusion
|
||||||
|
layer (ADR-311) incorporates the result.
|
||||||
|
|
||||||
|
### 3. Governance and honesty
|
||||||
|
|
||||||
|
- Skipping a sensor for a cycle is a *deliberate* reduction in coverage; the
|
||||||
|
scheduler records which sensors were sampled so downstream evidence (ADR-304)
|
||||||
|
reflects the actual sensing that occurred, and observability (ADR-302) can
|
||||||
|
raise `UNKNOWN` for a zone that went under-sampled rather than reporting a
|
||||||
|
stale estimate as current.
|
||||||
|
|
||||||
|
### Evidence discipline
|
||||||
|
|
||||||
|
- Expected-uncertainty-reduction estimates are model predictions from the
|
||||||
|
ADR-315 twin (simulation, L0 per ADR-282, `SYNTHETIC`); a scheduling decision
|
||||||
|
is a resource choice, never a sensing claim.
|
||||||
|
- Any energy/latency/throughput improvement figure requires real-silicon
|
||||||
|
measurement with a reproducer before it is tagged `MEASURED` (CLAUDE.md
|
||||||
|
hardware rule). This ADR asserts **no** efficiency number.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- Edge deployments spend scarce compute, energy, and bandwidth where they buy
|
||||||
|
the most certainty, making "hundreds of sensors" operationally tractable — a
|
||||||
|
capability the reviews flagged as critical for edge deployment.
|
||||||
|
- Quality is bounded by the accuracy of the ADR-315 forward model (informativeness
|
||||||
|
prediction) and ADR-320 cost descriptors; a poor forward model degrades to
|
||||||
|
near-round-robin, which is safe but not optimal. The sampling floor bounds the
|
||||||
|
worst case.
|
||||||
|
- Hard dependency on ADR-311 (covariance), ADR-315 (forward model), and ADR-320
|
||||||
|
(cost descriptors), and paired with ADR-309; this ADR builds none of those.
|
||||||
|
- Being phase 3, this is design intent sitting on the fused world state and is
|
||||||
|
expected to be revised as ADR-309, ADR-311, ADR-315, and the ADR-320 HAL land.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
- Unit tests: the value function is a deterministic function of covariance +
|
||||||
|
forward model + cost descriptors; a sensor predicted to reduce objective
|
||||||
|
uncertainty more per unit cost ranks above one that reduces it less; the
|
||||||
|
sampling floor guarantees eventual re-evaluation of a low-value sensor.
|
||||||
|
- Integration test: on a synthetic multi-sensor scene, the scheduler reduces
|
||||||
|
objective uncertainty faster per unit modelled cost than round-robin, and
|
||||||
|
raises ADR-302 UNKNOWN for a deliberately starved zone rather than reporting a
|
||||||
|
stale estimate.
|
||||||
|
- Field validation (deferred, real-silicon): energy/latency/throughput on an
|
||||||
|
instrumented multi-node deployment, reported as `MEASURED` with a reproducer.
|
||||||
|
Until then all informativeness and cost figures are `SYNTHETIC`/L0. No
|
||||||
|
efficiency number is asserted by this ADR.
|
||||||
159
docs/adr/ADR-315-digital-rf-twin.md
Normal file
159
docs/adr/ADR-315-digital-rf-twin.md
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
# ADR-315: Digital RF twin — persistent per-deployment RF model
|
||||||
|
|
||||||
|
- **Status**: Accepted — initial implementation (ADR-300 phase 3)
|
||||||
|
- **Date**: 2026-08-11
|
||||||
|
- **Deciders**: ruv
|
||||||
|
- **Tags**: rf-twin, digital-twin, propagation, calibration, spatial-memory, worldgraph, phase-3
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
This ADR is a child of **ADR-300** (perception substrate program) and owns
|
||||||
|
primitive #15, *digital RF twin*. In the ADR-300 DAG it is a phase-3,
|
||||||
|
research-forward primitive that underpins several other phase-3 primitives:
|
||||||
|
**ADR-308** (placement optimizer) plans against the twin's propagation model,
|
||||||
|
**ADR-313** (counterfactual inference) uses it as the generative forward model,
|
||||||
|
and **ADR-314** (information-gain scheduler) uses it to predict per-sensor
|
||||||
|
informativeness. It ties directly to **ADR-301** (calibration), **ADR-308**
|
||||||
|
(placement), and **ADR-312** (long-term spatial memory). It is authored as
|
||||||
|
Proposed and is not implemented by the phase-1 swarm.
|
||||||
|
|
||||||
|
RuView today has no persistent, per-deployment model of the RF environment.
|
||||||
|
Calibration state, observed multipath, and radio geometry exist transiently
|
||||||
|
inside a running session; when the process restarts or a change happens
|
||||||
|
overnight, there is nothing that says "this is what this room's RF looked like
|
||||||
|
yesterday." Without a persistent baseline, a physical change — furniture moved,
|
||||||
|
a wall opened, a machine relocated, an intruder present — has nothing to be a
|
||||||
|
*delta against*. It is just a different measurement, indistinguishable from
|
||||||
|
noise or drift.
|
||||||
|
|
||||||
|
The **digital RF twin** is that persistent baseline: a per-deployment model
|
||||||
|
holding
|
||||||
|
|
||||||
|
- **geometry and radio locations** (from the ADR-306 scene / worldgraph),
|
||||||
|
- **propagation history** and **observed multipath** structure,
|
||||||
|
- **calibration state** (from ADR-301),
|
||||||
|
- **expected measurement distributions** for each link and phenomenon.
|
||||||
|
|
||||||
|
Once the twin exists, a physical change becomes a **measurable delta against the
|
||||||
|
twin** rather than an unexplained measurement. This is what connects RuView to
|
||||||
|
facility management (what changed in this space?), security (is there an
|
||||||
|
unexplained presence?), robotics (has the map drifted?), and industrial
|
||||||
|
monitoring (did the plant layout change?) — the applications the strategic
|
||||||
|
assessment named as the value beyond a single detector.
|
||||||
|
|
||||||
|
Relevant existing assets to build on rather than duplicate:
|
||||||
|
|
||||||
|
- The `worldgraph` crate already models the physical scene — `Room`/`Space`
|
||||||
|
with `bounds_enu`, `Wall { rf_attenuation_db }`, `Doorway`, `Zone`, and
|
||||||
|
`Sensor` nodes (ADR-306). The twin *annotates and persists* this scene with RF
|
||||||
|
state; it does not invent a second geometry.
|
||||||
|
- `wifi-densepose-calibration` (enrollment, bank, anchor, runtime, specialist)
|
||||||
|
holds the calibration state the twin persists; the twin references and
|
||||||
|
versions calibration records, it does not reimplement calibration.
|
||||||
|
- **ADR-312** (long-term spatial memory, phase 3) is the persistence and
|
||||||
|
temporal-history substrate; the twin is a *structured occupant* of that
|
||||||
|
memory, not a separate database.
|
||||||
|
- **ADR-305** (authenticated identity) and **ADR-295** (provenance) mean the
|
||||||
|
measurements that update the twin carry verified lineage, so a delta is
|
||||||
|
attributable rather than anonymous.
|
||||||
|
|
||||||
|
## Options considered
|
||||||
|
|
||||||
|
1. **No persistent RF model (status quo).** Rejected: every change looks like
|
||||||
|
noise; nothing supports "what changed since yesterday?", which is the
|
||||||
|
question the facility/security/industrial applications actually ask.
|
||||||
|
2. **A full electromagnetic digital twin (per-site ray-tracing / FDTD kept in
|
||||||
|
sync in real time).** Rejected for the default path: far heavier than the
|
||||||
|
coarse `rf_attenuation_db` scene RuView actually has and impractical on edge
|
||||||
|
hardware. A high-fidelity solver is retained as an *optional backend* the
|
||||||
|
twin can call, not the baseline.
|
||||||
|
3. **A persistent, per-deployment RF model layered over the ADR-306 scene and
|
||||||
|
ADR-312 memory: geometry + radio locations + calibration state + observed
|
||||||
|
multipath + expected measurement distributions, updated by verified
|
||||||
|
measurements, exposing changes as deltas.** Chosen.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Define the **digital RF twin** as a persistent, versioned, per-deployment model
|
||||||
|
of the RF environment, layered over existing scene, calibration, and memory
|
||||||
|
assets.
|
||||||
|
|
||||||
|
### 1. State the twin holds
|
||||||
|
|
||||||
|
- **Geometry and radio locations** referenced from the ADR-306 / worldgraph
|
||||||
|
scene (not copied).
|
||||||
|
- **Calibration state** referenced and versioned from
|
||||||
|
`wifi-densepose-calibration` (ADR-301), so the twin knows *which* calibration
|
||||||
|
a stored distribution was captured under.
|
||||||
|
- **Observed multipath and propagation history** — a bounded temporal summary
|
||||||
|
of per-link channel structure, stored in ADR-312 spatial memory.
|
||||||
|
- **Expected measurement distributions** per link and phenomenon — the forward
|
||||||
|
model ADR-308, ADR-313, and ADR-314 consume.
|
||||||
|
|
||||||
|
### 2. Update and delta
|
||||||
|
|
||||||
|
- Verified measurements (ADR-305 identity, ADR-295 provenance) update the twin's
|
||||||
|
distributions online, bounded by ADR-301 calibration validity. A new
|
||||||
|
observation is compared to the twin's expected distribution; the **delta** —
|
||||||
|
and its statistical significance against the twin's own variance — is the
|
||||||
|
primary output. A change large relative to the twin's modelled variance is a
|
||||||
|
*detected physical change*, not noise.
|
||||||
|
- The twin is **versioned**: a calibration event, a deliberate geometry edit, or
|
||||||
|
an accepted physical change advances the twin version, so history is
|
||||||
|
auditable and a delta is always relative to a named baseline.
|
||||||
|
|
||||||
|
### 3. Consumers
|
||||||
|
|
||||||
|
- **ADR-308** queries the twin's propagation model to plan placements.
|
||||||
|
- **ADR-313** uses the twin's expected distributions as the generative forward
|
||||||
|
model for hypothesis scoring.
|
||||||
|
- **ADR-314** uses per-sensor expected informativeness from the twin.
|
||||||
|
- Facility/security/robotics/industrial integrations read the twin's change
|
||||||
|
deltas as governed ADR-306 spatial events.
|
||||||
|
|
||||||
|
### Evidence discipline
|
||||||
|
|
||||||
|
- The twin's expected distributions and any propagation simulation are
|
||||||
|
**simulation** (evidence level L0 per ADR-282), labelled `SYNTHETIC`. A delta
|
||||||
|
computed against them is a model-relative statement.
|
||||||
|
- A change/anomaly detection *claim* (e.g. "detects furniture-scale changes")
|
||||||
|
requires real-silicon measurement against a leakage-free protocol with a
|
||||||
|
reproducer before it is tagged `MEASURED` (CLAUDE.md hardware rule). The twin
|
||||||
|
never presents a modelled expected distribution as evidence that a physical
|
||||||
|
state *is* the case; it presents a *delta and its significance*. This ADR
|
||||||
|
asserts **no** detection-accuracy number.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- RuView gains a persistent per-deployment baseline, turning "a different
|
||||||
|
measurement" into "a measurable, attributable, versioned change" — the bridge
|
||||||
|
from a sensing runtime to facility management, security, robotics, and
|
||||||
|
industrial monitoring.
|
||||||
|
- The twin is the shared forward model for ADR-308/310/311, so those primitives
|
||||||
|
speak one propagation model rather than three inconsistent ones — a
|
||||||
|
deliberate reason to build the twin before its consumers mature.
|
||||||
|
- Quality is bounded by the coarseness of the worldgraph scene and the fidelity
|
||||||
|
of the forward model; the twin reports deltas *with significance against its
|
||||||
|
own variance* rather than asserting confident change detection on a coarse
|
||||||
|
model. The optional high-fidelity backend is where higher accuracy lives.
|
||||||
|
- Hard dependency on ADR-306 (scene), ADR-301 (calibration state), and ADR-312
|
||||||
|
(persistence); it reuses `worldgraph` and `wifi-densepose-calibration` rather
|
||||||
|
than rebuilding geometry or calibration.
|
||||||
|
- Being phase 3, this is design intent; it is expected to be revised as the
|
||||||
|
phase-1 spine, ADR-311 fusion, and ADR-312 memory land.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
- Unit tests: the twin's expected distribution is a deterministic function of
|
||||||
|
scene + calibration + propagation history; delta computation and its
|
||||||
|
significance against stored variance are correct on synthetic distributions;
|
||||||
|
versioning advances on calibration/geometry/accepted-change events and history
|
||||||
|
is retained.
|
||||||
|
- Integration test: on a synthetic deployment, an injected physical change (a
|
||||||
|
wall attenuation shift) produces a significant delta against the twin while
|
||||||
|
ordinary noise does not; the delta surfaces as a governed ADR-306 event with
|
||||||
|
provenance (ADR-305/292).
|
||||||
|
- Field validation (deferred, real-silicon): change detection on an instrumented
|
||||||
|
real deployment with a controlled physical-change protocol, reported as
|
||||||
|
`MEASURED` with a reproducer. Until then all twin distributions and deltas are
|
||||||
|
`SYNTHETIC`/L0. No detection-accuracy number is asserted by this ADR.
|
||||||
156
docs/adr/ADR-316-fleet-control-plane.md
Normal file
156
docs/adr/ADR-316-fleet-control-plane.md
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
# ADR-316: Fleet control plane — provisioning to audit trails
|
||||||
|
|
||||||
|
- **Status**: Proposed (ADR-300 phase 2)
|
||||||
|
- **Date**: 2026-08-11
|
||||||
|
- **Deciders**: ruv
|
||||||
|
- **Tags**: fleet, operations, provisioning, firmware, updates, audit, identity, phase-2
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
This ADR is a child of **ADR-300** (perception substrate program) and owns
|
||||||
|
primitive #16, *fleet control plane*. In the ADR-300 DAG it is a phase-2
|
||||||
|
integration-and-operations primitive that sits on the phase-1 spine: it
|
||||||
|
**consumes ADR-305** (authenticated sensor identity) for per-device identity and
|
||||||
|
enrollment, and **ADR-318** (capability certificate) for the signed models,
|
||||||
|
calibration validity, and capability envelopes a device is allowed to run. It is
|
||||||
|
authored as Proposed and is not implemented by the phase-1 swarm.
|
||||||
|
|
||||||
|
The external and internal reviews both named the same operational gap: RuView
|
||||||
|
has strong per-device primitives but no **release identity** and no **bill of
|
||||||
|
materials** binding a fielded sensor to the exact firmware, model, and
|
||||||
|
calibration it is running — and no plane to manage that across many devices.
|
||||||
|
Without this, a handful of nodes is fine but *hundreds* of sensors become an
|
||||||
|
operational nightmare: no coherent way to provision, roll certificates, verify
|
||||||
|
firmware compatibility, distribute signed models, track calibration lifecycle,
|
||||||
|
watch health, stage updates, roll back, diagnose remotely, enforce data
|
||||||
|
retention, or produce an audit trail. This ADR addresses that release-identity /
|
||||||
|
BOM gap directly.
|
||||||
|
|
||||||
|
The scope is deliberately the **control plane**, not the data plane. The
|
||||||
|
authenticated measurement path is **ADR-296** (bind + allowlist) plus **ADR-305**
|
||||||
|
(signed envelope); this ADR governs the *devices and artifacts*, not the
|
||||||
|
per-frame stream.
|
||||||
|
|
||||||
|
Relevant existing assets to build on rather than duplicate:
|
||||||
|
|
||||||
|
- **ADR-305** already defines per-device keypairs, the `DeviceId → public key →
|
||||||
|
capabilities` enrollment record, key rotation and revocation *semantics* — and
|
||||||
|
explicitly deferred their **fleet distribution** to this ADR. The control
|
||||||
|
plane is the distribution and lifecycle layer over ADR-305 identity, not a new
|
||||||
|
identity scheme.
|
||||||
|
- **ADR-318** (capability certificate) defines the signed, expiring artifact a
|
||||||
|
device is authorized to run; the fleet plane is what *distributes, stages, and
|
||||||
|
revokes* those certificates and the signed models they point at.
|
||||||
|
- **ADR-301** (calibration) owns calibration validity/expiry; the fleet plane
|
||||||
|
tracks calibration *lifecycle* across the fleet (which nodes are due, which are
|
||||||
|
stale) rather than redefining calibration.
|
||||||
|
- **ADR-319** (witness chain) provides the append-only, re-verifiable record;
|
||||||
|
fleet audit trails are witness-chain entries, not a parallel log format.
|
||||||
|
- **ADR-320** (RuView sensor HAL, phase 2) provides hardware/firmware capability
|
||||||
|
descriptors used for firmware-compatibility checks before staging an update.
|
||||||
|
- `wifi-densepose-bfld` `CapabilityAttestation` (ADR-141) is the device-side
|
||||||
|
attestation the plane checks against declared cohort capabilities.
|
||||||
|
|
||||||
|
## Options considered
|
||||||
|
|
||||||
|
1. **Manual per-device operations (SSH/flash by hand).** Rejected: does not
|
||||||
|
scale past a handful of nodes, produces no release identity, no audit trail,
|
||||||
|
and no safe rollback — exactly the operational nightmare the reviews named.
|
||||||
|
2. **Adopt a generic third-party IoT device-management platform wholesale.**
|
||||||
|
Rejected as the core: generic platforms do not understand RuView's signed
|
||||||
|
capability certificate, calibration validity, or witness chain, and would
|
||||||
|
fork trust away from the phase-1 spine. A generic transport/agent *may* be a
|
||||||
|
backend, but identity, certificates, and audit remain RuView's.
|
||||||
|
3. **A RuView-native control plane layered on ADR-305 identity, ADR-318
|
||||||
|
certificates, ADR-301 calibration lifecycle, and ADR-319 audit — covering
|
||||||
|
provisioning through rollback and retention.** Chosen.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Define a **fleet control plane** that manages RuView sensors and their signed
|
||||||
|
artifacts across their lifecycle, built on the phase-1 identity/certificate
|
||||||
|
spine.
|
||||||
|
|
||||||
|
### 1. Release identity and bill of materials
|
||||||
|
|
||||||
|
- Each fielded device has a **BOM record** binding `DeviceId` (ADR-305) → exact
|
||||||
|
firmware version → signed model set → active capability certificate (ADR-318)
|
||||||
|
→ current calibration record (ADR-301) → HAL/hardware descriptor (ADR-320).
|
||||||
|
This *is* the release identity the reviews found missing: given a device you
|
||||||
|
can state precisely what it is running and prove it is signed.
|
||||||
|
|
||||||
|
### 2. Provisioning, certificates, firmware compatibility
|
||||||
|
|
||||||
|
- **Provisioning** is the authorized ADR-305 enrollment step at fleet scale:
|
||||||
|
minting a keypair, registering the public key and capabilities, and issuing
|
||||||
|
the initial ADR-318 certificate. A device is untrusted until provisioned.
|
||||||
|
- **Certificate lifecycle**: issue, rotate, expire, and **revoke** ADR-318
|
||||||
|
certificates and the ADR-305 keys behind them; revocation lists are
|
||||||
|
distributed here (the distribution ADR-305 deferred).
|
||||||
|
- **Firmware compatibility**: before staging a firmware or model, check the
|
||||||
|
target's ADR-320 HAL descriptor and ADR-141 capability attestation so an
|
||||||
|
incompatible or under-capable device is never sent an artifact it cannot
|
||||||
|
honestly run.
|
||||||
|
|
||||||
|
### 3. Cohorts, staged updates, rollback
|
||||||
|
|
||||||
|
- Devices group into **cohorts** (by site, hardware, capability). Updates —
|
||||||
|
signed models and firmware — roll out **staged** (canary → cohort → fleet)
|
||||||
|
with health gates between stages, and **roll back** to the previously recorded
|
||||||
|
BOM on a failed health check. Only signed artifacts are ever staged.
|
||||||
|
|
||||||
|
### 4. Health telemetry, remote diagnostics, retention, audit
|
||||||
|
|
||||||
|
- **Health telemetry** and **remote diagnostics** report device liveness,
|
||||||
|
calibration staleness (ADR-301), certificate expiry (ADR-318), and error
|
||||||
|
state — read-only diagnostics by default, mutations authorized explicitly.
|
||||||
|
- **Data retention** policy is enforced per cohort, and P0/CSI/person data never
|
||||||
|
leaves the edge except under the ADR-277/280 governance already in force
|
||||||
|
(CLAUDE.md: never commit or exfiltrate CSI/person data).
|
||||||
|
- Every lifecycle action — provision, rotate, revoke, stage, roll back — is
|
||||||
|
written as an **ADR-319 witness-chain** entry, giving a re-verifiable **audit
|
||||||
|
trail** rather than a mutable log.
|
||||||
|
|
||||||
|
### Authority and least privilege
|
||||||
|
|
||||||
|
- The control plane is default-deny (CLAUDE.md: default to least authority).
|
||||||
|
Provisioning, key rotation, revocation, staging, and rollback are each
|
||||||
|
separately authorized operations; no fleet action is implied by another.
|
||||||
|
Credentials and private keys are never logged or committed.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- Hundreds of sensors become operable: coherent release identity, signed-artifact
|
||||||
|
distribution, staged updates with rollback, and a re-verifiable audit trail —
|
||||||
|
closing the release-identity / BOM gap the reviews raised.
|
||||||
|
- The plane concentrates operational authority; that is mitigated by
|
||||||
|
default-deny, per-action authorization, signed-only artifacts, and
|
||||||
|
witness-chained audit. A compromised plane must still forge signatures the
|
||||||
|
phase-1 spine verifies.
|
||||||
|
- Hard dependency on ADR-305 (identity), ADR-318 (certificate), ADR-301
|
||||||
|
(calibration lifecycle), ADR-319 (audit), and ADR-320 (firmware/HAL
|
||||||
|
compatibility). This ADR distributes and sequences those artifacts; it does
|
||||||
|
not redefine identity, certificates, calibration, or the witness format.
|
||||||
|
- Being phase 2, this is design intent depending on the spine; it is expected to
|
||||||
|
be revised as ADR-318, ADR-319, and ADR-320 land.
|
||||||
|
- **No fielded fleet-operation claim is MEASURED without real-silicon evidence**
|
||||||
|
(CLAUDE.md hardware rule): staged update and rollback on real nodes require a
|
||||||
|
captured runtime log. A passing simulation is not fleet evidence.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
- Unit tests: BOM records bind identity/firmware/model/certificate/calibration
|
||||||
|
consistently and reject inconsistent bindings; certificate issue/rotate/revoke
|
||||||
|
transitions are correct; a firmware-incompatible target is refused staging;
|
||||||
|
every lifecycle action emits a well-formed ADR-319 witness entry.
|
||||||
|
- Integration test: a synthetic cohort undergoes a canary→cohort→fleet staged
|
||||||
|
update; an injected health failure triggers rollback to the prior BOM; the
|
||||||
|
full sequence is re-verifiable from the witness chain offline; a revoked
|
||||||
|
certificate is rejected fleet-wide.
|
||||||
|
- Security test (`npm run test:security` analogue for the plane): default-deny
|
||||||
|
is enforced; unauthorized provision/rotate/revoke/stage is rejected and
|
||||||
|
counted; no credential or P0 data appears in telemetry or audit output.
|
||||||
|
- Field validation (deferred, real-silicon): a real multi-node staged update and
|
||||||
|
rollback with a captured boot/runtime log, reported as `MEASURED` with a
|
||||||
|
reproducer. Until then all fleet-operation results are simulator-level. No
|
||||||
|
fielded reliability number is asserted by this ADR.
|
||||||
140
docs/adr/ADR-317-benchmark-multi-domain-scorecard.md
Normal file
140
docs/adr/ADR-317-benchmark-multi-domain-scorecard.md
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
# ADR-317: Multi-domain benchmark scorecard — regressions cannot hide behind pooled accuracy
|
||||||
|
|
||||||
|
- **Status**: Accepted — initial implementation planned (ADR-300 phase 1)
|
||||||
|
- **Date**: 2026-08-11
|
||||||
|
- **Deciders**: ruv
|
||||||
|
- **Tags**: benchmark, aetherarena, ci-gate, evidence, honesty, domain-generalization, substrate
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
This ADR is primitive 17 of the perception-substrate program (ADR-300) and the
|
||||||
|
per-PR enforcement edge of the phase-1 certificate spine. In the ADR-300
|
||||||
|
dependency DAG it reads accuracy from the evidence engine (ADR-304), consumes
|
||||||
|
the domain state produced by out-of-distribution detection (ADR-302), scores
|
||||||
|
against calibration certificates (ADR-301), and is anchored in the witness chain
|
||||||
|
(ADR-319). It is the surface that makes the rest of the spine testable on every
|
||||||
|
change to sensing code.
|
||||||
|
|
||||||
|
A single pooled accuracy number is the classic way a domain-generalization
|
||||||
|
regression hides. A model can raise mean PCK or mean presence accuracy while
|
||||||
|
quietly collapsing on unseen rooms, unseen devices, or stationary subjects —
|
||||||
|
exactly the conditions WiFi sensing fails in and exactly the conditions a
|
||||||
|
pooled average washes out. The strategic assessment (ADR-300) named this: what
|
||||||
|
distinguishes infrastructure from a demo is that a regression on *any* operating
|
||||||
|
domain is caught before merge, not discovered in the field.
|
||||||
|
|
||||||
|
RuView does not need a new benchmark to do this. AetherArena is already
|
||||||
|
**v0-complete infrastructure** (ADR-149): a deterministic scoring engine
|
||||||
|
reusing `wifi-densepose-train` (`src/ruview_metrics.rs`, `src/ablation.rs`,
|
||||||
|
`src/eval.rs`, `src/proof.rs`), a `PROOF_SEED=42` determinism substrate that
|
||||||
|
SHA-256-hashes outputs against an expected hash, an append-only witness ledger,
|
||||||
|
and a live Hugging Face Space. ADR-145's ablation harness already computes
|
||||||
|
presence accuracy, localization error, FP/FN, latency percentiles, a
|
||||||
|
privacy-leakage score, and **cross-room degradation**. The board is
|
||||||
|
intentionally empty (benchmark-first). What is missing is not a scorer but a
|
||||||
|
**scorecard format** that reports per-domain rather than pooled, and a
|
||||||
|
**sensing-crate CI gate** that runs it on every PR.
|
||||||
|
|
||||||
|
## Options considered
|
||||||
|
|
||||||
|
1. **Keep the single pooled score / `RuViewTier`.** Rejected: it is exactly the
|
||||||
|
surface a per-domain regression hides behind; a Gold tier can coexist with a
|
||||||
|
broken unseen-room slice.
|
||||||
|
2. **Add a new benchmark repo/harness for domains.** Rejected: AetherArena's
|
||||||
|
scorer, determinism binding, and witness ledger already exist and are the
|
||||||
|
right engine; a parallel harness would fork the scoring substrate and its
|
||||||
|
anti-gaming/leakage discipline.
|
||||||
|
3. **Extend the AetherArena scorer with a per-domain scorecard and wire it as a
|
||||||
|
per-PR sensing-crate gate.** Chosen.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Reuse the AetherArena scorer and witness ledger (ADR-149) and add two things: a
|
||||||
|
**multi-domain scorecard** format and a **sensing-crate PR gate** that produces
|
||||||
|
it.
|
||||||
|
|
||||||
|
### 1. The multi-domain scorecard
|
||||||
|
|
||||||
|
The scorecard reports each capability broken out by operating domain, never
|
||||||
|
pooled into one figure. The v0 domain axes:
|
||||||
|
|
||||||
|
- **Presence**: `room-known`, `room-unseen`, `device-unseen`, `stationary-10m`
|
||||||
|
(a stationary subject at range — the canonical WiFi failure case).
|
||||||
|
- **Pose**: `matched`, `subject-unseen`, `room-unseen`.
|
||||||
|
- **OOD rejection**: the rate at which genuinely out-of-distribution input is
|
||||||
|
correctly returned as UNKNOWN by ADR-302 (a capability, not a failure) and
|
||||||
|
the false-UNKNOWN rate on in-distribution input.
|
||||||
|
- **Calibration drift**: fingerprint-distance trajectory against the ADR-301
|
||||||
|
certificate over the scored window, and the fraction of inferences in each
|
||||||
|
ADR-302 `DomainState` (KNOWN / DEGRADED / UNKNOWN).
|
||||||
|
|
||||||
|
Each cell carries exactly one `EvidenceLevel` (L0–L5, ADR-282). A slice scored
|
||||||
|
on synthetic input is L0/`Synthetic` by construction; a slice on a leakage-free
|
||||||
|
held-out real split is graded higher and only then may a per-domain number be
|
||||||
|
labelled MEASURED. Pose PCK cells additionally require the mean-pose baseline
|
||||||
|
and a leakage-free held-out split (CLAUDE.md) or they are not reported as pose
|
||||||
|
accuracy at all.
|
||||||
|
|
||||||
|
### 2. Per-domain regression gate
|
||||||
|
|
||||||
|
- The gate compares each scorecard cell against the merged-baseline scorecard
|
||||||
|
stored in the AetherArena witness ledger. A regression **in any single
|
||||||
|
domain** beyond its configured threshold fails the PR, even if the pooled
|
||||||
|
average improved. Improvement on `room-known` cannot buy a regression on
|
||||||
|
`room-unseen`.
|
||||||
|
- Thresholds are per-domain and per-capability; the unseen/stationary/OOD
|
||||||
|
domains carry the strictest budgets because they are the ones a pooled score
|
||||||
|
hides. The baseline is append-only and witness-anchored — a new baseline is a
|
||||||
|
new signed ledger entry, never an in-place overwrite (ADR-149 ledger pattern,
|
||||||
|
ADR-319 anchoring).
|
||||||
|
|
||||||
|
### 3. Sensing-crate CI wiring
|
||||||
|
|
||||||
|
- Every PR that touches a sensing crate runs the scorecard across all domains
|
||||||
|
under the ADR-011/ADR-149 determinism binding (`PROOF_SEED=42`), so the run
|
||||||
|
is reproducible and tamper-evident. The gate is added to
|
||||||
|
`.github/workflows/` as an authoritative check.
|
||||||
|
- The held-out real split remains private and is never accessible to synthetic
|
||||||
|
generation, augmentation, or calibration (ADR-149 leakage constraint, ADR-282
|
||||||
|
rule d). Submitters/PRs provide a model, not predictions on data they hold.
|
||||||
|
|
||||||
|
### Provenance and honesty discipline
|
||||||
|
|
||||||
|
- No benchmark numbers are invented by this ADR. It delivers the scorecard
|
||||||
|
format, the per-domain gate, and the CI wiring; the numbers come from the
|
||||||
|
ADR-304 evidence ledger and the AetherArena scorer on real data, labelled at
|
||||||
|
the honest evidence level. Empty domains report "no evidence," which the gate
|
||||||
|
treats as no coverage — never as a pass.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- A domain-generalization regression can no longer merge behind a flattering
|
||||||
|
pooled average; the failure mode that most distinguishes fielded sensing from
|
||||||
|
a demo is caught at PR time.
|
||||||
|
- Every PR touching sensing pays a per-domain scoring cost. Bounded by reusing
|
||||||
|
the existing deterministic scorer and by tiered compute (CPU smoke vs full
|
||||||
|
score, ADR-149), but it is a deliberate cost for per-domain safety.
|
||||||
|
- The empty AetherArena board fills with honest, per-domain, evidence-labelled
|
||||||
|
results rather than a single headline tier — consistent with the
|
||||||
|
benchmark-first posture and with ADR-282's ecosystem positioning.
|
||||||
|
- Some domains will show weak or absent coverage. Surfacing that per-domain is
|
||||||
|
the point; the scorecard must never paper over a thin domain with a pooled
|
||||||
|
number.
|
||||||
|
- The program-level acceptance test (ADR-300) is encoded here as an AetherArena
|
||||||
|
scenario, closing the loop once the phase-1 spine lands.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
- `cargo test` on the AetherArena scorer extension — per-domain slicing math
|
||||||
|
against fixtures; per-domain regression gate fails on a single-domain
|
||||||
|
regression while pooled improves, and passes when all domains hold; empty
|
||||||
|
domains report "no evidence," not a pass; every cell carries exactly one
|
||||||
|
`EvidenceLevel`; synthetic slices are L0 by construction.
|
||||||
|
- Determinism: a scored run reproduces its SHA-256 hash under `PROOF_SEED=42`
|
||||||
|
(ADR-011/ADR-149 binding); the baseline scorecard is append-only and
|
||||||
|
witness-anchored (ADR-319), never mutated in place.
|
||||||
|
- CI: the sensing-crate gate runs on a PR touching a sensing crate and blocks a
|
||||||
|
planted single-domain regression.
|
||||||
|
- Real-data scorecards (a leakage-free held-out split with ADR-303 references)
|
||||||
|
are the maturity milestone; a synthetic scorecard is L0 and no per-domain
|
||||||
|
number is MEASURED without a reproducer per CLAUDE.md.
|
||||||
138
docs/adr/ADR-318-capability-certificates.md
Normal file
138
docs/adr/ADR-318-capability-certificates.md
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
# ADR-318: Capability certificates — validated-for-this-environment claims
|
||||||
|
|
||||||
|
- **Status**: Accepted — initial implementation planned (ADR-300 phase 1)
|
||||||
|
- **Date**: 2026-08-11
|
||||||
|
- **Deciders**: ruv
|
||||||
|
- **Tags**: capability, certificate, evidence, provenance, signature, honesty, substrate
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
This ADR is primitive 18 of the perception-substrate program (ADR-300) and,
|
||||||
|
per the strategic assessment, among the strongest ideas in the program: it is
|
||||||
|
where the whole certificate spine becomes a consumable contract. In the ADR-300
|
||||||
|
dependency DAG it **consumes the evidence engine (ADR-304)** — a capability
|
||||||
|
certificate is a signed attestation minted over a slice of that ledger — the
|
||||||
|
**calibration certificate (ADR-301)** for the environment it is validated
|
||||||
|
against, and the **RuField signature types (ADR-305 / ADR-260/262/277/279)** to
|
||||||
|
sign it. It reports domain state via ADR-302 and is anchored in the witness
|
||||||
|
chain (ADR-319).
|
||||||
|
|
||||||
|
RuView must stop making unconditional capability claims. "Supports presence" is
|
||||||
|
not a true statement — presence detection works in some rooms, on some hardware,
|
||||||
|
for some subject dynamics, and fails on a stationary subject at range in an
|
||||||
|
uncalibrated room. A capability is only ever *validated for a specific
|
||||||
|
environment*, and the honest unit of that claim is a signed, expiring
|
||||||
|
certificate, not a feature flag in a README.
|
||||||
|
|
||||||
|
The ingredients now exist across the phase-1 spine: ADR-304 accumulates
|
||||||
|
per-`(room, device, subject)` accuracy, false-positive rate, drift, and domain
|
||||||
|
state; ADR-301 produces the signed room fingerprint the environment is keyed to;
|
||||||
|
ADR-305 provides the authenticated device identity and `CapabilityAttestation`
|
||||||
|
(BFLD, ADR-141) that bounds *what a device is even attested to sense*; ADR-282
|
||||||
|
provides the mandatory `EvidenceLevel`. What is missing is the artifact that
|
||||||
|
binds them into a single, verifiable "validated here, until then" claim and the
|
||||||
|
consumer-side rule that refuses capabilities lacking one.
|
||||||
|
|
||||||
|
## Options considered
|
||||||
|
|
||||||
|
1. **Static capability flags / a `supports_presence` boolean.** Rejected: it is
|
||||||
|
the exact dishonest claim — environment-independent, unsigned, non-expiring,
|
||||||
|
and false the moment the room, device, or subject dynamics differ.
|
||||||
|
2. **Report raw ledger accuracy to consumers directly.** Rejected: the ledger
|
||||||
|
(ADR-304) is the source of truth but not a portable, signed, bounded contract;
|
||||||
|
handing consumers raw records pushes evidence-weighting and expiry logic into
|
||||||
|
every consumer and drops the single verifiable object.
|
||||||
|
3. **Mint a signed, expiring `CapabilityCertificate` over an ADR-304 ledger
|
||||||
|
slice, and make consumers refuse capabilities without a valid one.** Chosen.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Introduce a signed **`CapabilityCertificate`**: a bounded attestation that a
|
||||||
|
specific capability has been validated for a specific environment, for a bounded
|
||||||
|
time.
|
||||||
|
|
||||||
|
### 1. The certificate
|
||||||
|
|
||||||
|
A serializable `CapabilityCertificate` binding:
|
||||||
|
|
||||||
|
- `capability` — the phenomenon (e.g. `presence`, `pose`), which must be within
|
||||||
|
the device's ADR-305/ADR-141 `CapabilityAttestation` (a device cannot be
|
||||||
|
certified for something it is not even attested to sense).
|
||||||
|
- `room` — the ADR-306 space identifier, tied to the ADR-301 calibration
|
||||||
|
certificate version the validation was performed against.
|
||||||
|
- `hardware` — the ADR-305 authenticated `DeviceId` (and, in phase 2, the
|
||||||
|
ADR-320 HAL descriptor of the sensor).
|
||||||
|
- `model` — the model version scored.
|
||||||
|
- `calibrated_date` — the calibration certificate age at validation time.
|
||||||
|
- `moving_recall`, `stationary_recall`, `false_presence_per_24h` — the measured
|
||||||
|
operating metrics, sliced from the ADR-304 ledger for this exact context (not
|
||||||
|
a global average), each honestly labelled. These are per-capability; a pose
|
||||||
|
certificate carries pose metrics with the mean-pose baseline and a
|
||||||
|
leakage-free split (CLAUDE.md) or it is not issued.
|
||||||
|
- `valid_until` — an explicit expiry; a certificate is never open-ended.
|
||||||
|
- `evidence_level` — exactly one L0–L5 (ADR-282). A certificate minted from a
|
||||||
|
synthetic ledger slice is L0/`Synthetic`; a MEASURED metric requires an
|
||||||
|
ADR-303 reference and a reproducer. The certificate cannot upgrade the level
|
||||||
|
of the ledger it is minted from (ADR-304 honesty rule).
|
||||||
|
- `signature` — a RuField `SignatureBlock` (ADR-305 / ADR-260/262/277/279) over
|
||||||
|
the canonical serialization; an unsigned certificate is not a valid
|
||||||
|
certificate. The certificate is anchored in the witness chain (ADR-319).
|
||||||
|
|
||||||
|
### 2. Minting
|
||||||
|
|
||||||
|
- A certificate is minted from a slice of the ADR-304 evidence ledger for one
|
||||||
|
`(room, device, subject-class, model)` context. If the ledger reports "no
|
||||||
|
evidence" for that context, **no certificate is issued** — absence of evidence
|
||||||
|
is never a capability. Minting is a pure function over the append-only ledger
|
||||||
|
at mint time; the metrics are frozen into the signed object.
|
||||||
|
- Expiry (`valid_until`) is derived from calibration validity (ADR-301) and an
|
||||||
|
evidence-freshness policy: a certificate cannot outlive the calibration it was
|
||||||
|
validated against, and drift beyond the ADR-301 envelope invalidates both.
|
||||||
|
|
||||||
|
### 3. Consumer refusal rule
|
||||||
|
|
||||||
|
- Applications and surfaces **refuse to consume a capability that lacks a valid
|
||||||
|
certificate for the current environment**. "Valid" means: signature verifies,
|
||||||
|
`room`/`hardware`/`model` match the running context, `valid_until` is in the
|
||||||
|
future, and the referenced calibration certificate is itself still valid
|
||||||
|
(ADR-301 not invalidated). A failed check yields UNKNOWN via ADR-302, not a
|
||||||
|
best-effort guess.
|
||||||
|
- This makes the ADR-300 acceptance clause "quantify whether it can reliably
|
||||||
|
sense the requested phenomenon → generate a signed capability certificate"
|
||||||
|
a hard gate rather than a hope.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- RuView can no longer claim a capability it has not validated for the caller's
|
||||||
|
environment; the honest failure — "not certified here" → UNKNOWN — is
|
||||||
|
surfaced by construction rather than by discipline.
|
||||||
|
- OEM/integrator diligence gets a single verifiable artifact ("presence,
|
||||||
|
validated in *this* room, on *this* device, with *these* recall/false-alarm
|
||||||
|
numbers, until *this* date, at *this* evidence level, signed") — the strongest
|
||||||
|
commercial output of the spine.
|
||||||
|
- Certificates expire and get refused; some environments will have no
|
||||||
|
certificate and therefore no capability until validated. That refusal is the
|
||||||
|
intended honest behavior, not a regression.
|
||||||
|
- Key management and expiry policy are operational responsibilities, reusing the
|
||||||
|
ADR-305 enrollment/rotation and ADR-301 validity machinery rather than new
|
||||||
|
infrastructure; fleet distribution of certificates is owned by ADR-316.
|
||||||
|
- No capability number is invented here; every metric on a certificate is sliced
|
||||||
|
from the ADR-304 ledger at its honest evidence level.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
- `cargo test` on the certificate crate — mint from a ledger slice produces the
|
||||||
|
frozen metrics; "no evidence" context yields no certificate; signature
|
||||||
|
round-trip and tamper rejection; `valid_until` and calibration-linked expiry
|
||||||
|
enforced; consumer refusal on room/hardware/model mismatch, expiry, or
|
||||||
|
invalidated calibration resolves to UNKNOWN (ADR-302), not a guess; evidence
|
||||||
|
level is inherited from the ledger and cannot be upgraded; a certificate
|
||||||
|
cannot be issued for a capability outside the device's ADR-305/ADR-141
|
||||||
|
attestation.
|
||||||
|
- Cross-ADR: an ADR-304 ledger fixture mints a certificate; an ADR-302 test
|
||||||
|
asserts an expired/mismatched certificate gates to UNKNOWN; the ADR-300
|
||||||
|
acceptance test consumes a minted certificate end-to-end.
|
||||||
|
- Real-deployment certificates (minted from a populated ledger with ADR-303
|
||||||
|
references on live ESP32 captures) are the maturity milestone and require
|
||||||
|
hardware evidence per CLAUDE.md; a certificate minted from a synthetic ledger
|
||||||
|
is L0 by construction.
|
||||||
146
docs/adr/ADR-319-witness-chain.md
Normal file
146
docs/adr/ADR-319-witness-chain.md
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
# ADR-319: Witness chain — epistemic infrastructure for physical AI
|
||||||
|
|
||||||
|
- **Status**: Accepted — initial implementation planned (ADR-300 phase 1)
|
||||||
|
- **Date**: 2026-08-11
|
||||||
|
- **Deciders**: ruv
|
||||||
|
- **Tags**: provenance, witness, evidence, signature, epistemics, ontology, substrate
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
This ADR is primitive 19 of the perception-substrate program (ADR-300) and a
|
||||||
|
spine root of its phase-1 certificate stack. In the ADR-300 dependency DAG it
|
||||||
|
**extends the source-provenance state machine (ADR-295)** and the RuField
|
||||||
|
provenance types, **ties to the signature machinery (ADR-305 /
|
||||||
|
ADR-260/262/277/279)**, and anchors the artifacts produced by ADR-301
|
||||||
|
(calibration certificates), ADR-304 (evidence records), ADR-317 (benchmark
|
||||||
|
scorecards), and ADR-318 (capability certificates). In phase 2 it carries the
|
||||||
|
independent-corroboration link from ADR-303.
|
||||||
|
|
||||||
|
The strategic assessment (ADR-300) framed RuView's real product as **epistemic
|
||||||
|
infrastructure for physical AI**: the value is not the claim "a person is
|
||||||
|
present" but the *auditable reasoning* behind it. A bare boolean output discards
|
||||||
|
everything a downstream system needs to trust or contest it — which radio
|
||||||
|
observed it, what DSP evidence supported it, which model inferred it, whether an
|
||||||
|
independent sensor agreed, what spatial state it updated, and what policy acted
|
||||||
|
on it. Once the answer is a boolean, "why do you believe that?" has no answer.
|
||||||
|
|
||||||
|
RuView already has the pieces of a chain but not the chain itself. ADR-295
|
||||||
|
defines a canonical `SourceState` (`Synthetic` / `LiveVerified` /
|
||||||
|
`LiveUnverified` / `Stale` / `Disconnected`) with `Unknown` structurally
|
||||||
|
forbidden from collapsing to live. ADR-305 defines the signed
|
||||||
|
`device → measurement → sequence → timestamp → … → signed event` chain of
|
||||||
|
custody. RuField carries `FrameProvenance`, `SemanticProvenance`, and signature
|
||||||
|
types; the AetherArena witness ledger (ADR-149) demonstrates an append-only,
|
||||||
|
witness-anchored ledger. What is missing is a single **staged, signed envelope**
|
||||||
|
that travels the whole pipeline and records, at each stage, the confidence and
|
||||||
|
provenance of that stage.
|
||||||
|
|
||||||
|
## Options considered
|
||||||
|
|
||||||
|
1. **Keep provenance as scattered per-stage fields (status quo).** Rejected:
|
||||||
|
`FrameProvenance`, `SourceState`, calibration state, and model uncertainty
|
||||||
|
live in different structures and are re-encoded per surface; there is no
|
||||||
|
single object a consumer can re-verify offline to answer "why."
|
||||||
|
2. **Log a free-form audit trail alongside the output.** Rejected: mutable,
|
||||||
|
unsigned, and not structurally tied to the output — the classic
|
||||||
|
dashboard-that-overwrites-yesterday failure the evidence engine (ADR-304)
|
||||||
|
already rejects.
|
||||||
|
3. **A staged, signed witness envelope carried through the pipeline, each stage
|
||||||
|
appended and signed, anchored in an append-only ledger.** Chosen.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Define the **witness chain**: a staged, append-only, signed envelope that
|
||||||
|
accompanies an observation from radio to policy decision. Instead of emitting
|
||||||
|
"person present," RuView emits a chain whose stages are:
|
||||||
|
|
||||||
|
```
|
||||||
|
RF observation ▸ DSP evidence ▸ model inference ▸ independent corroboration
|
||||||
|
▸ spatial state ▸ policy decision
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1. The staged envelope
|
||||||
|
|
||||||
|
- Each stage is a signed record carrying its **confidence** and its
|
||||||
|
**provenance**:
|
||||||
|
- **RF observation** — the ADR-305 authenticated frame envelope
|
||||||
|
(`DeviceId`, sequence, timestamp, measurement hash) and its ADR-295
|
||||||
|
`SourceState`. This is the root link; a `Synthetic` root can never present
|
||||||
|
as a `LiveVerified` one (ADR-295 invariant).
|
||||||
|
- **DSP evidence** — the deterministic signal features and the ADR-137
|
||||||
|
quality signals that support (or fail to support) an inference.
|
||||||
|
- **model inference** — the model version, its raw output, and its predictive
|
||||||
|
uncertainty; the ADR-302 `DomainState` (KNOWN / DEGRADED / UNKNOWN) gate
|
||||||
|
result, so a low-confidence or out-of-distribution inference is recorded as
|
||||||
|
such, not silently promoted.
|
||||||
|
- **independent corroboration** — the phase-2 ADR-303 agreement link
|
||||||
|
(a reference/second modality that agreed or disagreed); absent in phase 1,
|
||||||
|
the stage records "no corroboration," never a fabricated one.
|
||||||
|
- **spatial state** — the ADR-306 ontology `Observation`/`Track`/`Event` the
|
||||||
|
inference updated, carrying `SemanticProvenance` and its `EvidenceLevel`.
|
||||||
|
- **policy decision** — the governed action taken (or withheld), with the
|
||||||
|
certificate (ADR-318) it relied on.
|
||||||
|
- Each stage carries exactly one `EvidenceLevel` (L0–L5, ADR-282); the envelope's
|
||||||
|
effective level is the **minimum** across its stages — a synthetic root or an
|
||||||
|
unreferenced inference caps the whole chain, so the chain cannot claim more
|
||||||
|
than its weakest link.
|
||||||
|
|
||||||
|
### 2. Signing and anchoring
|
||||||
|
|
||||||
|
- Each stage is signed with RuField signature types (ADR-305 /
|
||||||
|
ADR-260/262/277/279) over the canonical serialization of that stage plus the
|
||||||
|
hash of the prior stage, so the chain is tamper-evident end to end and any
|
||||||
|
broken link is detectable. The completed chain is anchored in an append-only,
|
||||||
|
witness-anchored ledger following the AetherArena pattern (ADR-149); it is the
|
||||||
|
same anchoring ADR-301/ADR-304/ADR-317/ADR-318 write into.
|
||||||
|
- The chain is **append-only**: a correction is a new chain referencing the
|
||||||
|
prior one, never an in-place edit (mirroring ADR-304 and CLAUDE.md's "source
|
||||||
|
over summaries").
|
||||||
|
|
||||||
|
### 3. Offline re-verification
|
||||||
|
|
||||||
|
- A consumer with the enrolled public keys (ADR-305) can re-verify a chain
|
||||||
|
offline: check each stage signature, check each prior-stage hash, and read the
|
||||||
|
per-stage confidence and evidence level — answering "why do you believe this?"
|
||||||
|
without trusting the emitting host. This is the property store-and-forward
|
||||||
|
channel authentication (rejected in ADR-305) cannot provide.
|
||||||
|
|
||||||
|
### Provenance and honesty discipline
|
||||||
|
|
||||||
|
- The witness chain never manufactures confidence: a stage that lacks evidence
|
||||||
|
records the absence. A `Synthetic` root, a missing corroboration, or an
|
||||||
|
UNKNOWN gate is carried faithfully and caps the chain's evidence level. No
|
||||||
|
accuracy number is invented here; the chain records the numbers the other
|
||||||
|
primitives produce at their honest level.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- Every RuView output becomes contestable and auditable: a downstream physical-AI
|
||||||
|
system can inspect the reasoning, weight it by per-stage confidence, and reject
|
||||||
|
a chain whose weakest link is too weak — the defining property of epistemic
|
||||||
|
infrastructure the strategic assessment asked for.
|
||||||
|
- The certificate spine (ADR-301/301/314/315) gains a single anchoring substrate;
|
||||||
|
each of those artifacts is a specialization of a witness record rather than a
|
||||||
|
bespoke signed blob.
|
||||||
|
- Carrying and signing a staged envelope adds per-observation size and CPU cost;
|
||||||
|
bounded by reusing RuField signatures and the existing ledger, and by the
|
||||||
|
minimum-level rule keeping the object honest rather than exhaustive.
|
||||||
|
- The chain will frequently reveal weak links (synthetic root, no corroboration,
|
||||||
|
DEGRADED gate). Surfacing that is the point; the envelope must never smooth a
|
||||||
|
weak stage into a confident summary.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
- `cargo test` on the witness-chain crate — stage-by-stage signature round-trip
|
||||||
|
and tamper rejection (a mutated stage or a broken prior-stage hash fails
|
||||||
|
verification); effective evidence level equals the minimum across stages; a
|
||||||
|
`Synthetic` root caps the chain and cannot present as `LiveVerified`
|
||||||
|
(ADR-295 invariant); an UNKNOWN gate (ADR-302) and a "no corroboration" stage
|
||||||
|
are recorded faithfully; append-only correction produces a new chain
|
||||||
|
referencing the prior one.
|
||||||
|
- Cross-ADR: an ADR-305 signed frame lineage serializes into a chain that
|
||||||
|
re-verifies offline with only the enrolled public keys; ADR-301/301/314/315
|
||||||
|
artifacts anchor into the same ledger.
|
||||||
|
- Real-deployment chains (from live ESP32 captures with ADR-303 corroboration)
|
||||||
|
are the maturity milestone and require hardware evidence per CLAUDE.md; a
|
||||||
|
chain rooted in synthetic input is L0 by construction.
|
||||||
150
docs/adr/ADR-320-sensor-hal.md
Normal file
150
docs/adr/ADR-320-sensor-hal.md
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
# ADR-320: RuView sensor HAL — abstract all sensing hardware to one Observation type
|
||||||
|
|
||||||
|
- **Status**: Accepted — initial implementation (ADR-300 phase 2)
|
||||||
|
- **Date**: 2026-08-11
|
||||||
|
- **Deciders**: ruv
|
||||||
|
- **Tags**: hal, sensor-abstraction, ontology, fusion, adapters, category, phase-2
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
This ADR is primitive 20 of the perception-substrate program (ADR-300) and a
|
||||||
|
phase-2 integration primitive; it is authored as **Proposed**. In the ADR-300
|
||||||
|
DAG it **consumes the canonical spatial ontology (ADR-306)** — its output is an
|
||||||
|
ontology `Observation` bound to a `Sensor` entity — and **feeds real sensor
|
||||||
|
fusion (ADR-311)**, which resolves many observations into one world state. It
|
||||||
|
closes the "identify the hardware" clause of the ADR-300 acceptance test that
|
||||||
|
phase 1 leaves open.
|
||||||
|
|
||||||
|
RuView's strategic ceiling is set by how tightly it is coupled to WiFi CSI.
|
||||||
|
Every new modality today lands as a bespoke ingest path with its own frame
|
||||||
|
shape, its own provenance handling, and its own place in the pipeline. That is
|
||||||
|
the difference between "a WiFi-DensePose project" and "an open
|
||||||
|
spatial-intelligence operating layer": the category changes the moment *any*
|
||||||
|
sensing hardware — {CSI, 802.11bf, BLE, UWB, mmWave, acoustic, camera, lidar,
|
||||||
|
IMU, custom} — enters through one abstraction and becomes one `Observation`
|
||||||
|
feeding one world model.
|
||||||
|
|
||||||
|
Crucially this is a *unification*, not a green field. Adapters already exist and
|
||||||
|
must be reused, not rebuilt:
|
||||||
|
|
||||||
|
- ADR-279's native RF frame contract (`RfFrameV2`) already unifies ESP32,
|
||||||
|
Intel, Atheros, PicoScenes, Realtek radar, and 320 MHz 802.11bk producers as
|
||||||
|
`RfFrameV2` producers into a shared latent — "lightweight per-device adapters
|
||||||
|
into a shared latent, not a shared tensor." The HAL generalizes that lesson
|
||||||
|
beyond RF.
|
||||||
|
- Existing CSI adapters (ESP32/Nexmon/FeitCSI paths), the mmWave fusion path
|
||||||
|
(ADR-063), and the multistatic WiFi path (ADR-029) are concrete producers to
|
||||||
|
bring under one trait.
|
||||||
|
- ADR-305 already authenticates a `Sensor`/`DeviceId`; ADR-306 already defines
|
||||||
|
`Sensor`, `Observation`, `Track`, and `Event` as first-class node types. The
|
||||||
|
HAL is the trait that turns a heterogeneous device into that authenticated
|
||||||
|
`Sensor` emitting those `Observation`s.
|
||||||
|
|
||||||
|
The gap is a single **`SensorHal` trait and one `Observation` type** that every
|
||||||
|
modality implements, so the world model never sees a modality-specific frame —
|
||||||
|
only a provenance-bearing, evidence-labelled `Observation`.
|
||||||
|
|
||||||
|
## Options considered
|
||||||
|
|
||||||
|
1. **Continue adding per-modality ingest paths.** Rejected: O(modalities) bespoke
|
||||||
|
pipelines, each re-encoding provenance and evidence, each a place the ladder
|
||||||
|
can be dropped — and it keeps RuView categorically a WiFi project.
|
||||||
|
2. **Force every modality into the ADR-274/279 RF tensor/frame.** Rejected: the
|
||||||
|
ADR-279 lesson is precisely that premature canonicalization discards
|
||||||
|
information (bandwidth, antenna structure, phase). A camera, lidar, or IMU
|
||||||
|
has no meaningful `RfFrameV2` projection; forcing one is the same mistake at a
|
||||||
|
larger scale.
|
||||||
|
3. **Define a `SensorHal` trait producing one `Observation` type, with existing
|
||||||
|
adapters as implementations feeding a shared latent and the ADR-306
|
||||||
|
ontology.** Chosen.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Introduce a **`SensorHal` trait** and a single **`Observation`** type. Every
|
||||||
|
sensing modality is an implementation of the trait; the world model consumes
|
||||||
|
only `Observation`s.
|
||||||
|
|
||||||
|
### 1. The `SensorHal` trait
|
||||||
|
|
||||||
|
- A `SensorHal` describes a device's **capabilities** (which phenomena it can
|
||||||
|
sense — reusing the ADR-305/ADR-141 `CapabilityAttestation`), its **native
|
||||||
|
frame** (kept native, not canonicalized, per the ADR-279 shared-latent
|
||||||
|
lesson), and a method that lifts a native frame into an `Observation`.
|
||||||
|
- Implementations wrap the existing producers: CSI (ESP32/Nexmon/FeitCSI via the
|
||||||
|
ADR-279 `RfFrameV2` path), 802.11bf (ADR-310, phase 2), BLE, UWB, mmWave
|
||||||
|
(ADR-063), acoustic, camera, lidar, IMU, and `custom`. RF modalities reuse the
|
||||||
|
ADR-279 per-device latent adapters wholesale; the HAL adds the non-RF and
|
||||||
|
ranging modalities under the same trait.
|
||||||
|
- The trait is the boundary where untrusted hardware input is validated
|
||||||
|
(CLAUDE.md: validate at every hardware/FFI boundary; default to least
|
||||||
|
authority). A device is authenticated as an ADR-305 `Sensor` before its
|
||||||
|
observations are trusted.
|
||||||
|
|
||||||
|
### 2. The `Observation` type
|
||||||
|
|
||||||
|
- One provenance-bearing `Observation`: a measurement plus its `SensorHal`
|
||||||
|
source descriptor, its ADR-305 authenticated `DeviceId`, its ADR-295
|
||||||
|
`SourceState`, its native-frame reference (not a lossy projection), and
|
||||||
|
exactly one `EvidenceLevel` (L0–L5, ADR-282). A camera-derived `Observation`
|
||||||
|
and a CSI-derived `Observation` are the same type with different provenance —
|
||||||
|
and a camera observation never lifts WiFi output to camera-grade; each carries
|
||||||
|
its own honest evidence level (CLAUDE.md: never present WiFi sensing as
|
||||||
|
camera-grade).
|
||||||
|
- The `Observation` maps directly onto the ADR-306 ontology `Observation` node
|
||||||
|
attached to its `Sensor`, so the ontology is the one representation and the
|
||||||
|
HAL is its ingest funnel.
|
||||||
|
|
||||||
|
### 3. Feeding fusion
|
||||||
|
|
||||||
|
- Observations from any set of modalities flow into ADR-311 fusion, which
|
||||||
|
resolves them into one probabilistic world state. The HAL guarantees fusion
|
||||||
|
never sees a modality-specific frame — only `Observation`s with uniform
|
||||||
|
provenance and evidence — which is what makes ADR-311's "many observations →
|
||||||
|
one world state" invariant implementable across heterogeneous hardware.
|
||||||
|
|
||||||
|
### Category and honesty discipline
|
||||||
|
|
||||||
|
- This ADR changes RuView's category from a WiFi-DensePose pipeline to an open
|
||||||
|
spatial-intelligence operating layer, but it makes **no accuracy claim**: the
|
||||||
|
HAL delivers a uniform ingest boundary, not a detector. Any capability of a
|
||||||
|
newly-connected sensor is still gated by ADR-302 and certified by ADR-318 for
|
||||||
|
its specific environment — connecting a camera does not grant a validated
|
||||||
|
capability by itself.
|
||||||
|
- Hardware support for a given modality is CLAIMED until demonstrated on real
|
||||||
|
silicon with captured evidence per CLAUDE.md; a passing trait test proves the
|
||||||
|
abstraction, not a fielded device.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- New sensing hardware lands as one `SensorHal` implementation instead of a
|
||||||
|
bespoke pipeline; the translation matrix stays O(modalities), mirroring how
|
||||||
|
ADR-306 collapsed the surface matrix.
|
||||||
|
- The ADR-300 acceptance clause "identify the hardware" becomes implementable:
|
||||||
|
a new sensor type is described by its HAL, authenticated as an ADR-305
|
||||||
|
`Sensor`, calibrated (ADR-301), gated (ADR-302), and certified (ADR-318)
|
||||||
|
through the same phase-1 spine, closing the last open clause.
|
||||||
|
- A trait boundary and an `Observation` type are added; existing RF adapters
|
||||||
|
are re-expressed as implementations rather than rewritten, preserving the
|
||||||
|
ADR-279 native-frame/shared-latent design.
|
||||||
|
- Non-RF modalities (camera, lidar, acoustic) enter the governed plane with the
|
||||||
|
same provenance and privacy discipline as RF; a camera is not a privacy-free
|
||||||
|
shortcut — it inherits the ADR-277 governance and its own evidence level.
|
||||||
|
- As a phase-2 Proposed ADR, the trait shape may be revised as ADR-311 fusion
|
||||||
|
and ADR-310 802.11bf land; that revision is expected for a phased program.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
- `cargo test` on the HAL crate (design-time, Proposed) — a fixture `SensorHal`
|
||||||
|
for each of at least two modalities (CSI via ADR-279, plus one non-RF)
|
||||||
|
produces uniform `Observation`s; every `Observation` carries a `DeviceId`,
|
||||||
|
`SourceState`, native-frame reference, and exactly one `EvidenceLevel`; a
|
||||||
|
synthetic source yields L0/`Synthetic` and cannot alias to measured
|
||||||
|
(ADR-279 invariant 6); an unauthenticated device's observations are rejected
|
||||||
|
at the trait boundary (ADR-305).
|
||||||
|
- Cross-ADR: an `Observation` maps round-trip to an ADR-306 ontology
|
||||||
|
`Observation` node with no provenance loss, and a set of `Observation`s from
|
||||||
|
distinct modalities is accepted by an ADR-311 fusion fixture.
|
||||||
|
- Real-silicon evidence is required before any modality's hardware support is
|
||||||
|
claimed beyond CLAIMED: a captured boot/runtime log from the real device
|
||||||
|
emitting `Observation`s. A successful build or simulator run is not hardware
|
||||||
|
evidence (CLAUDE.md).
|
||||||
101
docs/adr/ADR-321-decision-policy-action-authorization.md
Normal file
101
docs/adr/ADR-321-decision-policy-action-authorization.md
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
# ADR-321: Decision policy — action authorization conditioned on certificate class, freshness, uncertainty, and evidence
|
||||||
|
|
||||||
|
- **Status**: Accepted — initial implementation planned (ADR-300 phase 1)
|
||||||
|
- **Date**: 2026-08-11
|
||||||
|
- **Deciders**: ruv
|
||||||
|
- **Tags**: policy, authorization, safety, certificates, governed-action, phase-1
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The perception substrate (ADR-300) makes RuView state *what it knows* and
|
||||||
|
*how well* — the capability certificate (ADR-318) binds hardware, environment,
|
||||||
|
model, calibration, metrics, expiry, and evidence level. But a certificate is a
|
||||||
|
statement of knowledge, not a grant of action. The same certificate that is
|
||||||
|
adequate to dim a light is wholly inadequate to release a door lock or clear an
|
||||||
|
industrial stop condition.
|
||||||
|
|
||||||
|
Without an explicit authorization layer, every consumer re-implements its own
|
||||||
|
(inconsistent, usually optimistic) rule for "is this good enough to act on,"
|
||||||
|
and a confident-but-out-of-domain inference can reach an actuator. That is the
|
||||||
|
exact failure the substrate exists to prevent. Decision policy therefore
|
||||||
|
belongs in **phase 1**, alongside the certificate it gates, not later.
|
||||||
|
|
||||||
|
This ADR realizes program invariant #1 (UNKNOWN is a first-class output, never
|
||||||
|
an error) and the action-side of the refined acceptance test: a drift-
|
||||||
|
invalidated capability must be *denied at the actuator* before a false
|
||||||
|
confident inference is acted upon.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Introduce a `ruview-policy` crate providing an **action authorization gate**
|
||||||
|
that sits between governed spatial state and any actuator.
|
||||||
|
|
||||||
|
### 1. Assurance requirements per action
|
||||||
|
|
||||||
|
An `ActionClass` declares the assurance an action demands:
|
||||||
|
|
||||||
|
- `min_certificate_class` — the required `CapabilityCertificate` class (ADR-318).
|
||||||
|
- `max_certificate_age` / `min_domain_freshness` — the certificate must be
|
||||||
|
currently valid **and** the live domain signature (ADR-302) must not be in a
|
||||||
|
DEGRADED/UNKNOWN state (this is the staleness guard, program invariant on
|
||||||
|
certificate conditionality — see ADR-300).
|
||||||
|
- `max_uncertainty` — inference uncertainty ceiling.
|
||||||
|
- `min_evidence_level` — the L0–L5 floor (ADR-282/ADR-304); e.g. a safety
|
||||||
|
action may require ≥ L3 (held-out room+subject validation).
|
||||||
|
|
||||||
|
Reference action classes (illustrative, configurable):
|
||||||
|
|
||||||
|
| Class | Example | Typical floor |
|
||||||
|
|---|---|---|
|
||||||
|
| `Convenience` | lighting, scenes | tolerant: L1+, higher uncertainty ok |
|
||||||
|
| `Security` | alerts, arming | stricter: valid cert, L2+, bounded uncertainty |
|
||||||
|
| `SafetyCritical` | door lock, machine stop | strict: fresh cert, L3+, low uncertainty, KNOWN domain only |
|
||||||
|
|
||||||
|
### 2. The authorization decision
|
||||||
|
|
||||||
|
`authorize(action, capability_certificate, live_state) -> Authorization` where
|
||||||
|
`live_state` carries the current `SourceState` (ADR-295), OOD/domain state
|
||||||
|
(ADR-302), and inference uncertainty. Rules:
|
||||||
|
|
||||||
|
- **Fail-closed.** Any unmet condition → `Deny { failed_condition }`. The denial
|
||||||
|
names the *specific* condition (expired cert, domain DEGRADED, uncertainty
|
||||||
|
over ceiling, evidence below floor, certificate class too low).
|
||||||
|
- **UNKNOWN denies high-assurance actions.** A domain in UNKNOWN (ADR-302)
|
||||||
|
cannot authorize `Security`/`SafetyCritical` actions; it may still authorize
|
||||||
|
`Convenience` if that class's policy permits, but the authorization records
|
||||||
|
that it proceeded under UNKNOWN.
|
||||||
|
- The decision is a **pure function** of (action class, certificate, live
|
||||||
|
state) — deterministic and unit-testable without a clock or actuator.
|
||||||
|
- Every authorization (allow or deny) is emitted as the terminal stage of the
|
||||||
|
witness chain (ADR-319), so "why was this actuator allowed/denied" is
|
||||||
|
auditable end-to-end.
|
||||||
|
|
||||||
|
### 3. No silent optimism
|
||||||
|
|
||||||
|
A missing certificate, an expired certificate, or an unrecognized action class
|
||||||
|
all deny by default. Absence of a policy is not permission.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- Action authorization becomes uniform and centrally reasoned instead of
|
||||||
|
per-consumer and optimistic; this is the "RuView Certify → constrains action"
|
||||||
|
boundary that is hard to commoditize.
|
||||||
|
- A behavior change for existing automations that acted directly on presence:
|
||||||
|
they now pass through the gate. Convenience-class defaults keep low-stakes
|
||||||
|
automations working; high-stakes actions must opt into stricter classes.
|
||||||
|
- Depends on ADR-318 (certificate), ADR-302 (domain/OOD state), ADR-295
|
||||||
|
(source state), ADR-304 (evidence). Built in the phase-1 dependent wave after
|
||||||
|
those types land.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
- Unit tests: each action class authorizes/denies correctly across the matrix
|
||||||
|
of (valid/expired/degraded cert × KNOWN/DEGRADED/UNKNOWN domain × uncertainty
|
||||||
|
above/below ceiling × evidence above/below floor); UNKNOWN denies
|
||||||
|
safety-critical; every deny names its failed condition; absence-of-policy
|
||||||
|
denies; determinism.
|
||||||
|
- Integration: the acceptance-test scenario (ADR-300) — post-certification room
|
||||||
|
change drives domain to DEGRADED→UNKNOWN, and a `SafetyCritical` authorization
|
||||||
|
is denied with `failed_condition = domain_not_known` *before* the inference
|
||||||
|
reaches the actuator, witness chain preserved.
|
||||||
|
- `cargo test -p ruview-policy`.
|
||||||
@@ -148,6 +148,37 @@ Statuses: **Proposed** (under discussion), **Accepted** (approved and/or impleme
|
|||||||
| [ADR-288](ADR-288-veil-privacy-shield-compliant-waveform.md) | VEIL — compliant-waveform privacy shield against unauthorized WiFi sensing (`wifi-densepose-privshield`) | Proposed (implemented, P1 reference) |
|
| [ADR-288](ADR-288-veil-privacy-shield-compliant-waveform.md) | VEIL — compliant-waveform privacy shield against unauthorized WiFi sensing (`wifi-densepose-privshield`) | Proposed (implemented, P1 reference) |
|
||||||
| [ADR-289](ADR-289-wifi-densepose-privshield-harness-via-metaharness.md) | `wifi-densepose-privshield-harness` — npm MetaHarness for the VEIL crate (guidance/router/flywheel) | Proposed (implemented, P1) |
|
| [ADR-289](ADR-289-wifi-densepose-privshield-harness-via-metaharness.md) | `wifi-densepose-privshield-harness` — npm MetaHarness for the VEIL crate (guidance/router/flywheel) | Proposed (implemented, P1) |
|
||||||
| [ADR-290](ADR-290-veil-e2e-hardware-implementation-program.md) | VEIL end-to-end hardware implementation program — portable C core + multi-provider firmware scaffolds (openwifi/openwrt/nexmon/esp32) | Proposed (P4 scaffolding; C core host-validated) |
|
| [ADR-290](ADR-290-veil-e2e-hardware-implementation-program.md) | VEIL end-to-end hardware implementation program — portable C core + multi-provider firmware scaffolds (openwifi/openwrt/nexmon/esp32) | Proposed (P4 scaffolding; C core host-validated) |
|
||||||
|
| [ADR-291](ADR-291-public-benchmark-evaluation-harness.md) | Public-benchmark evaluation harness — Widar3.0 ingest, split protocols, leakage guards | Accepted (initial implementation) |
|
||||||
|
| [ADR-292](ADR-292-wideband-80211ax-csi-ingest.md) | Wideband 802.11ax CSI ingest — FeitCSI/AX210 adapter, subcarrier-agnostic plumbing | Accepted (initial implementation) |
|
||||||
|
| [ADR-293](ADR-293-vitals-ground-truth-rig.md) | Vitals ground-truth rig — reference ingest, alignment, agreement metrics | Accepted (initial implementation) |
|
||||||
|
| [ADR-294](ADR-294-wifi-veil-integration.md) | WiFi Veil integration — emission-shaping countermeasure as advisory BFLD dependency | Accepted (initial implementation) |
|
||||||
|
| [ADR-295](ADR-295-source-provenance-state-machine.md) | Source provenance state machine — synthetic can never present as live | Accepted (initial implementation) |
|
||||||
|
| [ADR-296](ADR-296-sensor-data-plane-bind-hardening.md) | Sensor data-plane hardening — UDP bind control and source allowlist (step one) | Accepted (initial implementation) |
|
||||||
|
| [ADR-297](ADR-297-multi-node-semantic-correctness.md) | Multi-node semantic correctness — per-node inference, node-keyed rate limiting, stale state | Accepted (initial implementation) |
|
||||||
|
| [ADR-298](ADR-298-model-release-sanity-gates.md) | Model release sanity gates — block degenerate and mislabeled model artifacts | Accepted (initial implementation) |
|
||||||
|
| [ADR-299](ADR-299-csi-data-incident-repo-controls.md) | Repository CSI data-incident controls — ignore rules and pre-commit/CI policy check | Accepted (controls implemented; tree remediation gated) |
|
||||||
|
| [ADR-300](ADR-300-perception-substrate-program.md) | RuView perception substrate — phased 21-primitive program (calibration, evidence, trust, deployment) | Accepted (program; children ADR-301..317) |
|
||||||
|
| [ADR-301](ADR-301-automatic-domain-calibration.md) | Automatic domain calibration — signed, versioned, invalidatable room fingerprint | Accepted (phase 1) |
|
||||||
|
| [ADR-302](ADR-302-out-of-distribution-detection.md) | Out-of-distribution detection — KNOWN / DEGRADED / UNKNOWN gating | Accepted (phase 1) |
|
||||||
|
| [ADR-303](ADR-303-ground-truth-synchronization.md) | Ground-truth synchronization — reference sensors as a formal validation plane | Proposed (phase 2) |
|
||||||
|
| [ADR-304](ADR-304-evidence-engine.md) | Evidence engine — per-(room,device,subject) accuracy ledger | Accepted (phase 1) |
|
||||||
|
| [ADR-305](ADR-305-authenticated-sensor-identity.md) | Authenticated sensor identity — RF chain of custody | Accepted (phase 1) |
|
||||||
|
| [ADR-306](ADR-306-canonical-spatial-ontology.md) | Canonical spatial ontology — one Site→…→Event model for every surface | Accepted (phase 1) |
|
||||||
|
| [ADR-307](ADR-307-persistent-identity-tracking.md) | Persistent identity & tracking — privacy-preserving probabilistic tracks | Proposed (phase 2) |
|
||||||
|
| [ADR-308](ADR-308-sensor-placement-optimizer.md) | Sensor placement optimizer — floorplan + inventory → recommended positions | Proposed (phase 3) |
|
||||||
|
| [ADR-309](ADR-309-active-sensing.md) | Active sensing — closed-loop RF experiment control | Proposed (phase 3) |
|
||||||
|
| [ADR-310](ADR-310-80211bf-native-architecture.md) | 802.11bf-native architecture — standardized WLAN sensing as native measurement types | Proposed (phase 2) |
|
||||||
|
| [ADR-311](ADR-311-real-sensor-fusion.md) | Real sensor fusion — uncertainty-aware, multiple observations → one world state | Proposed (phase 2) |
|
||||||
|
| [ADR-312](ADR-312-long-term-spatial-memory.md) | Long-term spatial memory — learn the normal physics of a location | Proposed (phase 3) |
|
||||||
|
| [ADR-313](ADR-313-counterfactual-inference.md) | Counterfactual inference — generative spatial reasoning | Proposed (phase 3) |
|
||||||
|
| [ADR-314](ADR-314-information-gain-scheduler.md) | Information-gain scheduler — sample the most informative radios | Proposed (phase 3) |
|
||||||
|
| [ADR-315](ADR-315-digital-rf-twin.md) | Digital RF twin — persistent per-deployment RF model | Proposed (phase 3) |
|
||||||
|
| [ADR-316](ADR-316-fleet-control-plane.md) | Fleet control plane — provisioning to audit trails | Proposed (phase 2) |
|
||||||
|
| [ADR-317](ADR-317-benchmark-multi-domain-scorecard.md) | Multi-domain benchmark scorecard — regressions cannot hide behind pooled accuracy | Accepted (phase 1) |
|
||||||
|
| [ADR-318](ADR-318-capability-certificates.md) | Capability certificates — validated-for-this-environment claims | Accepted (phase 1) |
|
||||||
|
| [ADR-319](ADR-319-witness-chain.md) | Witness chain — staged, signed epistemic envelope | Accepted (phase 1) |
|
||||||
|
| [ADR-320](ADR-320-sensor-hal.md) | RuView sensor HAL — abstract all sensing hardware to one Observation type | Proposed (phase 2) |
|
||||||
|
| [ADR-321](ADR-321-decision-policy-action-authorization.md) | Decision policy — action authorization conditioned on certificate class, freshness, uncertainty, evidence | Accepted (phase 1) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ WiFi DensePose turns commodity WiFi signals into real-time human pose estimation
|
|||||||
14. [Training a Model](#training-a-model)
|
14. [Training a Model](#training-a-model)
|
||||||
- [CRV Signal-Line Protocol](#crv-signal-line-protocol)
|
- [CRV Signal-Line Protocol](#crv-signal-line-protocol)
|
||||||
14. [RVF Model Containers](#rvf-model-containers)
|
14. [RVF Model Containers](#rvf-model-containers)
|
||||||
|
14. [Perception Certificate Spine (Developer Preview, ADR-300)](#perception-certificate-spine-developer-preview-adr-297)
|
||||||
14. [Hardware Setup](#hardware-setup)
|
14. [Hardware Setup](#hardware-setup)
|
||||||
- [ESP32-S3 Mesh](#esp32-s3-mesh)
|
- [ESP32-S3 Mesh](#esp32-s3-mesh)
|
||||||
- [Intel 5300 / Atheros NIC](#intel-5300--atheros-nic)
|
- [Intel 5300 / Atheros NIC](#intel-5300--atheros-nic)
|
||||||
@@ -1493,6 +1494,78 @@ An RVF file contains: model weights, HNSW vector index, quantization codebooks,
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Perception Certificate Spine (Developer Preview, ADR-300)
|
||||||
|
|
||||||
|
RuView's perception substrate program (ADR-300) is building a `signal → observation →
|
||||||
|
calibration → inference → uncertainty → evidence → certificate → policy → governed
|
||||||
|
action` pipeline, where a downstream consumer either gets a calibrated, provenance-backed
|
||||||
|
answer or an explicit `UNKNOWN` — never a confident-looking guess outside the sensor's
|
||||||
|
proven operating envelope.
|
||||||
|
|
||||||
|
**Status: developer preview.** Phase 1 shipped nine new crates with their own test
|
||||||
|
suites, and each one works correctly in isolation. **They are not yet wired together or
|
||||||
|
into the live `sensing-server` request path** — there is currently no code path where a
|
||||||
|
real drift signal from a running sensor flows through calibration → certificate
|
||||||
|
invalidation → policy denial. Treat everything below as a library you can compose
|
||||||
|
yourself today, not a safety guarantee the server enforces for you yet.
|
||||||
|
|
||||||
|
### The crates
|
||||||
|
|
||||||
|
| Crate | Role |
|
||||||
|
|---|---|
|
||||||
|
| `ruview-ontology` | Canonical `Site → … → Event` types |
|
||||||
|
| `ruview-attest` | Signed measurement / RF chain-of-custody |
|
||||||
|
| `ruview-evidence` | Append-only per-context ledger (no pooling, no evidence upgrade) |
|
||||||
|
| `wifi-densepose-calibration` | Signed, drift-invalidatable calibration certificate |
|
||||||
|
| `ruview-ood` | `Known` / `Degraded` / `Unknown` staleness-guard domain gating |
|
||||||
|
| `ruview-witness` | Hash-linked staged provenance chain |
|
||||||
|
| `ruview-certify` | Capability certificate, conditional on a live domain signature |
|
||||||
|
| `ruview-scorecard` | Multi-domain scorecard, worst-domain promotion gate |
|
||||||
|
| `ruview-policy` | Fail-closed action authorization gate |
|
||||||
|
|
||||||
|
### Minting and checking a certificate
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use ruview_certify::{mint, CapabilityCertificate, DomainState};
|
||||||
|
|
||||||
|
// `signer`, `request`, and `evidence_slice` come from your own calibration run —
|
||||||
|
// see each crate's README for how to build them.
|
||||||
|
let cert = mint(&signer, request, &evidence_slice)?;
|
||||||
|
|
||||||
|
// A certificate is only valid at a given instant AND domain state — the same
|
||||||
|
// signed certificate is rejected the moment the live domain degrades:
|
||||||
|
assert!(cert.is_valid(now_ms, DomainState::Known));
|
||||||
|
assert!(!cert.is_valid(now_ms, DomainState::Degraded));
|
||||||
|
assert!(!cert.is_valid(now_ms, DomainState::Unknown));
|
||||||
|
```
|
||||||
|
|
||||||
|
### Gating an action
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use ruview_policy::{authorize, ActionClass, DomainState};
|
||||||
|
|
||||||
|
let decision = authorize(ActionClass::SafetyCritical, &inputs);
|
||||||
|
// Deny with a named FailedCondition (e.g. `domain_not_known`) rather than a
|
||||||
|
// silent false-positive, whenever the domain isn't KNOWN.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Important:** `ruview_certify::DomainState` and `ruview_policy::DomainState` (and
|
||||||
|
`ruview_ood`'s) are currently three separate enum types — `ruview-ood`'s `Degraded`
|
||||||
|
variant even carries different data. There is no automatic conversion between them.
|
||||||
|
If you compose these crates yourself today, you own writing that bridge; don't assume
|
||||||
|
one crate's domain read automatically reaches another's gate.
|
||||||
|
|
||||||
|
### What's genuinely enforced today, for comparison
|
||||||
|
|
||||||
|
Not every ADR-295–296 remediation item is preview-only. Two are live now:
|
||||||
|
|
||||||
|
- **UDP data-plane bind hardening (ADR-296)** — `sensing-server`'s `UdpSourceAllowlist`
|
||||||
|
is checked on every incoming packet (`main.rs`), not just defined.
|
||||||
|
- **CSI data-incident repo controls (ADR-299)** — `scripts/csi-data-policy-check.sh`
|
||||||
|
runs in CI on every push/PR and fails the build on a policy violation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Hardware Setup
|
## Hardware Setup
|
||||||
|
|
||||||
### Supported targets
|
### Supported targets
|
||||||
|
|||||||
282
scripts/csi-data-policy-check.sh
Executable file
282
scripts/csi-data-policy-check.sh
Executable file
@@ -0,0 +1,282 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# csi-data-policy-check.sh — ADR-299 CSI data-incident repository guard.
|
||||||
|
#
|
||||||
|
# WHY (ADR-299): raw CSI recordings are person data (they encode breathing,
|
||||||
|
# movement, and presence) and CLAUDE.md prohibits committing CSI or person
|
||||||
|
# data. A stale `.gitignore` rule let ~64.6 MB of raw captures reach the tree
|
||||||
|
# under `data/recordings/` and `v2/data/recordings/`. This check is the
|
||||||
|
# mechanical guard that prevents the incident from getting worse: it fails when
|
||||||
|
# CSI-format files or oversized JSONL captures are tracked/staged.
|
||||||
|
#
|
||||||
|
# WHAT IT FLAGS:
|
||||||
|
# * `*.csi.jsonl` — raw CSI capture stream (person data)
|
||||||
|
# * `*.csi.meta.json` — capture sidecar metadata
|
||||||
|
# * `*.jsonl` larger than CSI_POLICY_MAX_JSONL_BYTES (~5 MB default) — a
|
||||||
|
# capture-sized JSONL blob that almost never belongs in git.
|
||||||
|
#
|
||||||
|
# DETERMINISTIC / OFFLINE: no network, no clock, no randomness. It only reads
|
||||||
|
# the file list git already knows about (or a list you pass in) and file sizes.
|
||||||
|
#
|
||||||
|
# USAGE:
|
||||||
|
# scripts/csi-data-policy-check.sh # scan tracked files (git ls-files)
|
||||||
|
# scripts/csi-data-policy-check.sh --staged # scan the staged set (pre-commit)
|
||||||
|
# scripts/csi-data-policy-check.sh --files-from - # scan a newline list on stdin
|
||||||
|
# scripts/csi-data-policy-check.sh --files-from FILE
|
||||||
|
# scripts/csi-data-policy-check.sh --self-test # run built-in self-tests
|
||||||
|
#
|
||||||
|
# EXIT CODES: 0 = clean, 1 = policy violation, 2 = usage/environment error.
|
||||||
|
#
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
# ALLOWLIST (synthetic test fixtures)
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
# Tests may use only synthetic or expressly-consented minimal fixtures (ADR-299).
|
||||||
|
# A file whose path matches an allow pattern is exempt. Patterns come from:
|
||||||
|
# * the file `scripts/csi-data-policy.allow` (one glob per line, `#` comments), and
|
||||||
|
# * the env var `CSI_POLICY_ALLOW` (colon-separated globs).
|
||||||
|
# Patterns are shell globs matched against the repo-relative path, e.g.
|
||||||
|
# scripts/tests/fixtures/csi-policy/*.csi.jsonl
|
||||||
|
#
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
# BASELINE (acknowledged pre-existing incident, ADR-299)
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
# The tree today ALREADY contains the incident recordings under
|
||||||
|
# `data/recordings/` and `v2/data/recordings/`. Removing them is destructive and
|
||||||
|
# gated on data-owner sign-off (ADR-299 "Decision"), so this guard is EXPECTED to
|
||||||
|
# fail on the current tree — that failure documents the incident.
|
||||||
|
#
|
||||||
|
# Once the owner removes those files, or to acknowledge them in the interim
|
||||||
|
# without weakening the guard for NEW files, point `CSI_POLICY_BASELINE` at a
|
||||||
|
# file listing the acknowledged repo-relative paths (one per line, `#` comments,
|
||||||
|
# globs allowed). Baseline-matched files are reported as "acknowledged" and do
|
||||||
|
# NOT fail the check; every other violation still fails. This is the intended
|
||||||
|
# mechanism to make the CI job green in a follow-up once remediation lands.
|
||||||
|
#
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# --- configuration -----------------------------------------------------------
|
||||||
|
# ~5 MB default. Override with CSI_POLICY_MAX_JSONL_BYTES for tests/tuning.
|
||||||
|
MAX_JSONL_BYTES="${CSI_POLICY_MAX_JSONL_BYTES:-5242880}"
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
ALLOW_FILE="${CSI_POLICY_ALLOW_FILE:-$SCRIPT_DIR/csi-data-policy.allow}"
|
||||||
|
|
||||||
|
# --- allow / baseline pattern loading ----------------------------------------
|
||||||
|
# Read glob patterns from a file (skip blank lines and `#` comments) into the
|
||||||
|
# named array. Missing files are treated as empty (not an error).
|
||||||
|
read_patterns() {
|
||||||
|
local file="$1" __arrname="$2" line
|
||||||
|
eval "$__arrname=()"
|
||||||
|
[[ -f "$file" ]] || return 0
|
||||||
|
while IFS= read -r line || [[ -n "$line" ]]; do
|
||||||
|
line="${line%%#*}"
|
||||||
|
# trim leading/trailing whitespace
|
||||||
|
line="${line#"${line%%[![:space:]]*}"}"
|
||||||
|
line="${line%"${line##*[![:space:]]}"}"
|
||||||
|
[[ -z "$line" ]] && continue
|
||||||
|
eval "$__arrname+=(\"\$line\")"
|
||||||
|
done < "$file"
|
||||||
|
}
|
||||||
|
|
||||||
|
ALLOW_PATTERNS=()
|
||||||
|
read_patterns "$ALLOW_FILE" ALLOW_PATTERNS
|
||||||
|
# Append colon-separated CSI_POLICY_ALLOW entries.
|
||||||
|
if [[ -n "${CSI_POLICY_ALLOW:-}" ]]; then
|
||||||
|
local_ifs="$IFS"; IFS=':'
|
||||||
|
for p in $CSI_POLICY_ALLOW; do [[ -n "$p" ]] && ALLOW_PATTERNS+=("$p"); done
|
||||||
|
IFS="$local_ifs"
|
||||||
|
fi
|
||||||
|
|
||||||
|
BASELINE_PATTERNS=()
|
||||||
|
if [[ -n "${CSI_POLICY_BASELINE:-}" ]]; then
|
||||||
|
[[ -f "$CSI_POLICY_BASELINE" ]] || {
|
||||||
|
echo "ERROR: CSI_POLICY_BASELINE points at a missing file: $CSI_POLICY_BASELINE" >&2
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
read_patterns "$CSI_POLICY_BASELINE" BASELINE_PATTERNS
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Return 0 if $1 matches any glob in the array named by $2.
|
||||||
|
matches_any() {
|
||||||
|
local path="$1" __arrname="$2" pat
|
||||||
|
local -a arr
|
||||||
|
eval "arr=(\"\${${__arrname}[@]}\")"
|
||||||
|
for pat in "${arr[@]:-}"; do
|
||||||
|
[[ -z "$pat" ]] && continue
|
||||||
|
# shellcheck disable=SC2053 # intentional glob match
|
||||||
|
[[ "$path" == $pat ]] && return 0
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- per-file classification -------------------------------------------------
|
||||||
|
# Echo a violation reason for $1, or nothing if the file is fine. CSI-format
|
||||||
|
# files are always flagged; other .jsonl files are flagged only when oversized.
|
||||||
|
classify() {
|
||||||
|
local f="$1"
|
||||||
|
case "$f" in
|
||||||
|
*.csi.jsonl) echo "CSI capture stream (*.csi.jsonl)"; return 0 ;;
|
||||||
|
*.csi.meta.json) echo "CSI capture metadata (*.csi.meta.json)"; return 0 ;;
|
||||||
|
esac
|
||||||
|
case "$f" in
|
||||||
|
*.jsonl)
|
||||||
|
local size
|
||||||
|
size="$(file_size "$f")"
|
||||||
|
if [[ "$size" -gt "$MAX_JSONL_BYTES" ]]; then
|
||||||
|
echo "oversized JSONL capture (${size} bytes > ${MAX_JSONL_BYTES})"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# Best-effort byte size for a repo-relative path. Prefer the working-tree file;
|
||||||
|
# fall back to git's stored blob so the check works on a bare/partial checkout.
|
||||||
|
# Prints 0 when the size cannot be determined (glob rules still apply).
|
||||||
|
file_size() {
|
||||||
|
local f="$1" sz
|
||||||
|
if [[ -f "$f" ]]; then
|
||||||
|
sz="$(stat -c%s "$f" 2>/dev/null || stat -f%z "$f" 2>/dev/null || echo 0)"
|
||||||
|
echo "${sz:-0}"; return 0
|
||||||
|
fi
|
||||||
|
if command -v git >/dev/null 2>&1; then
|
||||||
|
sz="$(git cat-file -s ":$f" 2>/dev/null || git cat-file -s "HEAD:$f" 2>/dev/null || echo 0)"
|
||||||
|
echo "${sz:-0}"; return 0
|
||||||
|
fi
|
||||||
|
echo 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- core scan ---------------------------------------------------------------
|
||||||
|
# Read newline-separated repo-relative paths on stdin and enforce policy.
|
||||||
|
# Exits 1 if any non-baseline, non-allowlisted violation is found.
|
||||||
|
scan_stdin() {
|
||||||
|
local violations=0 acknowledged=0 f reason
|
||||||
|
while IFS= read -r f || [[ -n "$f" ]]; do
|
||||||
|
[[ -z "$f" ]] && continue
|
||||||
|
reason="$(classify "$f")"
|
||||||
|
[[ -z "$reason" ]] && continue
|
||||||
|
if matches_any "$f" ALLOW_PATTERNS; then
|
||||||
|
continue # synthetic fixture, expressly allowed
|
||||||
|
fi
|
||||||
|
if matches_any "$f" BASELINE_PATTERNS; then
|
||||||
|
echo "ack: $f — $reason (acknowledged baseline, ADR-299)" >&2
|
||||||
|
acknowledged=$((acknowledged + 1))
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
echo "BLOCK: $f — $reason" >&2
|
||||||
|
violations=$((violations + 1))
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ "$acknowledged" -gt 0 ]]; then
|
||||||
|
echo "note: $acknowledged file(s) acknowledged via CSI_POLICY_BASELINE (ADR-299)." >&2
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$violations" -gt 0 ]]; then
|
||||||
|
echo "" >&2
|
||||||
|
echo "FAIL: $violations CSI/person-data policy violation(s) (ADR-299)." >&2
|
||||||
|
echo " Raw CSI is person data and must not be tracked in git. See" >&2
|
||||||
|
echo " docs/adr/ADR-299-csi-data-incident-repo-controls.md." >&2
|
||||||
|
echo " Synthetic test fixtures can be allowlisted in $ALLOW_FILE." >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
echo "OK: no CSI/person-data policy violations." >&2
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- input sources -----------------------------------------------------------
|
||||||
|
emit_tracked() {
|
||||||
|
command -v git >/dev/null 2>&1 || { echo "ERROR: git not found" >&2; exit 2; }
|
||||||
|
git ls-files
|
||||||
|
}
|
||||||
|
|
||||||
|
emit_staged() {
|
||||||
|
command -v git >/dev/null 2>&1 || { echo "ERROR: git not found" >&2; exit 2; }
|
||||||
|
git diff --cached --name-only --diff-filter=ACMR
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- self-tests --------------------------------------------------------------
|
||||||
|
# Deterministic, offline. Builds synthetic fixtures in a temp dir and asserts
|
||||||
|
# the check flags a *.csi.jsonl / oversized JSONL and passes allowlisted ones.
|
||||||
|
self_test() {
|
||||||
|
local tmp rc pass=0 fail=0
|
||||||
|
tmp="$(mktemp -d)"
|
||||||
|
|
||||||
|
mkdir -p "$tmp/fixtures"
|
||||||
|
printf '{"csi":[1,2,3]}\n' > "$tmp/real.csi.jsonl"
|
||||||
|
printf '{"schema":1}\n' > "$tmp/real.csi.meta.json"
|
||||||
|
printf '{"note":"ok"}\n' > "$tmp/small.jsonl"
|
||||||
|
printf '{"synthetic":true}\n' > "$tmp/fixtures/synthetic.csi.jsonl"
|
||||||
|
# Oversized JSONL: 40 bytes, checked against a 10-byte threshold below.
|
||||||
|
printf '%0.sX' {1..40} > "$tmp/big.jsonl"; printf '\n' >> "$tmp/big.jsonl"
|
||||||
|
|
||||||
|
assert() { # desc expected_rc actual_rc
|
||||||
|
if [[ "$2" -eq "$3" ]]; then echo " PASS: $1"; pass=$((pass+1));
|
||||||
|
else echo " FAIL: $1 (expected rc=$2, got rc=$3)"; fail=$((fail+1)); fi
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "self-test: fixtures in $tmp"
|
||||||
|
|
||||||
|
# 1. A raw *.csi.jsonl must be blocked.
|
||||||
|
rc=0; printf '%s\n' "$tmp/real.csi.jsonl" | scan_stdin >/dev/null 2>&1 || rc=$?
|
||||||
|
assert "blocks *.csi.jsonl" 1 "$rc"
|
||||||
|
|
||||||
|
# 2. A *.csi.meta.json must be blocked.
|
||||||
|
rc=0; printf '%s\n' "$tmp/real.csi.meta.json" | scan_stdin >/dev/null 2>&1 || rc=$?
|
||||||
|
assert "blocks *.csi.meta.json" 1 "$rc"
|
||||||
|
|
||||||
|
# 3. A small, ordinary .jsonl must pass.
|
||||||
|
rc=0; printf '%s\n' "$tmp/small.jsonl" | scan_stdin >/dev/null 2>&1 || rc=$?
|
||||||
|
assert "passes small ordinary .jsonl" 0 "$rc"
|
||||||
|
|
||||||
|
# 4. An oversized .jsonl must be blocked (tiny threshold, deterministic).
|
||||||
|
rc=0; CSI_POLICY_MAX_JSONL_BYTES=10 bash "$0" --files-from - <<<"$tmp/big.jsonl" >/dev/null 2>&1 || rc=$?
|
||||||
|
assert "blocks oversized .jsonl" 1 "$rc"
|
||||||
|
|
||||||
|
# 5. An allowlisted synthetic fixture must pass despite matching *.csi.jsonl.
|
||||||
|
rc=0; CSI_POLICY_ALLOW="$tmp/fixtures/*.csi.jsonl" bash "$0" --files-from - \
|
||||||
|
<<<"$tmp/fixtures/synthetic.csi.jsonl" >/dev/null 2>&1 || rc=$?
|
||||||
|
assert "passes allowlisted synthetic fixture" 0 "$rc"
|
||||||
|
|
||||||
|
# 6. A baseline-acknowledged CSI file must pass (job made green post-cleanup).
|
||||||
|
local bl="$tmp/baseline.txt"; printf '%s\n' "$tmp/real.csi.jsonl" > "$bl"
|
||||||
|
rc=0; CSI_POLICY_BASELINE="$bl" bash "$0" --files-from - \
|
||||||
|
<<<"$tmp/real.csi.jsonl" >/dev/null 2>&1 || rc=$?
|
||||||
|
assert "passes baseline-acknowledged file" 0 "$rc"
|
||||||
|
|
||||||
|
echo "self-test: $pass passed, $fail failed"
|
||||||
|
rm -rf "$tmp"
|
||||||
|
[[ "$fail" -eq 0 ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- entrypoint --------------------------------------------------------------
|
||||||
|
main() {
|
||||||
|
local mode="tracked" from=""
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--staged) mode="staged" ;;
|
||||||
|
--tracked) mode="tracked" ;;
|
||||||
|
--files-from) mode="files-from"; from="${2:-}"; shift ;;
|
||||||
|
--self-test) mode="self-test" ;;
|
||||||
|
-h|--help) grep '^#' "$0" | sed 's/^#\s\{0,1\}//'; exit 0 ;;
|
||||||
|
*) echo "ERROR: unknown argument '$1'" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
shift
|
||||||
|
done
|
||||||
|
|
||||||
|
case "$mode" in
|
||||||
|
self-test) self_test ;;
|
||||||
|
tracked) emit_tracked | scan_stdin ;;
|
||||||
|
staged) emit_staged | scan_stdin ;;
|
||||||
|
files-from)
|
||||||
|
if [[ "$from" == "-" || -z "$from" ]]; then
|
||||||
|
scan_stdin
|
||||||
|
else
|
||||||
|
[[ -f "$from" ]] || { echo "ERROR: --files-from file not found: $from" >&2; exit 2; }
|
||||||
|
scan_stdin < "$from"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
14
scripts/csi-data-policy.allow
Normal file
14
scripts/csi-data-policy.allow
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
# csi-data-policy.allow — ADR-299 synthetic-fixture allowlist.
|
||||||
|
#
|
||||||
|
# One shell glob per line (repo-relative paths). `#` starts a comment; blank
|
||||||
|
# lines are ignored. A tracked/staged file whose path matches any pattern here
|
||||||
|
# is exempt from the CSI data-policy check (scripts/csi-data-policy-check.sh).
|
||||||
|
#
|
||||||
|
# ONLY synthetic or expressly-consented minimal fixtures belong here (ADR-299).
|
||||||
|
# Never allowlist a real capture to silence the guard — real CSI is person data.
|
||||||
|
# The CSI_POLICY_ALLOW env var appends extra patterns (colon-separated) for
|
||||||
|
# one-off/local use.
|
||||||
|
#
|
||||||
|
# Conventional location for synthetic CSI test fixtures generated by tests:
|
||||||
|
scripts/tests/fixtures/csi-policy/*.csi.jsonl
|
||||||
|
scripts/tests/fixtures/csi-policy/*.csi.meta.json
|
||||||
@@ -55,11 +55,15 @@ export class CsiSimulator {
|
|||||||
this.ws = new WebSocket(url);
|
this.ws = new WebSocket(url);
|
||||||
this.ws.binaryType = 'arraybuffer';
|
this.ws.binaryType = 'arraybuffer';
|
||||||
this.ws.onmessage = (evt) => this._handleLiveFrame(evt.data);
|
this.ws.onmessage = (evt) => this._handleLiveFrame(evt.data);
|
||||||
this.ws.onopen = () => { this.mode = 'live'; resolve(true); };
|
// ADR-295 (issue #1557): a socket that merely *opened* is NOT live —
|
||||||
|
// synthetic demo data keeps flowing until a real frame is decoded. We
|
||||||
|
// stay in demo mode (watermarked) on open; `_handleLiveFrame` flips to
|
||||||
|
// live only once it has parsed a verified frame.
|
||||||
|
this.ws.onopen = () => { this.socketOpen = true; resolve(true); };
|
||||||
this.ws.onerror = () => resolve(false);
|
this.ws.onerror = () => resolve(false);
|
||||||
this.ws.onclose = () => { this.mode = 'demo'; };
|
this.ws.onclose = () => { this.mode = 'demo'; this.verifiedFrame = false; this.socketOpen = false; };
|
||||||
// Timeout after 3s
|
// Timeout after 3s
|
||||||
setTimeout(() => { if (this.mode !== 'live') resolve(false); }, 3000);
|
setTimeout(() => { if (!this.socketOpen) resolve(false); }, 3000);
|
||||||
} catch {
|
} catch {
|
||||||
resolve(false);
|
resolve(false);
|
||||||
}
|
}
|
||||||
@@ -69,9 +73,12 @@ export class CsiSimulator {
|
|||||||
disconnect() {
|
disconnect() {
|
||||||
if (this.ws) { this.ws.close(); this.ws = null; }
|
if (this.ws) { this.ws.close(); this.ws = null; }
|
||||||
this.mode = 'demo';
|
this.mode = 'demo';
|
||||||
|
this.verifiedFrame = false;
|
||||||
|
this.socketOpen = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
get isLive() { return this.mode === 'live'; }
|
/** True only once a real frame has been decoded — not merely on socket open. */
|
||||||
|
get isLive() { return this.mode === 'live' && this.verifiedFrame === true; }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update person state from video detection (for correlated demo data).
|
* Update person state from video detection (for correlated demo data).
|
||||||
@@ -292,6 +299,15 @@ export class CsiSimulator {
|
|||||||
this._liveAmplitude[i] = Math.sqrt(real * real + imag * imag) / 2048;
|
this._liveAmplitude[i] = Math.sqrt(real * real + imag * imag) / 2048;
|
||||||
this._livePhase[i] = Math.atan2(imag, real);
|
this._livePhase[i] = Math.atan2(imag, real);
|
||||||
}
|
}
|
||||||
|
// ADR-295 (issue #1557): a real frame was decoded — only now is this live.
|
||||||
|
this._markVerifiedFrame();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ADR-295: promote from watermarked demo to live once a real frame lands. */
|
||||||
|
_markVerifiedFrame() {
|
||||||
|
this.verifiedFrame = true;
|
||||||
|
this.mode = 'live';
|
||||||
|
if (typeof this.onVerifiedFrame === 'function') this.onVerifiedFrame();
|
||||||
}
|
}
|
||||||
|
|
||||||
_handleJsonFrame(msg) {
|
_handleJsonFrame(msg) {
|
||||||
@@ -311,6 +327,8 @@ export class CsiSimulator {
|
|||||||
for (let i = 0; i < n; i++) {
|
for (let i = 0; i < n; i++) {
|
||||||
this._liveAmplitude[i] = Math.abs(ampArr[i]) * scale;
|
this._liveAmplitude[i] = Math.abs(ampArr[i]) * scale;
|
||||||
}
|
}
|
||||||
|
// ADR-295 (issue #1557): a real frame carrying amplitude was decoded.
|
||||||
|
this._markVerifiedFrame();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase from node (if available)
|
// Phase from node (if available)
|
||||||
|
|||||||
@@ -151,12 +151,21 @@ function init() {
|
|||||||
if (wsUrlInput) wsUrlInput.value = defaultWsUrl;
|
if (wsUrlInput) wsUrlInput.value = defaultWsUrl;
|
||||||
// ADR-272: exchange the stored bearer for a single-use ?ticket= before the
|
// ADR-272: exchange the stored bearer for a single-use ?ticket= before the
|
||||||
// upgrade — a browser cannot set an Authorization header on a WebSocket.
|
// upgrade — a browser cannot set an Authorization header on a WebSocket.
|
||||||
withWsTicket(defaultWsUrl).then(u => csiSimulator.connectLive(u)).then(ok => {
|
// ADR-295 (issue #1557): opening the socket does NOT mean live — the
|
||||||
if (ok && connectWsBtn) {
|
// simulator keeps producing watermarked SYNTHETIC data until a real CSI frame
|
||||||
|
// is decoded. Only the verified-frame callback promotes the label to LIVE.
|
||||||
|
csiSimulator.onVerifiedFrame = () => {
|
||||||
|
if (connectWsBtn) {
|
||||||
connectWsBtn.textContent = '✓ Live ESP32';
|
connectWsBtn.textContent = '✓ Live ESP32';
|
||||||
connectWsBtn.classList.add('active');
|
connectWsBtn.classList.add('active');
|
||||||
statusLabel.textContent = 'LIVE CSI';
|
}
|
||||||
statusDot.classList.remove('offline');
|
statusLabel.textContent = 'LIVE CSI';
|
||||||
|
statusDot.classList.remove('offline');
|
||||||
|
};
|
||||||
|
withWsTicket(defaultWsUrl).then(u => csiSimulator.connectLive(u)).then(ok => {
|
||||||
|
if (ok) {
|
||||||
|
// Socket open, but no verified frame yet — stay honest.
|
||||||
|
statusLabel.textContent = 'SYNTHETIC';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -304,25 +304,41 @@ class SensingService {
|
|||||||
* hardware or simulation. Called once on WebSocket open.
|
* hardware or simulation. Called once on WebSocket open.
|
||||||
*/
|
*/
|
||||||
async _detectServerSource() {
|
async _detectServerSource() {
|
||||||
|
// ADR-295 (issue #1526): an unreachable or unauthorized status endpoint is
|
||||||
|
// an *unknown* state — it must NOT collapse to "live". Prefer the canonical
|
||||||
|
// `source_state` the server now returns; on any error stay conservative
|
||||||
|
// (server-simulated) until a real frame's `source` field promotes us.
|
||||||
try {
|
try {
|
||||||
const resp = await fetch('/api/v1/status');
|
const resp = await fetch('/api/v1/status');
|
||||||
if (resp.ok) {
|
if (resp.ok) {
|
||||||
const json = await resp.json();
|
const json = await resp.json();
|
||||||
this._applyServerSource(json.source);
|
this._applyServerSource(json.source, json.source_state);
|
||||||
} else {
|
} else {
|
||||||
// Can't reach status endpoint — assume live until first frame tells us
|
this._setDataSource('server-simulated');
|
||||||
this._setDataSource('live');
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
this._setDataSource('live');
|
this._setDataSource('server-simulated');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Map a raw server source string to the UI data-source label.
|
* Map a raw server source string (and optional canonical ADR-295
|
||||||
|
* `source_state`) to the UI data-source label.
|
||||||
*/
|
*/
|
||||||
_applyServerSource(rawSource) {
|
_applyServerSource(rawSource, sourceState) {
|
||||||
this._serverSource = rawSource;
|
this._serverSource = rawSource;
|
||||||
|
// ADR-295: only the verified/unverified live states may show "live"; any
|
||||||
|
// synthetic/stale/disconnected state must not.
|
||||||
|
if (sourceState) {
|
||||||
|
if (sourceState === 'live_verified' || sourceState === 'live_unverified') {
|
||||||
|
this._setDataSource('live');
|
||||||
|
} else if (sourceState === 'synthetic') {
|
||||||
|
this._setDataSource('server-simulated');
|
||||||
|
} else {
|
||||||
|
this._setDataSource('server-simulated');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (rawSource === 'esp32' || rawSource === 'wifi' || rawSource === 'live') {
|
if (rawSource === 'esp32' || rawSource === 'wifi' || rawSource === 'live') {
|
||||||
this._setDataSource('live');
|
this._setDataSource('live');
|
||||||
} else if (rawSource === 'simulated' || rawSource === 'simulate') {
|
} else if (rawSource === 'simulated' || rawSource === 'simulate') {
|
||||||
|
|||||||
198
v2/Cargo.lock
generated
198
v2/Cargo.lock
generated
@@ -7869,6 +7869,27 @@ version = "2.0.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "753a07254fa68db183949ec6c7575d890da4d42404afabc11d610a720fcf570c"
|
checksum = "753a07254fa68db183949ec6c7575d890da4d42404afabc11d610a720fcf570c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ruview-active"
|
||||||
|
version = "0.3.1"
|
||||||
|
dependencies = [
|
||||||
|
"ruview-hal",
|
||||||
|
"ruview-ontology",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ruview-attest"
|
||||||
|
version = "0.3.1"
|
||||||
|
dependencies = [
|
||||||
|
"blake3",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ruview-auth"
|
name = "ruview-auth"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -7889,6 +7910,146 @@ dependencies = [
|
|||||||
"url",
|
"url",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ruview-certify"
|
||||||
|
version = "0.3.1"
|
||||||
|
dependencies = [
|
||||||
|
"blake3",
|
||||||
|
"ruview-attest",
|
||||||
|
"ruview-evidence",
|
||||||
|
"ruview-ontology",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"wifi-densepose-calibration",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ruview-counterfactual"
|
||||||
|
version = "0.3.1"
|
||||||
|
dependencies = [
|
||||||
|
"ruview-fusion",
|
||||||
|
"ruview-ontology",
|
||||||
|
"ruview-twin",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ruview-evidence"
|
||||||
|
version = "0.3.1"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ruview-fusion"
|
||||||
|
version = "0.3.1"
|
||||||
|
dependencies = [
|
||||||
|
"ruview-hal",
|
||||||
|
"ruview-ontology",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ruview-groundtruth"
|
||||||
|
version = "0.3.1"
|
||||||
|
dependencies = [
|
||||||
|
"ruview-evidence",
|
||||||
|
"ruview-ontology",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ruview-hal"
|
||||||
|
version = "0.3.1"
|
||||||
|
dependencies = [
|
||||||
|
"ruview-ontology",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ruview-infogain"
|
||||||
|
version = "0.3.1"
|
||||||
|
dependencies = [
|
||||||
|
"ruview-hal",
|
||||||
|
"ruview-ontology",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ruview-memory"
|
||||||
|
version = "0.3.1"
|
||||||
|
dependencies = [
|
||||||
|
"ruview-evidence",
|
||||||
|
"ruview-ontology",
|
||||||
|
"ruview-twin",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ruview-ontology"
|
||||||
|
version = "0.3.1"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ruview-ood"
|
||||||
|
version = "0.3.1"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"wifi-densepose-calibration",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ruview-placement"
|
||||||
|
version = "0.3.1"
|
||||||
|
dependencies = [
|
||||||
|
"ruview-ontology",
|
||||||
|
"ruview-twin",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ruview-policy"
|
||||||
|
version = "0.3.1"
|
||||||
|
dependencies = [
|
||||||
|
"ruview-evidence",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ruview-scorecard"
|
||||||
|
version = "0.3.1"
|
||||||
|
dependencies = [
|
||||||
|
"ruview-evidence",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ruview-swarm"
|
name = "ruview-swarm"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -7913,6 +8074,26 @@ dependencies = [
|
|||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ruview-track"
|
||||||
|
version = "0.3.1"
|
||||||
|
dependencies = [
|
||||||
|
"ruview-ontology",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ruview-twin"
|
||||||
|
version = "0.3.1"
|
||||||
|
dependencies = [
|
||||||
|
"ruview-ontology",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ruview-unified"
|
name = "ruview-unified"
|
||||||
version = "0.3.1"
|
version = "0.3.1"
|
||||||
@@ -7929,6 +8110,16 @@ dependencies = [
|
|||||||
"wifi-densepose-hardware",
|
"wifi-densepose-hardware",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ruview-witness"
|
||||||
|
version = "0.3.1"
|
||||||
|
dependencies = [
|
||||||
|
"ruview-attest",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ryu"
|
name = "ryu"
|
||||||
version = "1.0.23"
|
version = "1.0.23"
|
||||||
@@ -11365,6 +11556,7 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"static_assertions",
|
"static_assertions",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
|
"wifi-veil",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -11375,6 +11567,7 @@ dependencies = [
|
|||||||
"num-complex",
|
"num-complex",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sha2 0.10.9",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"uuid",
|
"uuid",
|
||||||
"wifi-densepose-core",
|
"wifi-densepose-core",
|
||||||
@@ -11804,6 +11997,11 @@ dependencies = [
|
|||||||
"wifi-densepose-geo",
|
"wifi-densepose-geo",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wifi-veil"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = "git+https://github.com/ruvnet/wifi-veil?rev=018468b5d2bf41f35c552910f35659830af0eb91#018468b5d2bf41f35c552910f35659830af0eb91"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "winapi"
|
name = "winapi"
|
||||||
version = "0.3.9"
|
version = "0.3.9"
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ members = [
|
|||||||
"crates/wifi-densepose-train",
|
"crates/wifi-densepose-train",
|
||||||
"crates/wifi-densepose-sensing-server",
|
"crates/wifi-densepose-sensing-server",
|
||||||
"crates/wifi-densepose-aether", # ADR-185 §13 — AETHER pure-compute leaf (std-only)
|
"crates/wifi-densepose-aether", # ADR-185 §13 — AETHER pure-compute leaf (std-only)
|
||||||
"crates/wifi-densepose-privshield", # ADR-288 — VEIL privacy shield (compliant-waveform anti-sensing; std-only leaf)
|
"crates/wifi-densepose-privshield", # ADR-291 — VEIL privacy shield (compliant-waveform anti-sensing; std-only leaf)
|
||||||
"crates/wifi-densepose-wifiscan",
|
"crates/wifi-densepose-wifiscan",
|
||||||
"crates/wifi-densepose-vitals",
|
"crates/wifi-densepose-vitals",
|
||||||
"crates/wifi-densepose-ruvector",
|
"crates/wifi-densepose-ruvector",
|
||||||
@@ -95,6 +95,28 @@ members = [
|
|||||||
# hardware coupling, every number SYNTHETIC/L0 until real wideband RF
|
# hardware coupling, every number SYNTHETIC/L0 until real wideband RF
|
||||||
# hardware exists.
|
# hardware exists.
|
||||||
"crates/wifi-densepose-sar",
|
"crates/wifi-densepose-sar",
|
||||||
|
# ADR-300 phase 1 — perception substrate spine (new first-party crates):
|
||||||
|
"crates/ruview-ontology", # ADR-306 canonical spatial ontology (Site..Event)
|
||||||
|
"crates/ruview-attest", # ADR-305 authenticated sensor identity / RF chain of custody
|
||||||
|
"crates/ruview-evidence", # ADR-304 evidence engine (per-room/device/subject ledger)
|
||||||
|
# ADR-300 phase 1 — dependent wave (build on the spine roots above):
|
||||||
|
"crates/ruview-ood", # ADR-302 OOD KNOWN/DEGRADED/UNKNOWN gating
|
||||||
|
"crates/ruview-witness", # ADR-319 witness chain (staged signed provenance)
|
||||||
|
"crates/ruview-certify", # ADR-318 capability certificate
|
||||||
|
"crates/ruview-scorecard", # ADR-317 multi-domain benchmark scorecard
|
||||||
|
"crates/ruview-policy", # ADR-321 decision policy / action authorization
|
||||||
|
# ADR-300 phase 2 — unified world-model core:
|
||||||
|
"crates/ruview-hal", # ADR-320 sensor HAL (any modality -> Observation)
|
||||||
|
"crates/ruview-groundtruth",# ADR-303 ground-truth synchronization / validation plane
|
||||||
|
"crates/ruview-track", # ADR-307 persistent privacy-preserving tracking
|
||||||
|
"crates/ruview-fusion", # ADR-311 uncertainty-aware fusion -> one world state
|
||||||
|
# ADR-300 phase 3 — higher-ceiling primitives (on the fused world state):
|
||||||
|
"crates/ruview-twin", # ADR-315 digital RF twin (per-deployment model)
|
||||||
|
"crates/ruview-placement", # ADR-308 sensor placement optimizer
|
||||||
|
"crates/ruview-memory", # ADR-312 long-term spatial memory / anomaly
|
||||||
|
"crates/ruview-counterfactual",# ADR-313 counterfactual spatial inference
|
||||||
|
"crates/ruview-infogain", # ADR-314 information-gain scheduler
|
||||||
|
"crates/ruview-active", # ADR-309 active sensing control
|
||||||
]
|
]
|
||||||
# ADR-040: WASM edge crate targets wasm32-unknown-unknown (no_std),
|
# ADR-040: WASM edge crate targets wasm32-unknown-unknown (no_std),
|
||||||
# excluded from workspace to avoid breaking `cargo test --workspace`.
|
# excluded from workspace to avoid breaking `cargo test --workspace`.
|
||||||
@@ -122,6 +144,10 @@ categories = ["science", "computer-vision", "wasm"]
|
|||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
# Core utilities
|
# Core utilities
|
||||||
thiserror = "2.0"
|
thiserror = "2.0"
|
||||||
|
# WiFi Veil — compliant-waveform countermeasure against unauthorized WiFi
|
||||||
|
# sensing (ADR-294). Dependency-free, deterministic, SYNTHETIC-only leaf;
|
||||||
|
# pinned to an exact rev because the crate is consumed pre-crates.io-release.
|
||||||
|
wifi-veil = { git = "https://github.com/ruvnet/wifi-veil", rev = "018468b5d2bf41f35c552910f35659830af0eb91" }
|
||||||
anyhow = "1.0"
|
anyhow = "1.0"
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
|
|||||||
16
v2/crates/ruview-active/Cargo.toml
Normal file
16
v2/crates/ruview-active/Cargo.toml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
[package]
|
||||||
|
name = "ruview-active"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
authors.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
thiserror.workspace = true
|
||||||
|
serde = { workspace = true, features = ["derive"] }
|
||||||
|
ruview-ontology = { path = "../ruview-ontology" }
|
||||||
|
ruview-hal = { path = "../ruview-hal" }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
serde_json.workspace = true
|
||||||
424
v2/crates/ruview-active/src/control.rs
Normal file
424
v2/crates/ruview-active/src/control.rs
Normal file
@@ -0,0 +1,424 @@
|
|||||||
|
//! Controllable degrees of freedom of an RF measurement (ADR-309 §1).
|
||||||
|
//!
|
||||||
|
//! **SYNTHETIC / L0 model scaffold.** These types describe *what a controller
|
||||||
|
//! could ask hardware to configure*; constructing one drives **no** radio and
|
||||||
|
//! emits **no** RF. Every axis is a typed enum / validated range so a malformed
|
||||||
|
//! configuration is rejected at the boundary rather than reaching an actuator.
|
||||||
|
//!
|
||||||
|
//! Each axis is optional and capability-gated: a deployment advertises the
|
||||||
|
//! values it can actually set through [`ControlCapability`]. A commodity ESP32
|
||||||
|
//! that can only vary its sounding cadence exposes a capability whose only
|
||||||
|
//! non-empty axis is [`ControlCapability::cadences`]; an all-empty capability
|
||||||
|
//! means nothing is controllable and the controller degrades to the passive
|
||||||
|
//! planner (ADR-309 §2, ADR-280).
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
/// Maximum number of distinct values accepted per control axis. Bounds
|
||||||
|
/// allocation when a capability set is built from untrusted input.
|
||||||
|
pub const MAX_AXIS_VALUES: usize = 64;
|
||||||
|
|
||||||
|
/// Maximum number of antenna chains a synthetic aperture may model.
|
||||||
|
pub const MAX_CHAINS: u8 = 16;
|
||||||
|
|
||||||
|
/// Smallest modelled sounding interval, in milliseconds (fastest cadence).
|
||||||
|
pub const MIN_CADENCE_MS: u32 = 1;
|
||||||
|
|
||||||
|
/// Largest modelled sounding interval, in milliseconds (slowest cadence).
|
||||||
|
pub const MAX_CADENCE_MS: u32 = 60_000;
|
||||||
|
|
||||||
|
/// Reasons a control value or capability set is rejected at the boundary.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Error)]
|
||||||
|
pub enum ControlError {
|
||||||
|
/// A channel number is not a valid channel for its band.
|
||||||
|
#[error("channel {number} is not valid in band {band:?}")]
|
||||||
|
InvalidChannel {
|
||||||
|
/// The rejected band.
|
||||||
|
band: Band,
|
||||||
|
/// The rejected channel number.
|
||||||
|
number: u16,
|
||||||
|
},
|
||||||
|
/// A bandwidth value (in MHz) is not a recognised channel width.
|
||||||
|
#[error("bandwidth {mhz} MHz is not a recognised channel width")]
|
||||||
|
InvalidBandwidth {
|
||||||
|
/// The rejected width in MHz.
|
||||||
|
mhz: u16,
|
||||||
|
},
|
||||||
|
/// A sounding interval is outside the modelled `[MIN, MAX]` cadence range.
|
||||||
|
#[error("cadence interval {interval_ms} ms is outside [{min}, {max}] ms")]
|
||||||
|
InvalidCadence {
|
||||||
|
/// The rejected interval in milliseconds.
|
||||||
|
interval_ms: u32,
|
||||||
|
/// The accepted minimum.
|
||||||
|
min: u32,
|
||||||
|
/// The accepted maximum.
|
||||||
|
max: u32,
|
||||||
|
},
|
||||||
|
/// An antenna selection is empty (no chains active).
|
||||||
|
#[error("antenna selection must activate at least one chain")]
|
||||||
|
EmptyAntennaSelection,
|
||||||
|
/// An antenna chain index is out of range for the declared aperture.
|
||||||
|
#[error("antenna chain index {index} is out of range for {num_chains} chains (max {max})")]
|
||||||
|
AntennaChainOutOfRange {
|
||||||
|
/// The offending chain index.
|
||||||
|
index: u8,
|
||||||
|
/// The declared number of chains.
|
||||||
|
num_chains: u8,
|
||||||
|
/// The largest permitted chain count.
|
||||||
|
max: u8,
|
||||||
|
},
|
||||||
|
/// A capability axis listed more than [`MAX_AXIS_VALUES`] values.
|
||||||
|
#[error("control axis lists {len} values, exceeding the maximum {max}")]
|
||||||
|
AxisTooLarge {
|
||||||
|
/// Actual number of values supplied.
|
||||||
|
len: usize,
|
||||||
|
/// The enforced maximum.
|
||||||
|
max: usize,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The RF band a channel belongs to. Determines which channel numbers are
|
||||||
|
/// valid.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum Band {
|
||||||
|
/// 2.4 GHz band.
|
||||||
|
Ghz24,
|
||||||
|
/// 5 GHz band.
|
||||||
|
Ghz5,
|
||||||
|
/// 6 GHz band (Wi-Fi 6E).
|
||||||
|
Ghz6,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The standard 5 GHz channel numbers RuView may model probing.
|
||||||
|
const GHZ5_CHANNELS: &[u16] = &[
|
||||||
|
36, 40, 44, 48, 52, 56, 60, 64, 100, 104, 108, 112, 116, 120, 124, 128, 132, 136, 140, 144,
|
||||||
|
149, 153, 157, 161, 165,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// A validated Wi-Fi channel: a band plus a channel number known to that band.
|
||||||
|
///
|
||||||
|
/// This is a *choice of which spectrum to probe*, not an instruction to any
|
||||||
|
/// radio. Construction validates the number against its band so an invalid
|
||||||
|
/// channel can never enter a [`ControlAction`].
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||||
|
pub struct Channel {
|
||||||
|
band: Band,
|
||||||
|
number: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Channel {
|
||||||
|
/// Construct a validated channel, rejecting a number that is not valid in
|
||||||
|
/// its band.
|
||||||
|
pub fn new(band: Band, number: u16) -> Result<Self, ControlError> {
|
||||||
|
let valid = match band {
|
||||||
|
Band::Ghz24 => (1..=14).contains(&number),
|
||||||
|
Band::Ghz5 => GHZ5_CHANNELS.contains(&number),
|
||||||
|
// Wi-Fi 6E channels are the odd numbers 1..=233.
|
||||||
|
Band::Ghz6 => (1..=233).contains(&number) && number % 2 == 1,
|
||||||
|
};
|
||||||
|
if valid {
|
||||||
|
Ok(Self { band, number })
|
||||||
|
} else {
|
||||||
|
Err(ControlError::InvalidChannel { band, number })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The band this channel is in.
|
||||||
|
#[must_use]
|
||||||
|
pub fn band(&self) -> Band {
|
||||||
|
self.band
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The channel number.
|
||||||
|
#[must_use]
|
||||||
|
pub fn number(&self) -> u16 {
|
||||||
|
self.number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A validated channel width. Wider widths probe more spectrum per sounding and
|
||||||
|
/// are treated as *more exploratory* by the controller.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum Bandwidth {
|
||||||
|
/// 20 MHz.
|
||||||
|
Bw20,
|
||||||
|
/// 40 MHz.
|
||||||
|
Bw40,
|
||||||
|
/// 80 MHz.
|
||||||
|
Bw80,
|
||||||
|
/// 160 MHz.
|
||||||
|
Bw160,
|
||||||
|
/// 320 MHz (Wi-Fi 7).
|
||||||
|
Bw320,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Bandwidth {
|
||||||
|
/// Construct a bandwidth from a width in MHz, rejecting unrecognised widths.
|
||||||
|
pub fn from_mhz(mhz: u16) -> Result<Self, ControlError> {
|
||||||
|
Ok(match mhz {
|
||||||
|
20 => Self::Bw20,
|
||||||
|
40 => Self::Bw40,
|
||||||
|
80 => Self::Bw80,
|
||||||
|
160 => Self::Bw160,
|
||||||
|
320 => Self::Bw320,
|
||||||
|
other => return Err(ControlError::InvalidBandwidth { mhz: other }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The width in MHz. Also the exploration-ordering key (wider = more
|
||||||
|
/// exploratory).
|
||||||
|
#[must_use]
|
||||||
|
pub fn mhz(&self) -> u16 {
|
||||||
|
match self {
|
||||||
|
Self::Bw20 => 20,
|
||||||
|
Self::Bw40 => 40,
|
||||||
|
Self::Bw80 => 80,
|
||||||
|
Self::Bw160 => 160,
|
||||||
|
Self::Bw320 => 320,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PartialOrd for Bandwidth {
|
||||||
|
fn partial_cmp(&self, other: &Self) -> Option<core::cmp::Ordering> {
|
||||||
|
Some(self.cmp(other))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Ord for Bandwidth {
|
||||||
|
fn cmp(&self, other: &Self) -> core::cmp::Ordering {
|
||||||
|
self.mhz().cmp(&other.mhz())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A validated sounding cadence: the interval between solicited soundings, in
|
||||||
|
/// milliseconds. A *shorter* interval is a faster cadence and is treated as
|
||||||
|
/// *more exploratory* (more measurements per unit time).
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||||
|
pub struct Cadence {
|
||||||
|
interval_ms: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Cadence {
|
||||||
|
/// Construct a cadence from a sounding interval, rejecting an interval
|
||||||
|
/// outside `[MIN_CADENCE_MS, MAX_CADENCE_MS]`.
|
||||||
|
pub fn from_interval_ms(interval_ms: u32) -> Result<Self, ControlError> {
|
||||||
|
if (MIN_CADENCE_MS..=MAX_CADENCE_MS).contains(&interval_ms) {
|
||||||
|
Ok(Self { interval_ms })
|
||||||
|
} else {
|
||||||
|
Err(ControlError::InvalidCadence {
|
||||||
|
interval_ms,
|
||||||
|
min: MIN_CADENCE_MS,
|
||||||
|
max: MAX_CADENCE_MS,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The sounding interval in milliseconds.
|
||||||
|
#[must_use]
|
||||||
|
pub fn interval_ms(&self) -> u32 {
|
||||||
|
self.interval_ms
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A validated subset of a distributed aperture's antenna chains. Activating
|
||||||
|
/// *more* chains widens the aperture and is treated as *more exploratory*.
|
||||||
|
///
|
||||||
|
/// The selection is bounded by the ADR-280 `CoherentSensorGroup` compatibility
|
||||||
|
/// proof in a fielded system; here it is a validated, deterministic set of
|
||||||
|
/// chain indices with no coherence claim.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||||
|
pub struct AntennaSelection {
|
||||||
|
num_chains: u8,
|
||||||
|
/// Active chain indices, sorted ascending and deduplicated.
|
||||||
|
active: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AntennaSelection {
|
||||||
|
/// Construct a validated antenna selection over an aperture of
|
||||||
|
/// `num_chains` chains, rejecting an empty selection or any index at or
|
||||||
|
/// beyond `num_chains` / [`MAX_CHAINS`]. Indices are sorted and
|
||||||
|
/// deduplicated so the selection is canonical.
|
||||||
|
pub fn new(active: impl IntoIterator<Item = u8>, num_chains: u8) -> Result<Self, ControlError> {
|
||||||
|
if num_chains == 0 || num_chains > MAX_CHAINS {
|
||||||
|
return Err(ControlError::AntennaChainOutOfRange {
|
||||||
|
index: 0,
|
||||||
|
num_chains,
|
||||||
|
max: MAX_CHAINS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let mut chains: Vec<u8> = active.into_iter().collect();
|
||||||
|
chains.sort_unstable();
|
||||||
|
chains.dedup();
|
||||||
|
if chains.is_empty() {
|
||||||
|
return Err(ControlError::EmptyAntennaSelection);
|
||||||
|
}
|
||||||
|
if let Some(&idx) = chains.iter().find(|&&i| i >= num_chains) {
|
||||||
|
return Err(ControlError::AntennaChainOutOfRange {
|
||||||
|
index: idx,
|
||||||
|
num_chains,
|
||||||
|
max: MAX_CHAINS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(Self {
|
||||||
|
num_chains,
|
||||||
|
active: chains,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The declared aperture size (total chains).
|
||||||
|
#[must_use]
|
||||||
|
pub fn num_chains(&self) -> u8 {
|
||||||
|
self.num_chains
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The active chain indices (sorted, deduplicated).
|
||||||
|
#[must_use]
|
||||||
|
pub fn active(&self) -> &[u8] {
|
||||||
|
&self.active
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The number of active chains. Also the exploration-ordering key (more
|
||||||
|
/// active chains = wider aperture = more exploratory).
|
||||||
|
#[must_use]
|
||||||
|
pub fn chain_count(&self) -> usize {
|
||||||
|
self.active.len()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A proposed measurement configuration: the controllable axes a controller
|
||||||
|
/// asks to set for the next sounding. Each axis is `None` when the deployment
|
||||||
|
/// cannot control it (it is left at the hardware default); a `Some` value is a
|
||||||
|
/// validated choice.
|
||||||
|
///
|
||||||
|
/// **This is a plan, never an emission.** Nothing here drives a radio, changes
|
||||||
|
/// pairing state, or transmits — an [`ControlAction`] is data describing what a
|
||||||
|
/// governed actuation *would* request through the ADR-280 fail-closed surface.
|
||||||
|
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct ControlAction {
|
||||||
|
/// Which channel to probe, if channel is controllable.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub channel: Option<Channel>,
|
||||||
|
/// Which channel width to probe, if bandwidth is controllable.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub bandwidth: Option<Bandwidth>,
|
||||||
|
/// How often to solicit a sounding, if cadence is controllable.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub cadence: Option<Cadence>,
|
||||||
|
/// Which antenna chains to activate, if antenna selection is controllable.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub antenna: Option<AntennaSelection>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ControlAction {
|
||||||
|
/// True when no axis is set — the action configures nothing.
|
||||||
|
#[must_use]
|
||||||
|
pub fn is_noop(&self) -> bool {
|
||||||
|
self.channel.is_none()
|
||||||
|
&& self.bandwidth.is_none()
|
||||||
|
&& self.cadence.is_none()
|
||||||
|
&& self.antenna.is_none()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The set of control values a deployment can actually set, per axis
|
||||||
|
/// (capability-gated by the ADR-320 HAL in a fielded system). An axis with no
|
||||||
|
/// values is not controllable on this deployment; an all-empty capability is
|
||||||
|
/// the ESP32-style passive fallback trigger.
|
||||||
|
///
|
||||||
|
/// Values are validated, deduplicated, and sorted into
|
||||||
|
/// *least-exploratory-first* order at construction, so the controller can map a
|
||||||
|
/// scalar exploration level onto a value deterministically.
|
||||||
|
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct ControlCapability {
|
||||||
|
/// Controllable channels (probe order preserved as supplied, deduplicated).
|
||||||
|
pub channels: Vec<Channel>,
|
||||||
|
/// Controllable bandwidths, sorted ascending (narrowest first).
|
||||||
|
pub bandwidths: Vec<Bandwidth>,
|
||||||
|
/// Controllable cadences, sorted least-exploratory-first (slowest first).
|
||||||
|
pub cadences: Vec<Cadence>,
|
||||||
|
/// Controllable antenna selections, sorted least-exploratory-first
|
||||||
|
/// (fewest chains first).
|
||||||
|
pub antennas: Vec<AntennaSelection>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ControlCapability {
|
||||||
|
/// An empty capability: nothing is controllable. The controller degrades to
|
||||||
|
/// the passive planner for any zone under this capability.
|
||||||
|
#[must_use]
|
||||||
|
pub fn none() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a validated, canonicalised capability set. Each axis is
|
||||||
|
/// deduplicated, bounded to [`MAX_AXIS_VALUES`], and sorted into
|
||||||
|
/// least-exploratory-first order so exploration mapping is deterministic.
|
||||||
|
///
|
||||||
|
/// Channels keep caller order (deduplicated) because band/number has no
|
||||||
|
/// intrinsic exploration ranking — the controller sweeps them by cycle.
|
||||||
|
pub fn new(
|
||||||
|
channels: Vec<Channel>,
|
||||||
|
mut bandwidths: Vec<Bandwidth>,
|
||||||
|
mut cadences: Vec<Cadence>,
|
||||||
|
mut antennas: Vec<AntennaSelection>,
|
||||||
|
) -> Result<Self, ControlError> {
|
||||||
|
// Dedup channels while preserving first-seen order.
|
||||||
|
let mut seen = Vec::new();
|
||||||
|
let mut channels_dedup = Vec::new();
|
||||||
|
for c in channels {
|
||||||
|
if !seen.contains(&c) {
|
||||||
|
seen.push(c);
|
||||||
|
channels_dedup.push(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
check_len(channels_dedup.len())?;
|
||||||
|
|
||||||
|
bandwidths.sort_unstable();
|
||||||
|
bandwidths.dedup();
|
||||||
|
check_len(bandwidths.len())?;
|
||||||
|
|
||||||
|
// Least exploratory first = slowest (largest interval) first.
|
||||||
|
cadences.sort_unstable_by(|a, b| b.interval_ms().cmp(&a.interval_ms()));
|
||||||
|
cadences.dedup();
|
||||||
|
check_len(cadences.len())?;
|
||||||
|
|
||||||
|
// Least exploratory first = fewest chains first; tie-break by indices.
|
||||||
|
antennas.sort_by(|a, b| {
|
||||||
|
a.chain_count()
|
||||||
|
.cmp(&b.chain_count())
|
||||||
|
.then_with(|| a.active().cmp(b.active()))
|
||||||
|
});
|
||||||
|
antennas.dedup();
|
||||||
|
check_len(antennas.len())?;
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
channels: channels_dedup,
|
||||||
|
bandwidths,
|
||||||
|
cadences,
|
||||||
|
antennas,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True when no axis has any controllable value.
|
||||||
|
#[must_use]
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.channels.is_empty()
|
||||||
|
&& self.bandwidths.is_empty()
|
||||||
|
&& self.cadences.is_empty()
|
||||||
|
&& self.antennas.is_empty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_len(len: usize) -> Result<(), ControlError> {
|
||||||
|
if len > MAX_AXIS_VALUES {
|
||||||
|
Err(ControlError::AxisTooLarge {
|
||||||
|
len,
|
||||||
|
max: MAX_AXIS_VALUES,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
404
v2/crates/ruview-active/src/lib.rs
Normal file
404
v2/crates/ruview-active/src/lib.rs
Normal file
@@ -0,0 +1,404 @@
|
|||||||
|
//! # `ruview-active` — closed-loop RF experiment control (ADR-309, ADR-300 primitive 9)
|
||||||
|
//!
|
||||||
|
//! **SYNTHETIC / L0 research-forward model scaffold (ADR-282, ADR-300 phase 3).**
|
||||||
|
//! This crate turns sensing from *RF-happens → observe* into
|
||||||
|
//! *RuView-controls-RF → observe the response → optimize the next measurement*.
|
||||||
|
//! It models the **control loop** ADR-280 deferred (ADR-280 built the governed
|
||||||
|
//! actuation surface but left information-gain-driven closed-loop control as a
|
||||||
|
//! roadmap item): read the current per-zone uncertainty and the last response,
|
||||||
|
//! and propose the controllable measurement configuration expected to reduce
|
||||||
|
//! that uncertainty most.
|
||||||
|
//!
|
||||||
|
//! It is a **simulation / planning model**, not a driver. Constructing a
|
||||||
|
//! [`ControlAction`] or a [`MeasurementPlan`] drives **no** radio, changes no
|
||||||
|
//! pairing state, and emits **no** RF — the loop *emits a plan*, and a fielded
|
||||||
|
//! caller submits every proposal through the ADR-280 fail-closed
|
||||||
|
//! admission/actuation surface. A twin predicts; it does not measure: no
|
||||||
|
//! `MEASURED`, accuracy, or traffic-reduction claim is made or implied. Every
|
||||||
|
//! exploration figure this crate produces is `SYNTHETIC` (CLAUDE.md honesty
|
||||||
|
//! discipline; ADR-309 §3).
|
||||||
|
//!
|
||||||
|
//! ## The four ADR-300 non-negotiable rules, as they bind this crate
|
||||||
|
//!
|
||||||
|
//! 1. **UNKNOWN is first-class, never an error.** A [`LastResponse::Unknown`]
|
||||||
|
//! is not zero uncertainty and not an error: the policy *widens* exploration
|
||||||
|
//! ([`ControllerConfig::unknown_widen`]) rather than committing to a narrow
|
||||||
|
//! configuration on a target it cannot currently resolve. A non-finite
|
||||||
|
//! uncertainty becomes maximal uncertainty, never a silent zero.
|
||||||
|
//! [`ClosedLoopController::step`] is total — no input panics.
|
||||||
|
//! 2. **Certificates bind cryptographically.** Out of scope here; a proposal
|
||||||
|
//! names an already-authenticated ADR-306
|
||||||
|
//! [`ZoneId`](ruview_ontology::ZoneId), and a fielded loop step is admitted
|
||||||
|
//! through the ADR-280 governed path before any actuation.
|
||||||
|
//! 3. **One canonical semantics downstream.** The loop reuses the canonical
|
||||||
|
//! [`ZoneId`](ruview_ontology::ZoneId),
|
||||||
|
//! [`EvidenceLevel`](ruview_ontology::EvidenceLevel), and
|
||||||
|
//! [`SemanticProvenance`](ruview_ontology::SemanticProvenance) rather than
|
||||||
|
//! reinventing per-crate identity/evidence shapes. It shares the *notion* of
|
||||||
|
//! expected gain with ADR-314 but defines its own [`ControlAction`]
|
||||||
|
//! vocabulary and does **not** depend on `ruview-infogain`, so the two crates
|
||||||
|
//! build in parallel.
|
||||||
|
//! 4. **Honest evidence.** Every proposal is stamped [`EvidenceLevel::L1`]
|
||||||
|
//! (heuristic/synthetic) with an explicit synthetic provenance; the
|
||||||
|
//! exploration scalar is a modelled magnitude, not a measured gain. When no
|
||||||
|
//! axis is controllable the controller returns [`PassiveReason::NoControllableAxes`]
|
||||||
|
//! rather than fabricating a gain estimate.
|
||||||
|
//!
|
||||||
|
//! ## The loop, in one call
|
||||||
|
//!
|
||||||
|
//! ```
|
||||||
|
//! use ruview_active::*;
|
||||||
|
//! use ruview_ontology::ZoneId;
|
||||||
|
//!
|
||||||
|
//! // A deployment that can vary channel width and antenna aperture.
|
||||||
|
//! let cap = ControlCapability::new(
|
||||||
|
//! vec![Channel::new(Band::Ghz5, 36).unwrap()],
|
||||||
|
//! vec![Bandwidth::Bw20, Bandwidth::Bw160],
|
||||||
|
//! vec![],
|
||||||
|
//! vec![
|
||||||
|
//! AntennaSelection::new([0], 4).unwrap(),
|
||||||
|
//! AntennaSelection::new([0, 1, 2, 3], 4).unwrap(),
|
||||||
|
//! ],
|
||||||
|
//! )
|
||||||
|
//! .unwrap();
|
||||||
|
//! let ctrl = ClosedLoopController::new(cap, ControllerConfig::default());
|
||||||
|
//!
|
||||||
|
//! // A poorly-known zone drives an exploratory (widest) measurement.
|
||||||
|
//! let uncertain = ZoneBelief::new(
|
||||||
|
//! ZoneId::new("kitchen").unwrap(),
|
||||||
|
//! Uncertainty::new(0.95),
|
||||||
|
//! LastResponse::None,
|
||||||
|
//! );
|
||||||
|
//! let decision = ctrl.step(&uncertain);
|
||||||
|
//! let p = decision.proposal().unwrap();
|
||||||
|
//! assert_eq!(p.intent, ControlIntent::Explore);
|
||||||
|
//! assert_eq!(p.action.bandwidth, Some(Bandwidth::Bw160)); // widest
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
#![forbid(unsafe_code)]
|
||||||
|
#![warn(missing_docs)]
|
||||||
|
|
||||||
|
mod control;
|
||||||
|
mod policy;
|
||||||
|
|
||||||
|
pub use control::{
|
||||||
|
AntennaSelection, Band, Bandwidth, Cadence, Channel, ControlAction, ControlCapability,
|
||||||
|
ControlError, MAX_AXIS_VALUES, MAX_CADENCE_MS, MAX_CHAINS, MIN_CADENCE_MS,
|
||||||
|
};
|
||||||
|
pub use policy::{
|
||||||
|
ClosedLoopController, ControlDecision, ControlIntent, ControlProposal, ControllerConfig,
|
||||||
|
LastResponse, MeasurementPlan, PassiveReason, Uncertainty, ZoneBelief, MODEL_VERSION,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use ruview_ontology::{EvidenceLevel, ZoneId};
|
||||||
|
|
||||||
|
fn zid(s: &str) -> ZoneId {
|
||||||
|
ZoneId::new(s).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A deployment controlling channel (sweep set), bandwidth, cadence, and
|
||||||
|
/// antenna aperture — a full controllable surface for the loop tests.
|
||||||
|
fn full_capability() -> ControlCapability {
|
||||||
|
ControlCapability::new(
|
||||||
|
vec![
|
||||||
|
Channel::new(Band::Ghz5, 36).unwrap(),
|
||||||
|
Channel::new(Band::Ghz5, 40).unwrap(),
|
||||||
|
Channel::new(Band::Ghz5, 44).unwrap(),
|
||||||
|
],
|
||||||
|
vec![Bandwidth::Bw20, Bandwidth::Bw80, Bandwidth::Bw160],
|
||||||
|
vec![
|
||||||
|
Cadence::from_interval_ms(1000).unwrap(), // slow
|
||||||
|
Cadence::from_interval_ms(100).unwrap(), // fast
|
||||||
|
],
|
||||||
|
vec![
|
||||||
|
AntennaSelection::new([0], 4).unwrap(),
|
||||||
|
AntennaSelection::new([0, 1], 4).unwrap(),
|
||||||
|
AntennaSelection::new([0, 1, 2, 3], 4).unwrap(),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn controller() -> ClosedLoopController {
|
||||||
|
ClosedLoopController::new(full_capability(), ControllerConfig::default())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ADR-309 §2: a high-uncertainty zone drives an exploratory control action
|
||||||
|
// — widest bandwidth, fastest cadence, widest aperture, Explore intent.
|
||||||
|
#[test]
|
||||||
|
fn high_uncertainty_drives_exploratory_action() {
|
||||||
|
let belief = ZoneBelief::new(zid("kitchen"), Uncertainty::new(1.0), LastResponse::None);
|
||||||
|
let p = controller().step(&belief).proposal().cloned().unwrap();
|
||||||
|
|
||||||
|
assert_eq!(p.intent, ControlIntent::Explore);
|
||||||
|
assert_eq!(p.action.bandwidth, Some(Bandwidth::Bw160)); // widest available
|
||||||
|
assert_eq!(p.action.cadence.unwrap().interval_ms(), 100); // fastest
|
||||||
|
assert_eq!(p.action.antenna.as_ref().unwrap().chain_count(), 4); // widest aperture
|
||||||
|
assert_eq!(p.evidence_level, EvidenceLevel::L1); // honest synthetic label
|
||||||
|
assert_eq!(p.provenance.model_version, MODEL_VERSION);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ADR-309 validation: convergence (falling uncertainty) reduces exploration
|
||||||
|
// — the proposal narrows and the intent flips to Exploit.
|
||||||
|
#[test]
|
||||||
|
fn convergence_reduces_exploration() {
|
||||||
|
let ctrl = controller();
|
||||||
|
let high = ZoneBelief::new(zid("z"), Uncertainty::new(0.95), LastResponse::None);
|
||||||
|
let low = ZoneBelief::new(zid("z"), Uncertainty::new(0.05), LastResponse::None);
|
||||||
|
|
||||||
|
let ph = ctrl.step(&high).proposal().cloned().unwrap();
|
||||||
|
let pl = ctrl.step(&low).proposal().cloned().unwrap();
|
||||||
|
|
||||||
|
// Exploration strictly falls as the zone converges.
|
||||||
|
assert!(pl.exploration < ph.exploration);
|
||||||
|
assert_eq!(ph.intent, ControlIntent::Explore);
|
||||||
|
assert_eq!(pl.intent, ControlIntent::Exploit);
|
||||||
|
|
||||||
|
// The converged proposal is narrower/slower on every graded axis.
|
||||||
|
assert!(pl.action.bandwidth.unwrap().mhz() < ph.action.bandwidth.unwrap().mhz());
|
||||||
|
assert!(
|
||||||
|
pl.action.cadence.unwrap().interval_ms() > ph.action.cadence.unwrap().interval_ms()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
pl.action.antenna.as_ref().unwrap().chain_count()
|
||||||
|
< ph.action.antenna.as_ref().unwrap().chain_count()
|
||||||
|
);
|
||||||
|
assert_eq!(pl.action.bandwidth, Some(Bandwidth::Bw20)); // narrowest
|
||||||
|
}
|
||||||
|
|
||||||
|
// ADR-300 rule 1: an UNKNOWN last response widens exploration relative to
|
||||||
|
// the same uncertainty with an observed response.
|
||||||
|
#[test]
|
||||||
|
fn unknown_last_response_widens_exploration() {
|
||||||
|
let ctrl = controller();
|
||||||
|
let u = Uncertainty::new(0.4); // below the 0.5 explore threshold on its own
|
||||||
|
|
||||||
|
let observed = ZoneBelief::new(
|
||||||
|
zid("z"),
|
||||||
|
u,
|
||||||
|
LastResponse::Observed {
|
||||||
|
evidence_level: EvidenceLevel::L2,
|
||||||
|
residual: Uncertainty::new(0.4),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let unknown = ZoneBelief::new(zid("z"), u, LastResponse::Unknown);
|
||||||
|
|
||||||
|
let po = ctrl.step(&observed).proposal().cloned().unwrap();
|
||||||
|
let pu = ctrl.step(&unknown).proposal().cloned().unwrap();
|
||||||
|
|
||||||
|
// UNKNOWN pushes exploration strictly higher...
|
||||||
|
assert!(pu.exploration > po.exploration);
|
||||||
|
// ...enough to cross from Exploit into Explore (0.4 + 0.3 = 0.7 >= 0.5).
|
||||||
|
assert_eq!(po.intent, ControlIntent::Exploit);
|
||||||
|
assert_eq!(pu.intent, ControlIntent::Explore);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ADR-309 §2 degradation: an empty controllable set (ESP32-only) falls back
|
||||||
|
// to the passive planner with no error and no fabricated gain.
|
||||||
|
#[test]
|
||||||
|
fn empty_capability_degrades_to_passive() {
|
||||||
|
let ctrl = ClosedLoopController::new(ControlCapability::none(), ControllerConfig::default());
|
||||||
|
let belief = ZoneBelief::new(zid("z"), Uncertainty::new(1.0), LastResponse::None);
|
||||||
|
match ctrl.step(&belief) {
|
||||||
|
ControlDecision::Passive { zone, reason } => {
|
||||||
|
assert_eq!(zone, zid("z"));
|
||||||
|
assert_eq!(reason, PassiveReason::NoControllableAxes);
|
||||||
|
}
|
||||||
|
other => panic!("expected passive fallback, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A cadence-only deployment (ESP32 that can vary sounding rate) still closes
|
||||||
|
// the loop on its one controllable axis; the others stay uncontrolled.
|
||||||
|
#[test]
|
||||||
|
fn cadence_only_capability_controls_only_cadence() {
|
||||||
|
let cap = ControlCapability::new(
|
||||||
|
vec![],
|
||||||
|
vec![],
|
||||||
|
vec![
|
||||||
|
Cadence::from_interval_ms(2000).unwrap(),
|
||||||
|
Cadence::from_interval_ms(50).unwrap(),
|
||||||
|
],
|
||||||
|
vec![],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let ctrl = ClosedLoopController::new(cap, ControllerConfig::default());
|
||||||
|
let belief = ZoneBelief::new(zid("z"), Uncertainty::new(1.0), LastResponse::None);
|
||||||
|
let p = ctrl.step(&belief).proposal().cloned().unwrap();
|
||||||
|
|
||||||
|
assert!(p.action.channel.is_none());
|
||||||
|
assert!(p.action.bandwidth.is_none());
|
||||||
|
assert!(p.action.antenna.is_none());
|
||||||
|
assert_eq!(p.action.cadence.unwrap().interval_ms(), 50); // fastest, exploring
|
||||||
|
assert!(!p.action.is_noop());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Control ranges are validated: an invalid channel/bandwidth/cadence/antenna
|
||||||
|
// is rejected at the boundary and can never enter an action.
|
||||||
|
#[test]
|
||||||
|
fn invalid_control_values_are_rejected() {
|
||||||
|
// 2.4 GHz has no channel 15.
|
||||||
|
assert!(matches!(
|
||||||
|
Channel::new(Band::Ghz24, 15),
|
||||||
|
Err(ControlError::InvalidChannel { .. })
|
||||||
|
));
|
||||||
|
// 5 GHz channel 37 is not a standard channel.
|
||||||
|
assert!(matches!(
|
||||||
|
Channel::new(Band::Ghz5, 37),
|
||||||
|
Err(ControlError::InvalidChannel { .. })
|
||||||
|
));
|
||||||
|
// A valid 5 GHz channel is accepted.
|
||||||
|
assert!(Channel::new(Band::Ghz5, 36).is_ok());
|
||||||
|
|
||||||
|
// 33 MHz is not a recognised channel width.
|
||||||
|
assert!(matches!(
|
||||||
|
Bandwidth::from_mhz(33),
|
||||||
|
Err(ControlError::InvalidBandwidth { mhz: 33 })
|
||||||
|
));
|
||||||
|
assert_eq!(Bandwidth::from_mhz(80).unwrap(), Bandwidth::Bw80);
|
||||||
|
|
||||||
|
// Cadence outside the modelled range is rejected on both ends.
|
||||||
|
assert!(matches!(
|
||||||
|
Cadence::from_interval_ms(0),
|
||||||
|
Err(ControlError::InvalidCadence { .. })
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
Cadence::from_interval_ms(MAX_CADENCE_MS + 1),
|
||||||
|
Err(ControlError::InvalidCadence { .. })
|
||||||
|
));
|
||||||
|
|
||||||
|
// Antenna selection: empty and out-of-range indices are rejected.
|
||||||
|
assert!(matches!(
|
||||||
|
AntennaSelection::new(Vec::<u8>::new(), 4),
|
||||||
|
Err(ControlError::EmptyAntennaSelection)
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
AntennaSelection::new([4], 4),
|
||||||
|
Err(ControlError::AntennaChainOutOfRange { index: 4, .. })
|
||||||
|
));
|
||||||
|
// A zero-chain aperture is rejected.
|
||||||
|
assert!(matches!(
|
||||||
|
AntennaSelection::new([0], 0),
|
||||||
|
Err(ControlError::AntennaChainOutOfRange { .. })
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The capability set bounds allocation: an axis longer than MAX_AXIS_VALUES
|
||||||
|
// is rejected rather than accepted unbounded.
|
||||||
|
#[test]
|
||||||
|
fn oversized_capability_axis_is_rejected() {
|
||||||
|
let cadences: Vec<Cadence> = (1..=(MAX_AXIS_VALUES as u32 + 1))
|
||||||
|
.map(|ms| Cadence::from_interval_ms(ms).unwrap())
|
||||||
|
.collect();
|
||||||
|
assert!(matches!(
|
||||||
|
ControlCapability::new(vec![], vec![], cadences, vec![]),
|
||||||
|
Err(ControlError::AxisTooLarge { .. })
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-finite uncertainty is treated as maximal uncertainty, never a silent
|
||||||
|
// zero (ADR-300 rule 1), and never panics.
|
||||||
|
#[test]
|
||||||
|
fn non_finite_uncertainty_is_maximal_not_zero() {
|
||||||
|
assert_eq!(Uncertainty::new(f64::NAN).value(), 1.0);
|
||||||
|
assert_eq!(Uncertainty::new(f64::INFINITY).value(), 1.0);
|
||||||
|
assert_eq!(Uncertainty::new(-5.0).value(), 0.0);
|
||||||
|
assert_eq!(Uncertainty::new(2.0).value(), 1.0);
|
||||||
|
|
||||||
|
let belief = ZoneBelief::new(zid("z"), Uncertainty::new(f64::NAN), LastResponse::None);
|
||||||
|
let p = controller().step(&belief).proposal().cloned().unwrap();
|
||||||
|
assert_eq!(p.intent, ControlIntent::Explore); // maximal → explore
|
||||||
|
}
|
||||||
|
|
||||||
|
// Channel sweep: exploring across cycles rotates deterministically through
|
||||||
|
// the controllable channels; exploiting anchors to the first channel.
|
||||||
|
#[test]
|
||||||
|
fn channel_sweeps_when_exploring_and_anchors_when_exploiting() {
|
||||||
|
let ctrl = controller();
|
||||||
|
let mk = |cycle: u64| {
|
||||||
|
ZoneBelief::new(zid("z"), Uncertainty::new(1.0), LastResponse::None).with_cycle(cycle)
|
||||||
|
};
|
||||||
|
let c0 = ctrl.step(&mk(0)).proposal().unwrap().action.channel.unwrap();
|
||||||
|
let c1 = ctrl.step(&mk(1)).proposal().unwrap().action.channel.unwrap();
|
||||||
|
let c2 = ctrl.step(&mk(2)).proposal().unwrap().action.channel.unwrap();
|
||||||
|
let c3 = ctrl.step(&mk(3)).proposal().unwrap().action.channel.unwrap();
|
||||||
|
assert_eq!(c0.number(), 36);
|
||||||
|
assert_eq!(c1.number(), 40);
|
||||||
|
assert_eq!(c2.number(), 44);
|
||||||
|
assert_eq!(c3.number(), 36); // wraps deterministically
|
||||||
|
|
||||||
|
// Exploiting (low uncertainty) anchors to the first channel regardless
|
||||||
|
// of cycle.
|
||||||
|
let exploit = ZoneBelief::new(zid("z"), Uncertainty::new(0.0), LastResponse::None)
|
||||||
|
.with_cycle(2);
|
||||||
|
let ce = ctrl.step(&exploit).proposal().unwrap().action.channel.unwrap();
|
||||||
|
assert_eq!(ce.number(), 36);
|
||||||
|
}
|
||||||
|
|
||||||
|
// plan() orders proposals most-exploratory-first and separates passive
|
||||||
|
// zones; the ordering is a deterministic function of the inputs.
|
||||||
|
#[test]
|
||||||
|
fn plan_orders_by_exploration_and_collects_passive() {
|
||||||
|
let ctrl = controller();
|
||||||
|
let beliefs = vec![
|
||||||
|
ZoneBelief::new(zid("low"), Uncertainty::new(0.1), LastResponse::None),
|
||||||
|
ZoneBelief::new(zid("high"), Uncertainty::new(0.9), LastResponse::None),
|
||||||
|
ZoneBelief::new(zid("mid"), Uncertainty::new(0.5), LastResponse::None),
|
||||||
|
];
|
||||||
|
let plan = ctrl.plan(&beliefs);
|
||||||
|
let order: Vec<&str> = plan.proposals.iter().map(|p| p.zone.as_str()).collect();
|
||||||
|
assert_eq!(order, vec!["high", "mid", "low"]);
|
||||||
|
assert!(plan.passive.is_empty());
|
||||||
|
|
||||||
|
// With an empty capability every zone degrades to passive.
|
||||||
|
let passive_ctrl =
|
||||||
|
ClosedLoopController::new(ControlCapability::none(), ControllerConfig::default());
|
||||||
|
let plan2 = passive_ctrl.plan(&beliefs);
|
||||||
|
assert!(plan2.proposals.is_empty());
|
||||||
|
assert_eq!(plan2.passive, vec![zid("high"), zid("low"), zid("mid")]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determinism: identical inputs yield identical decisions and plans.
|
||||||
|
#[test]
|
||||||
|
fn controller_is_deterministic() {
|
||||||
|
let ctrl = controller();
|
||||||
|
let beliefs = vec![
|
||||||
|
ZoneBelief::new(zid("a"), Uncertainty::new(0.7), LastResponse::Unknown).with_cycle(3),
|
||||||
|
ZoneBelief::new(
|
||||||
|
zid("b"),
|
||||||
|
Uncertainty::new(0.2),
|
||||||
|
LastResponse::Observed {
|
||||||
|
evidence_level: EvidenceLevel::L3,
|
||||||
|
residual: Uncertainty::new(0.2),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
];
|
||||||
|
assert_eq!(ctrl.plan(&beliefs), ctrl.plan(&beliefs));
|
||||||
|
assert_eq!(ctrl.step(&beliefs[0]), ctrl.step(&beliefs[0]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The whole plan round-trips losslessly through serde (canonical output).
|
||||||
|
#[test]
|
||||||
|
fn plan_serde_round_trips() {
|
||||||
|
let ctrl = controller();
|
||||||
|
let beliefs = vec![
|
||||||
|
ZoneBelief::new(zid("a"), Uncertainty::new(0.9), LastResponse::None),
|
||||||
|
ZoneBelief::new(zid("b"), Uncertainty::new(0.1), LastResponse::Unknown),
|
||||||
|
];
|
||||||
|
let plan = ctrl.plan(&beliefs);
|
||||||
|
let json = serde_json::to_string(&plan).unwrap();
|
||||||
|
let back: MeasurementPlan = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(plan, back);
|
||||||
|
}
|
||||||
|
|
||||||
|
// An empty belief set yields an empty plan, no panic.
|
||||||
|
#[test]
|
||||||
|
fn empty_beliefs_yield_empty_plan() {
|
||||||
|
let plan = controller().plan(&[]);
|
||||||
|
assert!(plan.is_empty());
|
||||||
|
assert_eq!(plan, MeasurementPlan::empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
377
v2/crates/ruview-active/src/policy.rs
Normal file
377
v2/crates/ruview-active/src/policy.rs
Normal file
@@ -0,0 +1,377 @@
|
|||||||
|
//! The closed-loop experiment controller (ADR-309 §2).
|
||||||
|
//!
|
||||||
|
//! **SYNTHETIC / L0 model scaffold.** This is the *loop* ADR-280 deferred: it
|
||||||
|
//! reads a modelled per-zone uncertainty and the last modelled response, and
|
||||||
|
//! proposes the next controllable measurement configuration expected to reduce
|
||||||
|
//! that uncertainty most. It is an information-driven **planning** policy — it
|
||||||
|
//! shares the *notion* of expected gain with ADR-314 but defines its own
|
||||||
|
//! control vocabulary and takes **no** dependency on `ruview-infogain`, so the
|
||||||
|
//! two crates build in parallel.
|
||||||
|
//!
|
||||||
|
//! No number here is `MEASURED`. Every exploration level is a modelled
|
||||||
|
//! magnitude, not a measured information gain (CLAUDE.md honesty discipline).
|
||||||
|
//! The controller **emits a plan; it never emits RF** and never bypasses the
|
||||||
|
//! ADR-280 governed admission/actuation surface — a fielded caller submits each
|
||||||
|
//! proposal through that fail-closed path.
|
||||||
|
//!
|
||||||
|
//! ## First-class UNKNOWN (ADR-300 rule 1)
|
||||||
|
//!
|
||||||
|
//! The last response is [`LastResponse::Unknown`] whenever the previous
|
||||||
|
//! solicited measurement returned nothing interpretable. UNKNOWN is **not**
|
||||||
|
//! zero uncertainty and **not** an error: the policy *widens* exploration by
|
||||||
|
//! [`ControllerConfig::unknown_widen`] rather than committing to a narrow,
|
||||||
|
//! exploitative configuration on a target it cannot currently resolve.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use ruview_ontology::{EvidenceLevel, SemanticProvenance, ZoneId};
|
||||||
|
|
||||||
|
use crate::control::{ControlAction, ControlCapability};
|
||||||
|
|
||||||
|
/// A modelled uncertainty scalar in `[0, 1]`: `0.0` fully resolved, `1.0`
|
||||||
|
/// maximally uncertain. Construction clamps to range and maps a non-finite
|
||||||
|
/// input to maximal uncertainty (an unusable estimate is treated as "know
|
||||||
|
/// nothing", never silently as zero).
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, PartialOrd, Serialize, Deserialize)]
|
||||||
|
#[serde(transparent)]
|
||||||
|
pub struct Uncertainty(f64);
|
||||||
|
|
||||||
|
impl Uncertainty {
|
||||||
|
/// Clamp an arbitrary value into `[0, 1]`; a non-finite value becomes
|
||||||
|
/// maximal uncertainty (`1.0`).
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(value: f64) -> Self {
|
||||||
|
if value.is_finite() {
|
||||||
|
Self(value.clamp(0.0, 1.0))
|
||||||
|
} else {
|
||||||
|
Self(1.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The clamped scalar value.
|
||||||
|
#[must_use]
|
||||||
|
pub fn value(self) -> f64 {
|
||||||
|
self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The outcome of the previous solicited measurement for a zone.
|
||||||
|
///
|
||||||
|
/// This reuses the canonical [`EvidenceLevel`] vocabulary rather than a
|
||||||
|
/// per-crate grade (ADR-300 rule 3). A fielded caller derives it from the
|
||||||
|
/// ADR-306 [`Observation`](ruview_ontology::Observation) the sounding produced.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum LastResponse {
|
||||||
|
/// An interpretable response arrived at the given evidence level, leaving a
|
||||||
|
/// modelled residual uncertainty.
|
||||||
|
Observed {
|
||||||
|
/// The canonical evidence level of the response.
|
||||||
|
evidence_level: EvidenceLevel,
|
||||||
|
/// Modelled residual uncertainty left by the response.
|
||||||
|
residual: Uncertainty,
|
||||||
|
},
|
||||||
|
/// The last solicited measurement returned nothing interpretable — a
|
||||||
|
/// first-class UNKNOWN, not an error and not zero uncertainty.
|
||||||
|
Unknown,
|
||||||
|
/// No measurement has been solicited yet (loop start).
|
||||||
|
None,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The modelled belief about a single controllable target zone, and the input
|
||||||
|
/// to one closed-loop [`ClosedLoopController::step`].
|
||||||
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct ZoneBelief {
|
||||||
|
/// The target zone (canonical ontology id, ADR-306).
|
||||||
|
pub zone: ZoneId,
|
||||||
|
/// Current modelled uncertainty about the zone.
|
||||||
|
pub uncertainty: Uncertainty,
|
||||||
|
/// The outcome of the previous solicited measurement.
|
||||||
|
pub last_response: LastResponse,
|
||||||
|
/// A deterministic loop counter used only to sweep channels across cycles.
|
||||||
|
/// Injected by the caller — never sampled from a clock (ADR-300 §rules).
|
||||||
|
#[serde(default)]
|
||||||
|
pub cycle: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ZoneBelief {
|
||||||
|
/// Construct a belief. `cycle` defaults to `0`.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(zone: ZoneId, uncertainty: Uncertainty, last_response: LastResponse) -> Self {
|
||||||
|
Self {
|
||||||
|
zone,
|
||||||
|
uncertainty,
|
||||||
|
last_response,
|
||||||
|
cycle: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builder-style setter for the deterministic sweep cycle.
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_cycle(mut self, cycle: u64) -> Self {
|
||||||
|
self.cycle = cycle;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a proposal is exploratory (widen to resolve a poorly-known zone) or
|
||||||
|
/// exploitative (narrow, concentrate on a well-known zone).
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum ControlIntent {
|
||||||
|
/// Widen the measurement to resolve high uncertainty.
|
||||||
|
Explore,
|
||||||
|
/// Narrow the measurement to exploit an already-resolved zone.
|
||||||
|
Exploit,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Why the controller could not propose a controllable action and fell back to
|
||||||
|
/// the passive planner (ADR-309 §2 degradation).
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum PassiveReason {
|
||||||
|
/// The deployment exposes no controllable axis (e.g. ESP32-only). The
|
||||||
|
/// controller defers to the ADR-280 staleness planner rather than
|
||||||
|
/// fabricating a gain estimate.
|
||||||
|
NoControllableAxes,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A proposed governed measurement for one zone.
|
||||||
|
///
|
||||||
|
/// The `exploration` scalar is a **SYNTHETIC** modelled magnitude, never a
|
||||||
|
/// measured information gain, and the proposal carries L1 (heuristic/synthetic)
|
||||||
|
/// evidence with an explicit provenance so no projection can silently upgrade
|
||||||
|
/// it.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct ControlProposal {
|
||||||
|
/// The target zone.
|
||||||
|
pub zone: ZoneId,
|
||||||
|
/// The controllable configuration to request (a plan, not an emission).
|
||||||
|
pub action: ControlAction,
|
||||||
|
/// Explore vs exploit.
|
||||||
|
pub intent: ControlIntent,
|
||||||
|
/// Modelled exploration level in `[0, 1]` (SYNTHETIC; not a measurement).
|
||||||
|
pub exploration: f64,
|
||||||
|
/// Honest evidence label for the proposal — always L1 (synthetic model).
|
||||||
|
pub evidence_level: EvidenceLevel,
|
||||||
|
/// Provenance tagging the proposal as a synthetic model output.
|
||||||
|
pub provenance: SemanticProvenance,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The controller's decision for one zone: either a governed proposal or a
|
||||||
|
/// passive fallback.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum ControlDecision {
|
||||||
|
/// Request a controllable measurement.
|
||||||
|
Actuate(ControlProposal),
|
||||||
|
/// No controllable axis — defer to the passive planner.
|
||||||
|
Passive {
|
||||||
|
/// The zone that could not be actively controlled.
|
||||||
|
zone: ZoneId,
|
||||||
|
/// Why the fallback occurred.
|
||||||
|
reason: PassiveReason,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ControlDecision {
|
||||||
|
/// The proposal, if this decision is an actuation.
|
||||||
|
#[must_use]
|
||||||
|
pub fn proposal(&self) -> Option<&ControlProposal> {
|
||||||
|
match self {
|
||||||
|
Self::Actuate(p) => Some(p),
|
||||||
|
Self::Passive { .. } => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A full measurement plan over several zones, ordered most-uncertain-first.
|
||||||
|
///
|
||||||
|
/// It is a pure planning artifact: it starts no sounding and touches no
|
||||||
|
/// hardware. Zones with no controllable axis are recorded in
|
||||||
|
/// [`MeasurementPlan::passive`] so the caller knows to route them to the
|
||||||
|
/// staleness planner instead.
|
||||||
|
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct MeasurementPlan {
|
||||||
|
/// Governed proposals, ordered by descending exploration then by zone id.
|
||||||
|
pub proposals: Vec<ControlProposal>,
|
||||||
|
/// Zones that degraded to the passive planner.
|
||||||
|
pub passive: Vec<ZoneId>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MeasurementPlan {
|
||||||
|
/// An empty plan.
|
||||||
|
#[must_use]
|
||||||
|
pub fn empty() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True when the plan contains neither a proposal nor a passive zone.
|
||||||
|
#[must_use]
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.proposals.is_empty() && self.passive.is_empty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configuration for the closed-loop controller. All knobs are deployment
|
||||||
|
/// choices; there is no wall clock and no randomness.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct ControllerConfig {
|
||||||
|
/// Exploration level at or above which a proposal is [`ControlIntent::Explore`]
|
||||||
|
/// (below it, [`ControlIntent::Exploit`]). In `[0, 1]`.
|
||||||
|
pub explore_threshold: f64,
|
||||||
|
/// Additive widening applied to the exploration level when the last
|
||||||
|
/// response was [`LastResponse::Unknown`]. Bounded into `[0, 1]` after
|
||||||
|
/// application (ADR-300 rule 1: UNKNOWN widens rather than commits).
|
||||||
|
pub unknown_widen: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ControllerConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
explore_threshold: 0.5,
|
||||||
|
unknown_widen: 0.3,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The synthetic model version stamped onto every proposal's provenance.
|
||||||
|
pub const MODEL_VERSION: &str = "ruview-active@synthetic-l0";
|
||||||
|
|
||||||
|
/// The closed-loop RF experiment controller (ADR-309).
|
||||||
|
///
|
||||||
|
/// Holds the controllable [`ControlCapability`] of the deployment and the
|
||||||
|
/// policy [`ControllerConfig`]. [`ClosedLoopController::step`] is a total,
|
||||||
|
/// deterministic function of its inputs: identical inputs always yield an
|
||||||
|
/// identical decision, and no input panics.
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct ClosedLoopController {
|
||||||
|
capability: ControlCapability,
|
||||||
|
config: ControllerConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ClosedLoopController {
|
||||||
|
/// Build a controller over a deployment's controllable capability set.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(capability: ControlCapability, config: ControllerConfig) -> Self {
|
||||||
|
Self { capability, config }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The controllable capability set.
|
||||||
|
#[must_use]
|
||||||
|
pub fn capability(&self) -> &ControlCapability {
|
||||||
|
&self.capability
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One closed-loop step for a single zone: read the belief, compute the
|
||||||
|
/// modelled exploration level, and propose the next controllable
|
||||||
|
/// configuration — or fall back to the passive planner when nothing is
|
||||||
|
/// controllable.
|
||||||
|
#[must_use]
|
||||||
|
pub fn step(&self, belief: &ZoneBelief) -> ControlDecision {
|
||||||
|
if self.capability.is_empty() {
|
||||||
|
return ControlDecision::Passive {
|
||||||
|
zone: belief.zone.clone(),
|
||||||
|
reason: PassiveReason::NoControllableAxes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let exploration = self.exploration_level(belief);
|
||||||
|
let explore = exploration >= self.config.explore_threshold;
|
||||||
|
let intent = if explore {
|
||||||
|
ControlIntent::Explore
|
||||||
|
} else {
|
||||||
|
ControlIntent::Exploit
|
||||||
|
};
|
||||||
|
|
||||||
|
let action = self.select_action(belief, exploration, explore);
|
||||||
|
|
||||||
|
ControlDecision::Actuate(ControlProposal {
|
||||||
|
zone: belief.zone.clone(),
|
||||||
|
action,
|
||||||
|
intent,
|
||||||
|
exploration,
|
||||||
|
evidence_level: EvidenceLevel::L1,
|
||||||
|
provenance: SemanticProvenance::declared(MODEL_VERSION),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Plan across several zones. Each zone is stepped; proposals are ordered
|
||||||
|
/// most-exploratory-first (tie-break by zone id) so the scarcest budget is
|
||||||
|
/// spent where uncertainty is highest, and passive zones are collected
|
||||||
|
/// separately.
|
||||||
|
#[must_use]
|
||||||
|
pub fn plan(&self, beliefs: &[ZoneBelief]) -> MeasurementPlan {
|
||||||
|
let mut proposals = Vec::new();
|
||||||
|
let mut passive = Vec::new();
|
||||||
|
for belief in beliefs {
|
||||||
|
match self.step(belief) {
|
||||||
|
ControlDecision::Actuate(p) => proposals.push(p),
|
||||||
|
ControlDecision::Passive { zone, .. } => passive.push(zone),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Deterministic ordering: descending exploration, then ascending zone id.
|
||||||
|
proposals.sort_by(|a, b| {
|
||||||
|
b.exploration
|
||||||
|
.partial_cmp(&a.exploration)
|
||||||
|
.unwrap_or(core::cmp::Ordering::Equal)
|
||||||
|
.then_with(|| a.zone.as_str().cmp(b.zone.as_str()))
|
||||||
|
});
|
||||||
|
passive.sort();
|
||||||
|
MeasurementPlan { proposals, passive }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The modelled exploration level for a belief: driven by current
|
||||||
|
/// uncertainty, widened when the last response was UNKNOWN.
|
||||||
|
fn exploration_level(&self, belief: &ZoneBelief) -> f64 {
|
||||||
|
let base = belief.uncertainty.value();
|
||||||
|
let e = match &belief.last_response {
|
||||||
|
// UNKNOWN response: widen exploration rather than commit.
|
||||||
|
LastResponse::Unknown => base + self.config.unknown_widen.max(0.0),
|
||||||
|
LastResponse::Observed { .. } | LastResponse::None => base,
|
||||||
|
};
|
||||||
|
e.clamp(0.0, 1.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map the exploration level onto a controllable action across the axes the
|
||||||
|
/// deployment exposes. Uncontrollable axes stay `None`.
|
||||||
|
fn select_action(&self, belief: &ZoneBelief, exploration: f64, explore: bool) -> ControlAction {
|
||||||
|
let cap = &self.capability;
|
||||||
|
|
||||||
|
// Channel: sweep across cycles when exploring; anchor to the first
|
||||||
|
// channel when exploiting. Categorical axis, no exploration grading.
|
||||||
|
let channel = if cap.channels.is_empty() {
|
||||||
|
None
|
||||||
|
} else if explore {
|
||||||
|
let idx = (belief.cycle as usize) % cap.channels.len();
|
||||||
|
Some(cap.channels[idx])
|
||||||
|
} else {
|
||||||
|
Some(cap.channels[0])
|
||||||
|
};
|
||||||
|
|
||||||
|
// Graded axes: capability vectors are sorted least-exploratory-first,
|
||||||
|
// so a higher exploration level selects a wider / faster value.
|
||||||
|
let bandwidth = graded_pick(&cap.bandwidths, exploration).copied();
|
||||||
|
let cadence = graded_pick(&cap.cadences, exploration).copied();
|
||||||
|
let antenna = graded_pick(&cap.antennas, exploration).cloned();
|
||||||
|
|
||||||
|
ControlAction {
|
||||||
|
channel,
|
||||||
|
bandwidth,
|
||||||
|
cadence,
|
||||||
|
antenna,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pick from a least-exploratory-first slice by mapping an exploration level in
|
||||||
|
/// `[0, 1]` onto an index. Returns `None` for an empty slice.
|
||||||
|
fn graded_pick<T>(values: &[T], exploration: f64) -> Option<&T> {
|
||||||
|
if values.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let e = exploration.clamp(0.0, 1.0);
|
||||||
|
let last = values.len() - 1;
|
||||||
|
let idx = (e * last as f64).round() as usize;
|
||||||
|
values.get(idx.min(last))
|
||||||
|
}
|
||||||
16
v2/crates/ruview-attest/Cargo.toml
Normal file
16
v2/crates/ruview-attest/Cargo.toml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
[package]
|
||||||
|
name = "ruview-attest"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
authors.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
thiserror.workspace = true
|
||||||
|
serde = { workspace = true, features = ["derive"] }
|
||||||
|
blake3 = { version = "1.5", default-features = false }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
serde_json.workspace = true
|
||||||
705
v2/crates/ruview-attest/src/lib.rs
Normal file
705
v2/crates/ruview-attest/src/lib.rs
Normal file
@@ -0,0 +1,705 @@
|
|||||||
|
//! `ruview-attest` — authenticated sensor identity and RF chain of custody.
|
||||||
|
//!
|
||||||
|
//! This crate implements **ADR-305** (authenticated sensor identity), phase 1 of
|
||||||
|
//! the ADR-300 perception substrate. It models the chain of custody link
|
||||||
|
//! `device → signed measurement → sequence → timestamp → payload hash →
|
||||||
|
//! calibration`, verified at the ingest boundary.
|
||||||
|
//!
|
||||||
|
//! ## Relationship to sibling ADRs
|
||||||
|
//!
|
||||||
|
//! - **ADR-296** shipped step one — a loopback-default UDP bind and an optional
|
||||||
|
//! source IP/CIDR allowlist — and explicitly deferred "per-device provisioned
|
||||||
|
//! keys, MAC/AEAD, device identifiers, monotonic sequence numbers, freshness
|
||||||
|
//! window, and replay rejection." **This crate is that step two.** An IP
|
||||||
|
//! allowlist does not stop on-subnet spoofing; a cryptographic device
|
||||||
|
//! identity bound into each measurement does.
|
||||||
|
//! - **ADR-319** (witness chain) consumes the [`VerifiedMeasurement`] lineage
|
||||||
|
//! produced here and serializes it for offline re-verification.
|
||||||
|
//!
|
||||||
|
//! ## Signer / Verifier abstraction and the SYNTHETIC reference
|
||||||
|
//!
|
||||||
|
//! Signing is expressed through the [`Signer`] and [`Verifier`] traits so a
|
||||||
|
//! production **Ed25519** asymmetric signer is a drop-in: implement the two
|
||||||
|
//! traits over a real keypair and the envelope, sequence, freshness, and tamper
|
||||||
|
//! logic here are unchanged.
|
||||||
|
//!
|
||||||
|
//! The bundled reference is [`Blake3MacSigner`], a keyed-BLAKE3 MAC. It is a
|
||||||
|
//! symmetric MAC, **not** an asymmetric signature: the verifier holds the same
|
||||||
|
//! secret the signer does, so it demonstrates the end-to-end custody logic but
|
||||||
|
//! confers no non-repudiation and no public-key trust boundary. Every accuracy
|
||||||
|
//! or spoof-resistance guarantee obtained with this reference signer is
|
||||||
|
//! **SYNTHETIC-grade** (CLAUDE.md evidence rule): a passing test suite exercises
|
||||||
|
//! the logic, never a fielded device. A deployment-grade claim requires an
|
||||||
|
//! Ed25519 signer plus real-silicon evidence.
|
||||||
|
|
||||||
|
#![forbid(unsafe_code)]
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
/// Maximum accepted byte length of a [`DeviceId`]. Bounds allocation at the
|
||||||
|
/// untrusted ingest boundary.
|
||||||
|
pub const MAX_DEVICE_ID_LEN: usize = 128;
|
||||||
|
|
||||||
|
/// Maximum accepted byte length of a [`CalibrationRef`].
|
||||||
|
pub const MAX_CALIBRATION_REF_LEN: usize = 128;
|
||||||
|
|
||||||
|
/// Width, in bytes, of a payload hash and of the reference MAC tag.
|
||||||
|
pub const TAG_LEN: usize = 32;
|
||||||
|
|
||||||
|
/// Domain-separation prefix mixed into the canonical signing bytes so a tag
|
||||||
|
/// produced here can never be confused with a hash produced for another purpose.
|
||||||
|
const DOMAIN: &[u8] = b"ruview-attest/v1\x00signed-measurement\x00";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Errors
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Failure while constructing a value from untrusted input.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Error)]
|
||||||
|
pub enum InputError {
|
||||||
|
/// A device identifier was empty.
|
||||||
|
#[error("device id must not be empty")]
|
||||||
|
EmptyDeviceId,
|
||||||
|
/// A device identifier exceeded [`MAX_DEVICE_ID_LEN`].
|
||||||
|
#[error("device id length {0} exceeds maximum {max}", max = MAX_DEVICE_ID_LEN)]
|
||||||
|
DeviceIdTooLong(usize),
|
||||||
|
/// A calibration reference exceeded [`MAX_CALIBRATION_REF_LEN`].
|
||||||
|
#[error("calibration ref length {0} exceeds maximum {max}", max = MAX_CALIBRATION_REF_LEN)]
|
||||||
|
CalibrationRefTooLong(usize),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reason a [`SignedMeasurement`] was rejected at the verification boundary.
|
||||||
|
///
|
||||||
|
/// Every variant is a hard `Err`: a rejected frame is dropped and counted,
|
||||||
|
/// never a warning that proceeds (mirroring ADR-296's source-drop behaviour).
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Error)]
|
||||||
|
pub enum VerifyError {
|
||||||
|
/// The measurement's `DeviceId` is not enrolled.
|
||||||
|
#[error("device is not enrolled")]
|
||||||
|
UnknownDevice,
|
||||||
|
/// The signature/MAC did not verify over the canonical bytes.
|
||||||
|
#[error("signature verification failed")]
|
||||||
|
BadSignature,
|
||||||
|
/// The carried payload hash did not match the presented payload.
|
||||||
|
#[error("payload hash does not match presented payload (tamper)")]
|
||||||
|
Tampered,
|
||||||
|
/// The sequence number did not strictly increase for this device.
|
||||||
|
#[error("sequence {got} is not greater than last accepted {last} (replay)")]
|
||||||
|
Replay {
|
||||||
|
/// The last sequence number this device successfully advanced to.
|
||||||
|
last: u64,
|
||||||
|
/// The offending non-increasing sequence number.
|
||||||
|
got: u64,
|
||||||
|
},
|
||||||
|
/// The timestamp is older than the freshness window allows.
|
||||||
|
#[error("timestamp is stale by {by_nanos} ns beyond the freshness window")]
|
||||||
|
Stale {
|
||||||
|
/// How far past the allowed age the timestamp fell, in nanoseconds.
|
||||||
|
by_nanos: i64,
|
||||||
|
},
|
||||||
|
/// The timestamp is further in the future than the clock-skew budget allows.
|
||||||
|
#[error("timestamp is {by_nanos} ns further ahead than the skew budget")]
|
||||||
|
FutureDated {
|
||||||
|
/// How far past the allowed skew the timestamp fell, in nanoseconds.
|
||||||
|
by_nanos: i64,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Core value types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Authenticated device identity. Constructed only through [`DeviceId::new`],
|
||||||
|
/// which validates length at the boundary.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||||
|
pub struct DeviceId(String);
|
||||||
|
|
||||||
|
impl DeviceId {
|
||||||
|
/// Validate and wrap a device identifier. Rejects empty or oversized ids.
|
||||||
|
pub fn new(id: impl Into<String>) -> Result<Self, InputError> {
|
||||||
|
let id = id.into();
|
||||||
|
if id.is_empty() {
|
||||||
|
return Err(InputError::EmptyDeviceId);
|
||||||
|
}
|
||||||
|
if id.len() > MAX_DEVICE_ID_LEN {
|
||||||
|
return Err(InputError::DeviceIdTooLong(id.len()));
|
||||||
|
}
|
||||||
|
Ok(Self(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Borrow the identifier string.
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Server-injected timestamp, nanoseconds since an agreed epoch. Time is always
|
||||||
|
/// injected (never read from a wall clock inside this crate) so verification is
|
||||||
|
/// deterministic and testable.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||||
|
pub struct Timestamp(pub i64);
|
||||||
|
|
||||||
|
/// BLAKE3 hash of a measurement payload (CSI/CIR bytes). The payload itself is
|
||||||
|
/// *not* embedded in the envelope; only this hash is signed, so tampering is
|
||||||
|
/// detectable without carrying the payload twice.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct PayloadHash(pub [u8; TAG_LEN]);
|
||||||
|
|
||||||
|
impl PayloadHash {
|
||||||
|
/// Compute the hash of a payload.
|
||||||
|
pub fn of(payload: &[u8]) -> Self {
|
||||||
|
Self(*blake3::hash(payload).as_bytes())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Optional reference to a calibration certificate (ADR-301) in effect for a
|
||||||
|
/// measurement. Validated length at the boundary.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct CalibrationRef(String);
|
||||||
|
|
||||||
|
impl CalibrationRef {
|
||||||
|
/// Validate and wrap a calibration reference.
|
||||||
|
pub fn new(reference: impl Into<String>) -> Result<Self, InputError> {
|
||||||
|
let reference = reference.into();
|
||||||
|
if reference.len() > MAX_CALIBRATION_REF_LEN {
|
||||||
|
return Err(InputError::CalibrationRefTooLong(reference.len()));
|
||||||
|
}
|
||||||
|
Ok(Self(reference))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Borrow the reference string.
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A signature/MAC tag over the canonical measurement bytes. Fixed width so a
|
||||||
|
/// malformed wire value cannot force an unbounded allocation.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct Signature(pub [u8; TAG_LEN]);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// The signed envelope
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// The unsigned content bound by a signature: everything a verifier must be able
|
||||||
|
/// to reconstruct byte-for-byte to check the tag.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct MeasurementContent {
|
||||||
|
/// Authenticated origin device.
|
||||||
|
pub device: DeviceId,
|
||||||
|
/// Strictly monotonic per-device sequence number (replay defense).
|
||||||
|
pub sequence: u64,
|
||||||
|
/// Device-asserted capture timestamp, checked against the freshness window.
|
||||||
|
pub timestamp: Timestamp,
|
||||||
|
/// Hash of the measurement payload (tamper detection).
|
||||||
|
pub payload_hash: PayloadHash,
|
||||||
|
/// Optional calibration certificate reference in effect.
|
||||||
|
pub calibration_ref: Option<CalibrationRef>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MeasurementContent {
|
||||||
|
/// Deterministic, length-prefixed canonical serialization used as the
|
||||||
|
/// signing input. Length prefixes make the encoding unambiguous (no field
|
||||||
|
/// can be confused with another) and independent of any serde format.
|
||||||
|
pub fn canonical_bytes(&self) -> Vec<u8> {
|
||||||
|
let mut out = Vec::with_capacity(DOMAIN.len() + 96 + self.device.0.len());
|
||||||
|
out.extend_from_slice(DOMAIN);
|
||||||
|
push_field(&mut out, self.device.0.as_bytes());
|
||||||
|
out.extend_from_slice(&self.sequence.to_le_bytes());
|
||||||
|
out.extend_from_slice(&self.timestamp.0.to_le_bytes());
|
||||||
|
push_field(&mut out, &self.payload_hash.0);
|
||||||
|
match &self.calibration_ref {
|
||||||
|
Some(c) => {
|
||||||
|
out.push(1);
|
||||||
|
push_field(&mut out, c.0.as_bytes());
|
||||||
|
}
|
||||||
|
None => out.push(0),
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A [`MeasurementContent`] together with its signature. This is the object on
|
||||||
|
/// the wire and the unit the witness chain (ADR-319) serializes.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct SignedMeasurement {
|
||||||
|
/// The signed content.
|
||||||
|
pub content: MeasurementContent,
|
||||||
|
/// The tag over [`MeasurementContent::canonical_bytes`].
|
||||||
|
pub signature: Signature,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SignedMeasurement {
|
||||||
|
/// Build a signed measurement from its parts using `signer`.
|
||||||
|
pub fn sign<S: Signer + ?Sized>(
|
||||||
|
signer: &S,
|
||||||
|
device: DeviceId,
|
||||||
|
sequence: u64,
|
||||||
|
timestamp: Timestamp,
|
||||||
|
payload: &[u8],
|
||||||
|
calibration_ref: Option<CalibrationRef>,
|
||||||
|
) -> Self {
|
||||||
|
let content = MeasurementContent {
|
||||||
|
device,
|
||||||
|
sequence,
|
||||||
|
timestamp,
|
||||||
|
payload_hash: PayloadHash::of(payload),
|
||||||
|
calibration_ref,
|
||||||
|
};
|
||||||
|
let signature = signer.sign(&content.canonical_bytes());
|
||||||
|
Self { content, signature }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The trusted result of verification: proof that a measurement's origin,
|
||||||
|
/// sequence, freshness, and payload integrity were all checked. Carries the
|
||||||
|
/// verified chain-of-custody fields forward to calibration, inference, and the
|
||||||
|
/// witness chain.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct VerifiedMeasurement {
|
||||||
|
/// Verified origin device.
|
||||||
|
pub device: DeviceId,
|
||||||
|
/// Verified sequence number (strictly greater than the previous accepted).
|
||||||
|
pub sequence: u64,
|
||||||
|
/// Verified timestamp (within the freshness window).
|
||||||
|
pub timestamp: Timestamp,
|
||||||
|
/// Verified payload hash (matched the presented payload).
|
||||||
|
pub payload_hash: PayloadHash,
|
||||||
|
/// Calibration reference in effect, if any.
|
||||||
|
pub calibration_ref: Option<CalibrationRef>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Signer / Verifier abstraction
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Produces a signature over canonical measurement bytes. A production Ed25519
|
||||||
|
/// signer implements this over its private key.
|
||||||
|
pub trait Signer {
|
||||||
|
/// Sign `message`, returning a fixed-width tag.
|
||||||
|
fn sign(&self, message: &[u8]) -> Signature;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verifies a signature over canonical measurement bytes. A production Ed25519
|
||||||
|
/// verifier implements this over the enrolled public key.
|
||||||
|
pub trait Verifier {
|
||||||
|
/// Return `true` iff `signature` is valid for `message` under this identity.
|
||||||
|
fn verify(&self, message: &[u8], signature: &Signature) -> bool;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **SYNTHETIC-grade reference** signer/verifier: a keyed-BLAKE3 MAC.
|
||||||
|
///
|
||||||
|
/// This is a symmetric MAC — the same secret signs and verifies — so it proves
|
||||||
|
/// the chain-of-custody logic but provides no non-repudiation. Do not read a
|
||||||
|
/// spoof-resistance guarantee from tests that use it (CLAUDE.md evidence rule).
|
||||||
|
/// Swap in an Ed25519 [`Signer`]/[`Verifier`] for a real asymmetric identity.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct Blake3MacSigner {
|
||||||
|
key: [u8; TAG_LEN],
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Blake3MacSigner {
|
||||||
|
/// Construct from a 32-byte secret key.
|
||||||
|
pub fn new(key: [u8; TAG_LEN]) -> Self {
|
||||||
|
Self { key }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tag(&self, message: &[u8]) -> Signature {
|
||||||
|
Signature(*blake3::keyed_hash(&self.key, message).as_bytes())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Signer for Blake3MacSigner {
|
||||||
|
fn sign(&self, message: &[u8]) -> Signature {
|
||||||
|
self.tag(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Verifier for Blake3MacSigner {
|
||||||
|
fn verify(&self, message: &[u8], signature: &Signature) -> bool {
|
||||||
|
constant_time_eq(&self.tag(message).0, &signature.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Freshness policy
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Bounds a measurement timestamp against the injected server clock. Rejects
|
||||||
|
/// frames older than `max_age_nanos` (stale) or more than `max_skew_ahead_nanos`
|
||||||
|
/// in the future (clock-skew budget). Reuses ADR-295's freshness notion rather
|
||||||
|
/// than inventing a parallel one.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct FreshnessPolicy {
|
||||||
|
/// Maximum accepted age (`now - timestamp`) in nanoseconds.
|
||||||
|
pub max_age_nanos: i64,
|
||||||
|
/// Maximum accepted lead (`timestamp - now`) in nanoseconds.
|
||||||
|
pub max_skew_ahead_nanos: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FreshnessPolicy {
|
||||||
|
/// A policy with the given symmetric window.
|
||||||
|
pub fn new(max_age_nanos: i64, max_skew_ahead_nanos: i64) -> Self {
|
||||||
|
Self {
|
||||||
|
max_age_nanos: max_age_nanos.max(0),
|
||||||
|
max_skew_ahead_nanos: max_skew_ahead_nanos.max(0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check(&self, timestamp: Timestamp, now: Timestamp) -> Result<(), VerifyError> {
|
||||||
|
let delta = now.0.saturating_sub(timestamp.0); // positive => in the past
|
||||||
|
if delta > self.max_age_nanos {
|
||||||
|
return Err(VerifyError::Stale {
|
||||||
|
by_nanos: delta - self.max_age_nanos,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let ahead = timestamp.0.saturating_sub(now.0); // positive => in the future
|
||||||
|
if ahead > self.max_skew_ahead_nanos {
|
||||||
|
return Err(VerifyError::FutureDated {
|
||||||
|
by_nanos: ahead - self.max_skew_ahead_nanos,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// The verifier: enrollment + per-device sequence state
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
struct Enrolled<V: Verifier> {
|
||||||
|
verifier: V,
|
||||||
|
last_sequence: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The ingest-boundary verifier. Holds enrolled device identities (a device is
|
||||||
|
/// untrusted until an operator enrolls its verifier) and the last accepted
|
||||||
|
/// sequence per device, and applies signature + monotonic-sequence + freshness
|
||||||
|
/// + tamper checks.
|
||||||
|
pub struct AttestationVerifier<V: Verifier> {
|
||||||
|
enrolled: BTreeMap<DeviceId, Enrolled<V>>,
|
||||||
|
freshness: FreshnessPolicy,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<V: Verifier> AttestationVerifier<V> {
|
||||||
|
/// Create an empty verifier with the given freshness policy.
|
||||||
|
pub fn new(freshness: FreshnessPolicy) -> Self {
|
||||||
|
Self {
|
||||||
|
enrolled: BTreeMap::new(),
|
||||||
|
freshness,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enroll (or re-enroll) a device with the verifier for its identity. This
|
||||||
|
/// is the explicit, authorized enrollment step from ADR-305; re-enrolling
|
||||||
|
/// resets the device's sequence state.
|
||||||
|
pub fn enroll(&mut self, device: DeviceId, verifier: V) {
|
||||||
|
self.enrolled.insert(
|
||||||
|
device,
|
||||||
|
Enrolled {
|
||||||
|
verifier,
|
||||||
|
last_sequence: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a device is enrolled.
|
||||||
|
pub fn is_enrolled(&self, device: &DeviceId) -> bool {
|
||||||
|
self.enrolled.contains_key(device)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The last accepted sequence for a device, if any.
|
||||||
|
pub fn last_sequence(&self, device: &DeviceId) -> Option<u64> {
|
||||||
|
self.enrolled.get(device).and_then(|e| e.last_sequence)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify a signed measurement against the presented `payload` at injected
|
||||||
|
/// time `now`.
|
||||||
|
///
|
||||||
|
/// Checks, in order: device enrolled → signature → payload-hash (tamper) →
|
||||||
|
/// strictly-monotonic sequence (replay) → freshness. Per-device sequence
|
||||||
|
/// state advances **only** on full success, so a rejected frame never
|
||||||
|
/// consumes a sequence number.
|
||||||
|
pub fn verify(
|
||||||
|
&mut self,
|
||||||
|
measurement: &SignedMeasurement,
|
||||||
|
payload: &[u8],
|
||||||
|
now: Timestamp,
|
||||||
|
) -> Result<VerifiedMeasurement, VerifyError> {
|
||||||
|
let content = &measurement.content;
|
||||||
|
|
||||||
|
let entry = self
|
||||||
|
.enrolled
|
||||||
|
.get_mut(&content.device)
|
||||||
|
.ok_or(VerifyError::UnknownDevice)?;
|
||||||
|
|
||||||
|
// Authenticate the envelope: the tag covers the payload *hash*, so a
|
||||||
|
// valid signature also authenticates the hash field itself.
|
||||||
|
if !entry
|
||||||
|
.verifier
|
||||||
|
.verify(&content.canonical_bytes(), &measurement.signature)
|
||||||
|
{
|
||||||
|
return Err(VerifyError::BadSignature);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tamper detection: the presented payload must match the signed hash.
|
||||||
|
if PayloadHash::of(payload) != content.payload_hash {
|
||||||
|
return Err(VerifyError::Tampered);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replay defense: strictly increasing sequence per device.
|
||||||
|
if let Some(last) = entry.last_sequence {
|
||||||
|
if content.sequence <= last {
|
||||||
|
return Err(VerifyError::Replay {
|
||||||
|
last,
|
||||||
|
got: content.sequence,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Freshness window.
|
||||||
|
self.freshness.check(content.timestamp, now)?;
|
||||||
|
|
||||||
|
// All checks passed: advance the accepted sequence and emit the
|
||||||
|
// verified custody record.
|
||||||
|
entry.last_sequence = Some(content.sequence);
|
||||||
|
Ok(VerifiedMeasurement {
|
||||||
|
device: content.device.clone(),
|
||||||
|
sequence: content.sequence,
|
||||||
|
timestamp: content.timestamp,
|
||||||
|
payload_hash: content.payload_hash,
|
||||||
|
calibration_ref: content.calibration_ref.clone(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Append a `u32` little-endian length prefix followed by the bytes.
|
||||||
|
fn push_field(out: &mut Vec<u8>, bytes: &[u8]) {
|
||||||
|
out.extend_from_slice(&(bytes.len() as u32).to_le_bytes());
|
||||||
|
out.extend_from_slice(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Constant-time equality over equal-length byte arrays.
|
||||||
|
fn constant_time_eq(a: &[u8; TAG_LEN], b: &[u8; TAG_LEN]) -> bool {
|
||||||
|
let mut diff = 0u8;
|
||||||
|
for i in 0..TAG_LEN {
|
||||||
|
diff |= a[i] ^ b[i];
|
||||||
|
}
|
||||||
|
diff == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
const KEY: [u8; TAG_LEN] = [7u8; TAG_LEN];
|
||||||
|
|
||||||
|
fn signer() -> Blake3MacSigner {
|
||||||
|
Blake3MacSigner::new(KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn device() -> DeviceId {
|
||||||
|
DeviceId::new("esp32-node-01").unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fresh_policy() -> FreshnessPolicy {
|
||||||
|
// 1 second age budget, 100 ms future skew budget.
|
||||||
|
FreshnessPolicy::new(1_000_000_000, 100_000_000)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_verifier() -> AttestationVerifier<Blake3MacSigner> {
|
||||||
|
let mut v = AttestationVerifier::new(fresh_policy());
|
||||||
|
v.enroll(device(), signer());
|
||||||
|
v
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn valid_measurement_verifies() {
|
||||||
|
let mut v = make_verifier();
|
||||||
|
let m = SignedMeasurement::sign(&signer(), device(), 1, Timestamp(1000), b"csi-frame", None);
|
||||||
|
let out = v.verify(&m, b"csi-frame", Timestamp(1000)).unwrap();
|
||||||
|
assert_eq!(out.device, device());
|
||||||
|
assert_eq!(out.sequence, 1);
|
||||||
|
assert_eq!(out.timestamp, Timestamp(1000));
|
||||||
|
assert_eq!(v.last_sequence(&device()), Some(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn valid_with_calibration_ref_verifies() {
|
||||||
|
let mut v = make_verifier();
|
||||||
|
let cal = CalibrationRef::new("cal-cert-abc").unwrap();
|
||||||
|
let m = SignedMeasurement::sign(
|
||||||
|
&signer(),
|
||||||
|
device(),
|
||||||
|
5,
|
||||||
|
Timestamp(2000),
|
||||||
|
b"payload",
|
||||||
|
Some(cal.clone()),
|
||||||
|
);
|
||||||
|
let out = v.verify(&m, b"payload", Timestamp(2000)).unwrap();
|
||||||
|
assert_eq!(out.calibration_ref, Some(cal));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn replayed_or_old_sequence_rejected() {
|
||||||
|
let mut v = make_verifier();
|
||||||
|
let now = Timestamp(5000);
|
||||||
|
let m3 = SignedMeasurement::sign(&signer(), device(), 3, now, b"p", None);
|
||||||
|
v.verify(&m3, b"p", now).unwrap();
|
||||||
|
|
||||||
|
// Exact replay of sequence 3.
|
||||||
|
assert_eq!(v.verify(&m3, b"p", now), Err(VerifyError::Replay { last: 3, got: 3 }));
|
||||||
|
|
||||||
|
// Older sequence 2.
|
||||||
|
let m2 = SignedMeasurement::sign(&signer(), device(), 2, now, b"p", None);
|
||||||
|
assert_eq!(v.verify(&m2, b"p", now), Err(VerifyError::Replay { last: 3, got: 2 }));
|
||||||
|
|
||||||
|
// A strictly greater sequence still works, and the rejected frames did
|
||||||
|
// not consume a sequence slot.
|
||||||
|
let m4 = SignedMeasurement::sign(&signer(), device(), 4, now, b"p", None);
|
||||||
|
assert!(v.verify(&m4, b"p", now).is_ok());
|
||||||
|
assert_eq!(v.last_sequence(&device()), Some(4));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stale_timestamp_rejected() {
|
||||||
|
let mut v = make_verifier();
|
||||||
|
// Captured at t=0, verified at t=2s with a 1s age budget => 1s stale.
|
||||||
|
let m = SignedMeasurement::sign(&signer(), device(), 1, Timestamp(0), b"p", None);
|
||||||
|
assert_eq!(
|
||||||
|
v.verify(&m, b"p", Timestamp(2_000_000_000)),
|
||||||
|
Err(VerifyError::Stale { by_nanos: 1_000_000_000 })
|
||||||
|
);
|
||||||
|
// Rejected frame did not advance sequence state.
|
||||||
|
assert_eq!(v.last_sequence(&device()), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn future_dated_timestamp_rejected() {
|
||||||
|
let mut v = make_verifier();
|
||||||
|
// Captured 500ms in the future with a 100ms skew budget => 400ms over.
|
||||||
|
let m = SignedMeasurement::sign(&signer(), device(), 1, Timestamp(500_000_000), b"p", None);
|
||||||
|
assert_eq!(
|
||||||
|
v.verify(&m, b"p", Timestamp(0)),
|
||||||
|
Err(VerifyError::FutureDated { by_nanos: 400_000_000 })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tampered_payload_rejected() {
|
||||||
|
let mut v = make_verifier();
|
||||||
|
let m = SignedMeasurement::sign(&signer(), device(), 1, Timestamp(0), b"real-payload", None);
|
||||||
|
// Same envelope, but a different payload is presented at ingest.
|
||||||
|
assert_eq!(v.verify(&m, b"evil-payload", Timestamp(0)), Err(VerifyError::Tampered));
|
||||||
|
assert_eq!(v.last_sequence(&device()), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tampered_envelope_field_fails_signature() {
|
||||||
|
let mut v = make_verifier();
|
||||||
|
let mut m = SignedMeasurement::sign(&signer(), device(), 1, Timestamp(0), b"p", None);
|
||||||
|
// Flip the sequence without re-signing.
|
||||||
|
m.content.sequence = 999;
|
||||||
|
assert_eq!(v.verify(&m, b"p", Timestamp(0)), Err(VerifyError::BadSignature));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wrong_key_fails_signature() {
|
||||||
|
let mut v = make_verifier();
|
||||||
|
let attacker = Blake3MacSigner::new([9u8; TAG_LEN]);
|
||||||
|
let m = SignedMeasurement::sign(&attacker, device(), 1, Timestamp(0), b"p", None);
|
||||||
|
assert_eq!(v.verify(&m, b"p", Timestamp(0)), Err(VerifyError::BadSignature));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unknown_device_rejected() {
|
||||||
|
let mut v = make_verifier();
|
||||||
|
let stranger = DeviceId::new("rogue-node").unwrap();
|
||||||
|
let m = SignedMeasurement::sign(&signer(), stranger, 1, Timestamp(0), b"p", None);
|
||||||
|
assert_eq!(v.verify(&m, b"p", Timestamp(0)), Err(VerifyError::UnknownDevice));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn signing_is_deterministic() {
|
||||||
|
let a = SignedMeasurement::sign(&signer(), device(), 1, Timestamp(42), b"p", None);
|
||||||
|
let b = SignedMeasurement::sign(&signer(), device(), 1, Timestamp(42), b"p", None);
|
||||||
|
assert_eq!(a, b);
|
||||||
|
assert_eq!(a.signature, b.signature);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn canonical_bytes_are_field_unambiguous() {
|
||||||
|
// "ab" + "" must not collide with "a" + "b": length prefixes prevent it.
|
||||||
|
let mk = |d: &str, cal: Option<&str>| MeasurementContent {
|
||||||
|
device: DeviceId::new(d).unwrap(),
|
||||||
|
sequence: 1,
|
||||||
|
timestamp: Timestamp(0),
|
||||||
|
payload_hash: PayloadHash::of(b""),
|
||||||
|
calibration_ref: cal.map(|c| CalibrationRef::new(c).unwrap()),
|
||||||
|
};
|
||||||
|
assert_ne!(
|
||||||
|
mk("ab", None).canonical_bytes(),
|
||||||
|
mk("a", Some("b")).canonical_bytes()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn envelope_round_trips_through_serde() {
|
||||||
|
let m = SignedMeasurement::sign(
|
||||||
|
&signer(),
|
||||||
|
device(),
|
||||||
|
7,
|
||||||
|
Timestamp(123),
|
||||||
|
b"payload",
|
||||||
|
Some(CalibrationRef::new("cal").unwrap()),
|
||||||
|
);
|
||||||
|
let json = serde_json::to_string(&m).unwrap();
|
||||||
|
let back: SignedMeasurement = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(m, back);
|
||||||
|
|
||||||
|
// A deserialized envelope still verifies end-to-end.
|
||||||
|
let mut v = make_verifier();
|
||||||
|
assert!(v.verify(&back, b"payload", Timestamp(123)).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn device_id_boundary_validation() {
|
||||||
|
assert_eq!(DeviceId::new(""), Err(InputError::EmptyDeviceId));
|
||||||
|
let long = "x".repeat(MAX_DEVICE_ID_LEN + 1);
|
||||||
|
assert_eq!(
|
||||||
|
DeviceId::new(long),
|
||||||
|
Err(InputError::DeviceIdTooLong(MAX_DEVICE_ID_LEN + 1))
|
||||||
|
);
|
||||||
|
assert!(DeviceId::new("x").is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn per_device_sequence_is_independent() {
|
||||||
|
let mut v = AttestationVerifier::new(fresh_policy());
|
||||||
|
let d1 = DeviceId::new("node-1").unwrap();
|
||||||
|
let d2 = DeviceId::new("node-2").unwrap();
|
||||||
|
v.enroll(d1.clone(), signer());
|
||||||
|
v.enroll(d2.clone(), signer());
|
||||||
|
|
||||||
|
let now = Timestamp(100);
|
||||||
|
let m1 = SignedMeasurement::sign(&signer(), d1.clone(), 10, now, b"p", None);
|
||||||
|
let m2 = SignedMeasurement::sign(&signer(), d2.clone(), 1, now, b"p", None);
|
||||||
|
// d1 at seq 10 does not block d2 at seq 1.
|
||||||
|
assert!(v.verify(&m1, b"p", now).is_ok());
|
||||||
|
assert!(v.verify(&m2, b"p", now).is_ok());
|
||||||
|
assert_eq!(v.last_sequence(&d1), Some(10));
|
||||||
|
assert_eq!(v.last_sequence(&d2), Some(1));
|
||||||
|
}
|
||||||
|
}
|
||||||
19
v2/crates/ruview-certify/Cargo.toml
Normal file
19
v2/crates/ruview-certify/Cargo.toml
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
[package]
|
||||||
|
name = "ruview-certify"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
authors.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
thiserror.workspace = true
|
||||||
|
serde = { workspace = true, features = ["derive"] }
|
||||||
|
blake3 = { version = "1.5", default-features = false }
|
||||||
|
ruview-ontology = { path = "../ruview-ontology" }
|
||||||
|
ruview-attest = { path = "../ruview-attest" }
|
||||||
|
ruview-evidence = { path = "../ruview-evidence" }
|
||||||
|
wifi-densepose-calibration = { path = "../wifi-densepose-calibration", default-features = false }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
serde_json.workspace = true
|
||||||
427
v2/crates/ruview-certify/src/lib.rs
Normal file
427
v2/crates/ruview-certify/src/lib.rs
Normal file
@@ -0,0 +1,427 @@
|
|||||||
|
//! # `ruview-certify` — signed capability certificates (ADR-318, ADR-300 §1)
|
||||||
|
//!
|
||||||
|
//! A [`CapabilityCertificate`] is a bounded, signed attestation that a specific
|
||||||
|
//! capability (e.g. presence, pose) has been *validated for a specific
|
||||||
|
//! environment*, for a *bounded* time. RuView must stop making unconditional
|
||||||
|
//! capability claims: "supports presence" is not a true statement — presence
|
||||||
|
//! works in some rooms, on some hardware, for some subject dynamics, and fails
|
||||||
|
//! on a stationary subject at range in an uncalibrated room. The honest unit of
|
||||||
|
//! the claim is a signed, expiring certificate, never a feature flag.
|
||||||
|
//!
|
||||||
|
//! ## What the certificate binds
|
||||||
|
//!
|
||||||
|
//! - the **capability** ([`Capability`]);
|
||||||
|
//! - the **room** ([`SpaceId`], ADR-306) plus the **calibration-certificate
|
||||||
|
//! version** (ADR-301) it was validated against;
|
||||||
|
//! - the **hardware** ([`DeviceId`], ADR-305);
|
||||||
|
//! - the scored **model** version;
|
||||||
|
//! - the **calibrated date** the calibration was captured;
|
||||||
|
//! - the operating **metrics** (`moving_recall`, `stationary_recall`,
|
||||||
|
//! `false_presence_per_24h`) sliced from the ADR-304 ledger for **exactly this
|
||||||
|
//! context** (never pooled across contexts);
|
||||||
|
//! - a `valid_until` expiry that is **never open-ended** and **cannot outlive the
|
||||||
|
//! calibration validity**;
|
||||||
|
//! - exactly one [`EvidenceLevel`] (ADR-282) that **cannot exceed the evidence
|
||||||
|
//! slice's floor** — a certificate never upgrades the ledger it is minted from;
|
||||||
|
//! - a **signature** over the canonical serialization ([`ruview_attest`]); an
|
||||||
|
//! unsigned certificate is not a valid certificate.
|
||||||
|
//!
|
||||||
|
//! ## Honest by construction (ADR-300 rule)
|
||||||
|
//!
|
||||||
|
//! - Minting from a slice that reports **no evidence** yields no certificate —
|
||||||
|
//! absence of evidence is never a capability.
|
||||||
|
//! - The evidence level is the ledger floor, never an upgrade.
|
||||||
|
//! - A certificate minted from a synthetic ledger slice is `L0`/SYNTHETIC by
|
||||||
|
//! construction; nothing here invents a MEASURED number.
|
||||||
|
//! - [`CapabilityCertificate::is_valid`] is *conditional on the live domain
|
||||||
|
//! signature* (ADR-302): a certificate over a `DEGRADED`/`UNKNOWN` domain is
|
||||||
|
//! not valid, and an expired certificate is not valid — the honest failure is
|
||||||
|
//! UNKNOWN, not a best-effort guess.
|
||||||
|
//!
|
||||||
|
//! Time is always injected (no wall clock); no randomness; malformed input is a
|
||||||
|
//! returned error, never a panic; allocation is bounded at every boundary.
|
||||||
|
|
||||||
|
#![forbid(unsafe_code)]
|
||||||
|
|
||||||
|
use ruview_attest::{DeviceId, Signature, Signer, Verifier};
|
||||||
|
use ruview_evidence::{EvidenceLevel, EvidenceSlice, SummaryEvidence};
|
||||||
|
use ruview_ontology::SpaceId;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use wifi_densepose_calibration::CalibrationCertificate;
|
||||||
|
|
||||||
|
/// Maximum accepted byte length for the model-version identifier. Bounds
|
||||||
|
/// allocation at the untrusted-input boundary (CLAUDE.md).
|
||||||
|
pub const MAX_MODEL_LEN: usize = 256;
|
||||||
|
|
||||||
|
/// Domain-separation tag for the canonical signing bytes. Distinguishes a
|
||||||
|
/// capability-certificate signature from any other signed object in the system.
|
||||||
|
const DOMAIN: &[u8] = b"ruview-certify/CapabilityCertificate/v1";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Value types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// The phenomenon a certificate is about. A device may only be certified for a
|
||||||
|
/// capability it is attested to sense (ADR-305/ADR-141); the attestation gate is
|
||||||
|
/// a phase-2 concern — this phase binds the capability into the signed object.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
||||||
|
pub enum Capability {
|
||||||
|
/// Presence / occupancy detection.
|
||||||
|
Presence,
|
||||||
|
/// Body-pose (DensePose) estimation.
|
||||||
|
Pose,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Capability {
|
||||||
|
/// Stable byte tag used inside the canonical serialization. Never `0`, so a
|
||||||
|
/// field boundary can never be confused with an absent value.
|
||||||
|
const fn tag(self) -> u8 {
|
||||||
|
match self {
|
||||||
|
Capability::Presence => 1,
|
||||||
|
Capability::Pose => 2,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The live domain-state signature a consumer supplies at validation time
|
||||||
|
/// (ADR-302). Only `Known` permits a capability; `Degraded`/`Unknown` gate the
|
||||||
|
/// certificate to invalid — the honest failure is UNKNOWN, not a guess.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
||||||
|
pub enum DomainState {
|
||||||
|
/// The domain is characterized and within its calibration envelope.
|
||||||
|
Known,
|
||||||
|
/// The domain has drifted or is degraded — no capability.
|
||||||
|
Degraded,
|
||||||
|
/// The domain is uncharacterized / unknown — no capability.
|
||||||
|
Unknown,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DomainState {
|
||||||
|
/// Whether the live domain permits consuming a capability.
|
||||||
|
#[must_use]
|
||||||
|
pub fn is_known(self) -> bool {
|
||||||
|
matches!(self, DomainState::Known)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The operating metrics frozen onto a certificate, sliced from the ADR-304
|
||||||
|
/// ledger for one exact context (never a global average).
|
||||||
|
///
|
||||||
|
/// `false_presence_per_24h` carries the ledger's context false-positive rate;
|
||||||
|
/// no per-24h count is invented here — the value is the number the ledger
|
||||||
|
/// reports for this context, relabelled to the certificate's operating vocab.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct OperatingMetrics {
|
||||||
|
/// Recall on moving subjects, `[0, 1]`.
|
||||||
|
pub moving_recall: f64,
|
||||||
|
/// Recall on stationary subjects, `[0, 1]`.
|
||||||
|
pub stationary_recall: f64,
|
||||||
|
/// False-presence operating metric (ledger context false-positive rate).
|
||||||
|
pub false_presence_per_24h: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The unsigned content a signature binds: everything a verifier must
|
||||||
|
/// reconstruct byte-for-byte to check the tag.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct CertificateContent {
|
||||||
|
/// The certified phenomenon.
|
||||||
|
pub capability: Capability,
|
||||||
|
/// The room this claim is validated for (ADR-306).
|
||||||
|
pub room: SpaceId,
|
||||||
|
/// Version of the calibration certificate the validation ran against
|
||||||
|
/// (ADR-301). The certificate cannot outlive this calibration.
|
||||||
|
pub calibration_version: u64,
|
||||||
|
/// Expiry of the calibration certificate (unix seconds); the ceiling on
|
||||||
|
/// `valid_until`.
|
||||||
|
pub calibration_expires_at_unix_s: i64,
|
||||||
|
/// The authenticated device the claim is validated for (ADR-305).
|
||||||
|
pub hardware: DeviceId,
|
||||||
|
/// The scored model version.
|
||||||
|
pub model_version: String,
|
||||||
|
/// Capture time of the calibration certificate (unix seconds).
|
||||||
|
pub calibrated_date_unix_s: i64,
|
||||||
|
/// Operating metrics, sliced from the ledger for this exact context.
|
||||||
|
pub metrics: OperatingMetrics,
|
||||||
|
/// Explicit expiry (unix seconds); never open-ended, never past the
|
||||||
|
/// calibration expiry.
|
||||||
|
pub valid_until_unix_s: i64,
|
||||||
|
/// Exactly one evidence level; the ledger floor, never an upgrade.
|
||||||
|
pub evidence_level: EvidenceLevel,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CertificateContent {
|
||||||
|
/// Deterministic, length-prefixed canonical serialization used as the
|
||||||
|
/// signing input. Length prefixes make the encoding unambiguous (no field
|
||||||
|
/// can be confused with another) and independent of any serde format, so
|
||||||
|
/// two byte-identical contents always sign identically.
|
||||||
|
#[must_use]
|
||||||
|
pub fn canonical_bytes(&self) -> Vec<u8> {
|
||||||
|
let mut out = Vec::with_capacity(
|
||||||
|
DOMAIN.len() + 128 + self.room.as_str().len() + self.hardware.as_str().len(),
|
||||||
|
);
|
||||||
|
out.extend_from_slice(DOMAIN);
|
||||||
|
out.push(self.capability.tag());
|
||||||
|
push_field(&mut out, self.room.as_str().as_bytes());
|
||||||
|
out.extend_from_slice(&self.calibration_version.to_le_bytes());
|
||||||
|
out.extend_from_slice(&self.calibration_expires_at_unix_s.to_le_bytes());
|
||||||
|
push_field(&mut out, self.hardware.as_str().as_bytes());
|
||||||
|
push_field(&mut out, self.model_version.as_bytes());
|
||||||
|
out.extend_from_slice(&self.calibrated_date_unix_s.to_le_bytes());
|
||||||
|
out.extend_from_slice(&self.metrics.moving_recall.to_bits().to_le_bytes());
|
||||||
|
out.extend_from_slice(&self.metrics.stationary_recall.to_bits().to_le_bytes());
|
||||||
|
out.extend_from_slice(&self.metrics.false_presence_per_24h.to_bits().to_le_bytes());
|
||||||
|
out.extend_from_slice(&self.valid_until_unix_s.to_le_bytes());
|
||||||
|
out.push(level_byte(self.evidence_level));
|
||||||
|
out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A signed capability certificate: the [`CertificateContent`] together with a
|
||||||
|
/// signature over its canonical bytes. `signature` is [`None`] for an unsigned
|
||||||
|
/// certificate, which is never valid (ADR-318 §1).
|
||||||
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct CapabilityCertificate {
|
||||||
|
/// The signed content.
|
||||||
|
pub content: CertificateContent,
|
||||||
|
/// Tag over [`CertificateContent::canonical_bytes`]; `None` means unsigned.
|
||||||
|
pub signature: Option<Signature>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CapabilityCertificate {
|
||||||
|
/// Wrap content as an **unsigned** certificate. Useful for tests and for
|
||||||
|
/// staging content before signing; [`Self::verify`] and [`Self::is_valid`]
|
||||||
|
/// both reject it because an unsigned certificate is not a valid
|
||||||
|
/// certificate (ADR-318 §1).
|
||||||
|
#[must_use]
|
||||||
|
pub fn unsigned(content: CertificateContent) -> Self {
|
||||||
|
Self {
|
||||||
|
content,
|
||||||
|
signature: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify the signature over the canonical bytes. Returns `false` for an
|
||||||
|
/// unsigned certificate or a tampered one. This is the cryptographic check;
|
||||||
|
/// [`Self::is_valid`] adds the expiry and live-domain gates.
|
||||||
|
#[must_use]
|
||||||
|
pub fn verify<V: Verifier + ?Sized>(&self, verifier: &V) -> bool {
|
||||||
|
match &self.signature {
|
||||||
|
Some(sig) => verifier.verify(&self.content.canonical_bytes(), sig),
|
||||||
|
None => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The consumer gate (ADR-318 §3, ADR-300/ADR-302): the certificate is valid
|
||||||
|
/// **iff** it is signed, it has not expired (`now < valid_until`), and the
|
||||||
|
/// live domain state is `Known`. A `Degraded`/`Unknown` domain or an expired
|
||||||
|
/// or unsigned certificate resolves to *not valid* — the honest UNKNOWN,
|
||||||
|
/// never a best-effort guess. This is the crypto-independent gate; call
|
||||||
|
/// [`Self::verify`] with the enrolled key for the signature check.
|
||||||
|
#[must_use]
|
||||||
|
pub fn is_valid(&self, now_unix_s: i64, domain: DomainState) -> bool {
|
||||||
|
self.signature.is_some()
|
||||||
|
&& domain.is_known()
|
||||||
|
&& now_unix_s < self.content.valid_until_unix_s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Minting
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// The inputs to [`mint`], other than the signer and the evidence slice. Owned
|
||||||
|
/// so the minted certificate freezes its own copy of every bound field.
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct MintRequest<'c> {
|
||||||
|
/// The phenomenon being certified.
|
||||||
|
pub capability: Capability,
|
||||||
|
/// The room the claim is validated for.
|
||||||
|
pub room: SpaceId,
|
||||||
|
/// The authenticated device the claim is validated for.
|
||||||
|
pub hardware: DeviceId,
|
||||||
|
/// The scored model version.
|
||||||
|
pub model_version: String,
|
||||||
|
/// The calibration certificate the validation ran against; supplies the
|
||||||
|
/// version, calibrated date, and the expiry ceiling.
|
||||||
|
pub calibration: &'c CalibrationCertificate,
|
||||||
|
/// Requested expiry (unix seconds); must not exceed the calibration expiry.
|
||||||
|
pub valid_until_unix_s: i64,
|
||||||
|
/// Requested evidence level; must not exceed the slice floor.
|
||||||
|
pub evidence_level: EvidenceLevel,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mint a signed [`CapabilityCertificate`] from an ADR-304 evidence slice for
|
||||||
|
/// one `(room, device, model)` context.
|
||||||
|
///
|
||||||
|
/// Minting is a pure function over the slice: the metrics are frozen into the
|
||||||
|
/// signed object. It refuses to issue a certificate unless every honesty
|
||||||
|
/// invariant holds.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// - [`CertifyError::NoEvidence`] — the slice reports no evidence for the
|
||||||
|
/// context; absence of evidence is never a capability.
|
||||||
|
/// - [`CertifyError::ContextMismatch`] — the slice's context does not match the
|
||||||
|
/// bound room/hardware/model, so the metrics would not describe the claim.
|
||||||
|
/// - [`CertifyError::CalibrationRoomMismatch`] — the calibration certificate is
|
||||||
|
/// for a different room than the claim.
|
||||||
|
/// - [`CertifyError::EvidenceLevelUpgrade`] — the requested level exceeds the
|
||||||
|
/// ledger floor (no upgrade).
|
||||||
|
/// - [`CertifyError::OutlivesCalibration`] — `valid_until` is past the
|
||||||
|
/// calibration expiry; a certificate cannot outlive its calibration.
|
||||||
|
/// - [`CertifyError::ModelTooLong`] — the model version exceeds [`MAX_MODEL_LEN`].
|
||||||
|
pub fn mint<S: Signer + ?Sized>(
|
||||||
|
signer: &S,
|
||||||
|
request: MintRequest<'_>,
|
||||||
|
slice: &EvidenceSlice<'_>,
|
||||||
|
) -> Result<CapabilityCertificate, CertifyError> {
|
||||||
|
// Bound untrusted input at the boundary.
|
||||||
|
if request.model_version.len() > MAX_MODEL_LEN {
|
||||||
|
return Err(CertifyError::ModelTooLong {
|
||||||
|
len: request.model_version.len(),
|
||||||
|
max: MAX_MODEL_LEN,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Absence of evidence is never a capability (ADR-318 §2).
|
||||||
|
let summary = slice.summarize();
|
||||||
|
let (floor, agg) = match summary.evidence {
|
||||||
|
SummaryEvidence::NoEvidence => return Err(CertifyError::NoEvidence),
|
||||||
|
SummaryEvidence::Aggregated { level, metrics, .. } => (level, metrics),
|
||||||
|
};
|
||||||
|
|
||||||
|
// The metrics must describe *this* context, or the claim is unbacked.
|
||||||
|
let ctx = slice.context();
|
||||||
|
if ctx.room != request.room.as_str() {
|
||||||
|
return Err(CertifyError::ContextMismatch { field: "room" });
|
||||||
|
}
|
||||||
|
if ctx.device != request.hardware.as_str() {
|
||||||
|
return Err(CertifyError::ContextMismatch { field: "device" });
|
||||||
|
}
|
||||||
|
if ctx.model_version != request.model_version {
|
||||||
|
return Err(CertifyError::ContextMismatch {
|
||||||
|
field: "model_version",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// The calibration certificate must be for the same room as the claim.
|
||||||
|
if request.calibration.space_id != request.room.as_str() {
|
||||||
|
return Err(CertifyError::CalibrationRoomMismatch);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Evidence level is inherited from the ledger and can never be upgraded.
|
||||||
|
if request.evidence_level > floor {
|
||||||
|
return Err(CertifyError::EvidenceLevelUpgrade {
|
||||||
|
requested: request.evidence_level,
|
||||||
|
floor,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// A certificate can never outlive the calibration it was validated against.
|
||||||
|
let calibration_expires_at_unix_s = request.calibration.expires_at_unix_s;
|
||||||
|
if request.valid_until_unix_s > calibration_expires_at_unix_s {
|
||||||
|
return Err(CertifyError::OutlivesCalibration {
|
||||||
|
valid_until_unix_s: request.valid_until_unix_s,
|
||||||
|
calibration_expires_at_unix_s,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let content = CertificateContent {
|
||||||
|
capability: request.capability,
|
||||||
|
room: request.room,
|
||||||
|
calibration_version: request.calibration.version,
|
||||||
|
calibration_expires_at_unix_s,
|
||||||
|
hardware: request.hardware,
|
||||||
|
model_version: request.model_version,
|
||||||
|
calibrated_date_unix_s: request.calibration.captured_at_unix_s,
|
||||||
|
metrics: OperatingMetrics {
|
||||||
|
moving_recall: agg.moving_recall,
|
||||||
|
stationary_recall: agg.stationary_recall,
|
||||||
|
false_presence_per_24h: agg.false_positive_rate,
|
||||||
|
},
|
||||||
|
valid_until_unix_s: request.valid_until_unix_s,
|
||||||
|
evidence_level: request.evidence_level,
|
||||||
|
};
|
||||||
|
|
||||||
|
let signature = signer.sign(&content.canonical_bytes());
|
||||||
|
Ok(CapabilityCertificate {
|
||||||
|
content,
|
||||||
|
signature: Some(signature),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Errors
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Errors raised at the minting boundary. No variant panics; a malformed or
|
||||||
|
/// dishonest request is always a returned error (CLAUDE.md).
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||||
|
pub enum CertifyError {
|
||||||
|
/// The evidence slice reports no evidence for the context — no capability.
|
||||||
|
#[error("no evidence for the context; a certificate cannot be minted")]
|
||||||
|
NoEvidence,
|
||||||
|
/// The slice's context does not match a bound field.
|
||||||
|
#[error("evidence slice context field `{field}` does not match the bound claim")]
|
||||||
|
ContextMismatch {
|
||||||
|
/// The mismatched field name.
|
||||||
|
field: &'static str,
|
||||||
|
},
|
||||||
|
/// The calibration certificate is for a different room than the claim.
|
||||||
|
#[error("calibration certificate room does not match the certified room")]
|
||||||
|
CalibrationRoomMismatch,
|
||||||
|
/// The requested evidence level exceeds the ledger floor (no upgrade).
|
||||||
|
#[error("requested evidence level {requested:?} exceeds ledger floor {floor:?}")]
|
||||||
|
EvidenceLevelUpgrade {
|
||||||
|
/// The requested (too-high) level.
|
||||||
|
requested: EvidenceLevel,
|
||||||
|
/// The ledger floor that caps it.
|
||||||
|
floor: EvidenceLevel,
|
||||||
|
},
|
||||||
|
/// `valid_until` is past the calibration expiry.
|
||||||
|
#[error(
|
||||||
|
"valid_until {valid_until_unix_s} outlives calibration expiry \
|
||||||
|
{calibration_expires_at_unix_s}"
|
||||||
|
)]
|
||||||
|
OutlivesCalibration {
|
||||||
|
/// The requested expiry.
|
||||||
|
valid_until_unix_s: i64,
|
||||||
|
/// The calibration ceiling it exceeded.
|
||||||
|
calibration_expires_at_unix_s: i64,
|
||||||
|
},
|
||||||
|
/// The model version exceeded [`MAX_MODEL_LEN`].
|
||||||
|
#[error("model version is {len} bytes, exceeds max {max}")]
|
||||||
|
ModelTooLong {
|
||||||
|
/// The offending length.
|
||||||
|
len: usize,
|
||||||
|
/// The maximum accepted length.
|
||||||
|
max: usize,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Length-prefixed field push (8-byte LE length + bytes) for unambiguous
|
||||||
|
/// canonical encoding.
|
||||||
|
fn push_field(out: &mut Vec<u8>, field: &[u8]) {
|
||||||
|
out.extend_from_slice(&(field.len() as u64).to_le_bytes());
|
||||||
|
out.extend_from_slice(field);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stable byte for an evidence level, ordered `L0 < … < L5`.
|
||||||
|
fn level_byte(level: EvidenceLevel) -> u8 {
|
||||||
|
match level {
|
||||||
|
EvidenceLevel::L0 => 0,
|
||||||
|
EvidenceLevel::L1 => 1,
|
||||||
|
EvidenceLevel::L2 => 2,
|
||||||
|
EvidenceLevel::L3 => 3,
|
||||||
|
EvidenceLevel::L4 => 4,
|
||||||
|
EvidenceLevel::L5 => 5,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests;
|
||||||
271
v2/crates/ruview-certify/src/tests.rs
Normal file
271
v2/crates/ruview-certify/src/tests.rs
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
//! Deterministic tests (ADR-318 validation matrix): mint+verify, no-evidence =>
|
||||||
|
//! no certificate, evidence-level floor enforced, expiry, unsigned invalid,
|
||||||
|
//! not-KNOWN domain invalidates, canonical-bytes determinism, serde round-trip,
|
||||||
|
//! calibration-linked expiry ceiling, and context binding. No wall clock, no
|
||||||
|
//! randomness; every fixture is synthetic (L0) and built in code.
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
use ruview_attest::{Blake3MacSigner, DeviceId};
|
||||||
|
use ruview_evidence::{
|
||||||
|
AccuracyMetrics, EvidenceContext, EvidenceLedger, EvidenceLevel as EvLevel, EvidenceRecord,
|
||||||
|
};
|
||||||
|
use ruview_ontology::SpaceId;
|
||||||
|
use wifi_densepose_calibration::{
|
||||||
|
CalibrationCertificate, CalibrationTier, CharacterizationSource, CompatibilityEnvelope,
|
||||||
|
EvidenceLevel as CalibLevel, KeyedHashSigner, MintParams, SpecialistBank,
|
||||||
|
};
|
||||||
|
use wifi_densepose_calibration::extract::AnchorFeature;
|
||||||
|
use wifi_densepose_calibration::AnchorLabel;
|
||||||
|
|
||||||
|
const ROOM: &str = "kitchen";
|
||||||
|
const DEVICE: &str = "dev-1";
|
||||||
|
const MODEL: &str = "m-1";
|
||||||
|
|
||||||
|
fn cert_signer() -> Blake3MacSigner {
|
||||||
|
Blake3MacSigner::new([7u8; 32])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A synthetic calibration certificate (L0) for `ROOM`, captured at
|
||||||
|
/// `captured_at`, valid for `validity` seconds.
|
||||||
|
fn calibration(captured_at: i64, validity: i64) -> CalibrationCertificate {
|
||||||
|
let anchors = vec![AnchorFeature::from_series(
|
||||||
|
ROOM,
|
||||||
|
AnchorLabel::Empty,
|
||||||
|
&[0.0, 0.1, 0.0, 0.1, 0.0, 0.1, 0.0, 0.1, 0.0, 0.1, 0.0, 0.1, 0.0, 0.1, 0.0, 0.1],
|
||||||
|
20.0,
|
||||||
|
)];
|
||||||
|
let bank = SpecialistBank::train(ROOM, "base-1", &anchors, captured_at).unwrap();
|
||||||
|
let signer = KeyedHashSigner::new("sensor-1", b"secret".to_vec());
|
||||||
|
let params = MintParams {
|
||||||
|
space_id: ROOM.into(),
|
||||||
|
sensor_id: "sensor-1".into(),
|
||||||
|
captured_at_unix_s: captured_at,
|
||||||
|
validity_secs: validity,
|
||||||
|
version: 1,
|
||||||
|
tier: CalibrationTier::Auto,
|
||||||
|
evidence: CalibLevel::L0Synthetic,
|
||||||
|
source: CharacterizationSource::Synthetic,
|
||||||
|
envelope: CompatibilityEnvelope::default(),
|
||||||
|
};
|
||||||
|
CalibrationCertificate::mint(params, &bank, &signer).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn context() -> EvidenceContext {
|
||||||
|
EvidenceContext::new(ROOM, DEVICE, "moving", MODEL).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn metrics() -> AccuracyMetrics {
|
||||||
|
AccuracyMetrics {
|
||||||
|
moving_recall: 0.8,
|
||||||
|
stationary_recall: 0.4,
|
||||||
|
false_positive_rate: 0.02,
|
||||||
|
drift: 0.1,
|
||||||
|
uncertainty: 0.05,
|
||||||
|
calibration_age_secs: 100,
|
||||||
|
sample_count: 10,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A ledger holding one synthetic (L0) record for `context()`.
|
||||||
|
fn synthetic_ledger() -> EvidenceLedger {
|
||||||
|
let mut ledger = EvidenceLedger::new();
|
||||||
|
ledger
|
||||||
|
.append(EvidenceRecord::synthetic(context(), metrics(), 1).unwrap())
|
||||||
|
.unwrap();
|
||||||
|
ledger
|
||||||
|
}
|
||||||
|
|
||||||
|
fn base_request<'c>(calibration: &'c CalibrationCertificate) -> MintRequest<'c> {
|
||||||
|
MintRequest {
|
||||||
|
capability: Capability::Presence,
|
||||||
|
room: SpaceId::new(ROOM).unwrap(),
|
||||||
|
hardware: DeviceId::new(DEVICE).unwrap(),
|
||||||
|
model_version: MODEL.into(),
|
||||||
|
calibration,
|
||||||
|
valid_until_unix_s: 5_000,
|
||||||
|
evidence_level: EvLevel::L0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mint_then_verify_round_trips_and_rejects_tampering() {
|
||||||
|
let calibration = calibration(1_000, 5_000); // expires at 6_000
|
||||||
|
let ledger = synthetic_ledger();
|
||||||
|
let slice = ledger.query(&context());
|
||||||
|
let signer = cert_signer();
|
||||||
|
|
||||||
|
let cert = mint(&signer, base_request(&calibration), &slice).unwrap();
|
||||||
|
|
||||||
|
// Frozen from the ledger slice, not a global average.
|
||||||
|
assert_eq!(cert.content.metrics.moving_recall, 0.8);
|
||||||
|
assert_eq!(cert.content.metrics.stationary_recall, 0.4);
|
||||||
|
assert_eq!(cert.content.metrics.false_presence_per_24h, 0.02);
|
||||||
|
// Synthetic ledger => L0 by construction (never upgraded).
|
||||||
|
assert_eq!(cert.content.evidence_level, EvLevel::L0);
|
||||||
|
// Calibration binding carried through.
|
||||||
|
assert_eq!(cert.content.calibration_version, 1);
|
||||||
|
assert_eq!(cert.content.calibration_expires_at_unix_s, 6_000);
|
||||||
|
assert_eq!(cert.content.calibrated_date_unix_s, 1_000);
|
||||||
|
|
||||||
|
assert!(cert.verify(&signer), "freshly minted certificate verifies");
|
||||||
|
|
||||||
|
// Tamper with a signed field: the signature no longer verifies.
|
||||||
|
let mut tampered = cert.clone();
|
||||||
|
tampered.content.metrics.moving_recall = 0.99;
|
||||||
|
assert!(!tampered.verify(&signer), "tampered metric is rejected");
|
||||||
|
|
||||||
|
let mut tampered2 = cert.clone();
|
||||||
|
tampered2.content.valid_until_unix_s += 1;
|
||||||
|
assert!(!tampered2.verify(&signer), "tampered expiry is rejected");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_evidence_context_yields_no_certificate() {
|
||||||
|
let calibration = calibration(1_000, 5_000);
|
||||||
|
let ledger = EvidenceLedger::new(); // empty
|
||||||
|
let slice = ledger.query(&context());
|
||||||
|
let signer = cert_signer();
|
||||||
|
|
||||||
|
let err = mint(&signer, base_request(&calibration), &slice).unwrap_err();
|
||||||
|
assert_eq!(err, CertifyError::NoEvidence);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn evidence_level_cannot_exceed_the_slice_floor() {
|
||||||
|
let calibration = calibration(1_000, 5_000);
|
||||||
|
// One measured L3 record => floor L3.
|
||||||
|
let mut ledger = EvidenceLedger::new();
|
||||||
|
ledger
|
||||||
|
.append(EvidenceRecord::measured(context(), metrics(), EvLevel::L3, "repro-1", 1).unwrap())
|
||||||
|
.unwrap();
|
||||||
|
let slice = ledger.query(&context());
|
||||||
|
let signer = cert_signer();
|
||||||
|
|
||||||
|
// Requesting L4 over an L3 floor is an upgrade — refused.
|
||||||
|
let mut req = base_request(&calibration);
|
||||||
|
req.evidence_level = EvLevel::L4;
|
||||||
|
let err = mint(&signer, req, &slice).unwrap_err();
|
||||||
|
assert_eq!(
|
||||||
|
err,
|
||||||
|
CertifyError::EvidenceLevelUpgrade {
|
||||||
|
requested: EvLevel::L4,
|
||||||
|
floor: EvLevel::L3,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Requesting at or below the floor is honest and permitted.
|
||||||
|
let mut req_ok = base_request(&calibration);
|
||||||
|
req_ok.evidence_level = EvLevel::L2;
|
||||||
|
let cert = mint(&signer, req_ok, &slice).unwrap();
|
||||||
|
assert_eq!(cert.content.evidence_level, EvLevel::L2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn valid_until_cannot_outlive_calibration() {
|
||||||
|
let calibration = calibration(1_000, 5_000); // expires 6_000
|
||||||
|
let ledger = synthetic_ledger();
|
||||||
|
let slice = ledger.query(&context());
|
||||||
|
let signer = cert_signer();
|
||||||
|
|
||||||
|
let mut req = base_request(&calibration);
|
||||||
|
req.valid_until_unix_s = 7_000; // past calibration expiry
|
||||||
|
let err = mint(&signer, req, &slice).unwrap_err();
|
||||||
|
assert_eq!(
|
||||||
|
err,
|
||||||
|
CertifyError::OutlivesCalibration {
|
||||||
|
valid_until_unix_s: 7_000,
|
||||||
|
calibration_expires_at_unix_s: 6_000,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_valid_enforces_expiry() {
|
||||||
|
let calibration = calibration(1_000, 5_000);
|
||||||
|
let ledger = synthetic_ledger();
|
||||||
|
let slice = ledger.query(&context());
|
||||||
|
let signer = cert_signer();
|
||||||
|
let cert = mint(&signer, base_request(&calibration), &slice).unwrap();
|
||||||
|
// valid_until = 5_000.
|
||||||
|
|
||||||
|
assert!(cert.is_valid(4_999, DomainState::Known), "before expiry");
|
||||||
|
assert!(!cert.is_valid(5_000, DomainState::Known), "at expiry");
|
||||||
|
assert!(!cert.is_valid(6_000, DomainState::Known), "after expiry");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unsigned_certificate_is_never_valid() {
|
||||||
|
let calibration = calibration(1_000, 5_000);
|
||||||
|
let ledger = synthetic_ledger();
|
||||||
|
let slice = ledger.query(&context());
|
||||||
|
let signer = cert_signer();
|
||||||
|
let cert = mint(&signer, base_request(&calibration), &slice).unwrap();
|
||||||
|
|
||||||
|
let unsigned = CapabilityCertificate::unsigned(cert.content.clone());
|
||||||
|
assert!(!unsigned.verify(&signer), "unsigned does not verify");
|
||||||
|
assert!(
|
||||||
|
!unsigned.is_valid(0, DomainState::Known),
|
||||||
|
"unsigned is never valid even fresh and KNOWN"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn non_known_domain_invalidates() {
|
||||||
|
let calibration = calibration(1_000, 5_000);
|
||||||
|
let ledger = synthetic_ledger();
|
||||||
|
let slice = ledger.query(&context());
|
||||||
|
let signer = cert_signer();
|
||||||
|
let cert = mint(&signer, base_request(&calibration), &slice).unwrap();
|
||||||
|
|
||||||
|
// Same instant, only the live domain signature differs.
|
||||||
|
assert!(cert.is_valid(4_000, DomainState::Known));
|
||||||
|
assert!(!cert.is_valid(4_000, DomainState::Degraded));
|
||||||
|
assert!(!cert.is_valid(4_000, DomainState::Unknown));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn context_mismatch_refuses_to_bind_metrics() {
|
||||||
|
let calibration = calibration(1_000, 5_000);
|
||||||
|
let ledger = synthetic_ledger();
|
||||||
|
let slice = ledger.query(&context());
|
||||||
|
let signer = cert_signer();
|
||||||
|
|
||||||
|
let mut req = base_request(&calibration);
|
||||||
|
req.hardware = DeviceId::new("other-device").unwrap();
|
||||||
|
let err = mint(&signer, req, &slice).unwrap_err();
|
||||||
|
assert_eq!(err, CertifyError::ContextMismatch { field: "device" });
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn canonical_bytes_are_deterministic() {
|
||||||
|
let calibration = calibration(1_000, 5_000);
|
||||||
|
let ledger = synthetic_ledger();
|
||||||
|
let slice = ledger.query(&context());
|
||||||
|
let signer = cert_signer();
|
||||||
|
|
||||||
|
let a = mint(&signer, base_request(&calibration), &slice).unwrap();
|
||||||
|
let b = mint(&signer, base_request(&calibration), &slice).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
a.content.canonical_bytes(),
|
||||||
|
b.content.canonical_bytes(),
|
||||||
|
"identical content => identical bytes"
|
||||||
|
);
|
||||||
|
assert_eq!(a, b, "mint is a pure function of its inputs");
|
||||||
|
assert_eq!(a.signature, b.signature);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn serde_round_trips() {
|
||||||
|
let calibration = calibration(1_000, 5_000);
|
||||||
|
let ledger = synthetic_ledger();
|
||||||
|
let slice = ledger.query(&context());
|
||||||
|
let signer = cert_signer();
|
||||||
|
let cert = mint(&signer, base_request(&calibration), &slice).unwrap();
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&cert).unwrap();
|
||||||
|
let back: CapabilityCertificate = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(cert, back);
|
||||||
|
// The deserialized certificate still verifies against the same key.
|
||||||
|
assert!(back.verify(&signer));
|
||||||
|
}
|
||||||
17
v2/crates/ruview-counterfactual/Cargo.toml
Normal file
17
v2/crates/ruview-counterfactual/Cargo.toml
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
[package]
|
||||||
|
name = "ruview-counterfactual"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
authors.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
thiserror.workspace = true
|
||||||
|
serde = { workspace = true, features = ["derive"] }
|
||||||
|
ruview-ontology = { path = "../ruview-ontology" }
|
||||||
|
ruview-fusion = { path = "../ruview-fusion" }
|
||||||
|
ruview-twin = { path = "../ruview-twin" }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
serde_json.workspace = true
|
||||||
150
v2/crates/ruview-counterfactual/src/hypothesis.rs
Normal file
150
v2/crates/ruview-counterfactual/src/hypothesis.rs
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
//! Scene hypotheses over the canonical ontology (ADR-313 §1).
|
||||||
|
//!
|
||||||
|
//! **SYNTHETIC / L0 — a research-forward model scaffold, not a measurement
|
||||||
|
//! system.** A [`Hypothesis`] is a *hypothesized* scene state — an occupant
|
||||||
|
//! count and their coarse positions — expressed over the ADR-306 canonical
|
||||||
|
//! [`SpaceId`] so a counterfactual result is a governed spatial statement, not
|
||||||
|
//! an opaque score. Nothing here is a hardware, `MEASURED`, or accuracy claim,
|
||||||
|
//! and this crate asserts **no** discrimination-accuracy number (ADR-313
|
||||||
|
//! evidence discipline).
|
||||||
|
//!
|
||||||
|
//! Hypotheses are drawn from (and score *relative to*) the ADR-311 fused world
|
||||||
|
//! state and its neighbourhood: the current estimate, the **null hypothesis**
|
||||||
|
//! (nobody present, [`Hypothesis::empty`]), and a bounded set of nearby
|
||||||
|
//! alternatives (±1 occupant, shifted position). Positions are a coarse metric
|
||||||
|
//! abstraction in the twin's local frame, not surveyed coordinates.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
use ruview_ontology::SpaceId;
|
||||||
|
|
||||||
|
/// Upper bound on occupants in a single hypothesis. Bounds allocation and the
|
||||||
|
/// per-link scoring cost on untrusted input; construction beyond this is
|
||||||
|
/// rejected, never truncated.
|
||||||
|
pub const MAX_OCCUPANTS: usize = 64;
|
||||||
|
|
||||||
|
/// Upper bound on hypotheses evaluated in one call. Bounds allocation on
|
||||||
|
/// untrusted input.
|
||||||
|
pub const MAX_HYPOTHESES: usize = 256;
|
||||||
|
|
||||||
|
/// One hypothesized occupant at a coarse metric position in the twin's frame.
|
||||||
|
///
|
||||||
|
/// **SYNTHETIC.** An occupant is modelled by the counterfactual layer as a body
|
||||||
|
/// that attenuates any link whose line of sight passes near it (see
|
||||||
|
/// [`crate::infer`]); it is a hypothesis element, never evidence of a person.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct Occupant {
|
||||||
|
/// Coarse `x` position, metres, in the twin's local frame.
|
||||||
|
pub x: f64,
|
||||||
|
/// Coarse `y` position, metres, in the twin's local frame.
|
||||||
|
pub y: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Occupant {
|
||||||
|
/// Construct an occupant at a coarse position.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn new(x: f64, y: f64) -> Self {
|
||||||
|
Self { x, y }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The horizontal-plane position `(x, y)` used for link-blocking geometry.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn xy(&self) -> (f64, f64) {
|
||||||
|
(self.x, self.y)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True when both coordinates are finite (rejects `NaN`/`inf`).
|
||||||
|
#[must_use]
|
||||||
|
pub fn is_finite(&self) -> bool {
|
||||||
|
self.x.is_finite() && self.y.is_finite()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A hypothesized scene state: how many occupants are present and where.
|
||||||
|
///
|
||||||
|
/// **SYNTHETIC / L0.** The occupant count is `occupants.len()`; the **null
|
||||||
|
/// hypothesis** ([`Hypothesis::empty`]) is an empty occupant set — *nobody
|
||||||
|
/// present*. The [`id`](Self::id) is a stable label used both for reporting the
|
||||||
|
/// best explanation and as a deterministic tie-break in ranking.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct Hypothesis {
|
||||||
|
/// Stable, caller-supplied label (e.g. `"empty"`, `"one"`, `"two"`). Used to
|
||||||
|
/// report the best explanation and to break ranking ties deterministically.
|
||||||
|
pub id: String,
|
||||||
|
/// The ontology space this hypothesis is over (governed spatial statement).
|
||||||
|
pub space: SpaceId,
|
||||||
|
/// The hypothesized occupants; `occupants.len()` is the occupant count. An
|
||||||
|
/// empty vector is the null (nobody-present) hypothesis.
|
||||||
|
pub occupants: Vec<Occupant>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Hypothesis {
|
||||||
|
/// The **null hypothesis**: nobody present in `space`.
|
||||||
|
#[must_use]
|
||||||
|
pub fn empty(id: impl Into<String>, space: SpaceId) -> Self {
|
||||||
|
Self {
|
||||||
|
id: id.into(),
|
||||||
|
space,
|
||||||
|
occupants: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Construct and validate a hypothesis. Rejects non-finite occupant
|
||||||
|
/// positions and occupant counts above [`MAX_OCCUPANTS`] at the boundary;
|
||||||
|
/// never panics on malformed input.
|
||||||
|
pub fn new(
|
||||||
|
id: impl Into<String>,
|
||||||
|
space: SpaceId,
|
||||||
|
occupants: Vec<Occupant>,
|
||||||
|
) -> Result<Self, HypothesisError> {
|
||||||
|
if occupants.len() > MAX_OCCUPANTS {
|
||||||
|
return Err(HypothesisError::TooManyOccupants {
|
||||||
|
len: occupants.len(),
|
||||||
|
max: MAX_OCCUPANTS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (i, occ) in occupants.iter().enumerate() {
|
||||||
|
if !occ.is_finite() {
|
||||||
|
return Err(HypothesisError::NonFinitePosition { index: i });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Self {
|
||||||
|
id: id.into(),
|
||||||
|
space,
|
||||||
|
occupants,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The hypothesized occupant count.
|
||||||
|
#[must_use]
|
||||||
|
pub fn occupant_count(&self) -> usize {
|
||||||
|
self.occupants.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True when this is the null (nobody-present) hypothesis.
|
||||||
|
#[must_use]
|
||||||
|
pub fn is_null(&self) -> bool {
|
||||||
|
self.occupants.is_empty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Boundary errors from constructing a hypothesis. Malformed input yields one of
|
||||||
|
/// these; it never panics.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Error)]
|
||||||
|
pub enum HypothesisError {
|
||||||
|
/// An occupant carried a non-finite coordinate.
|
||||||
|
#[error("non-finite occupant position at index {index}")]
|
||||||
|
NonFinitePosition {
|
||||||
|
/// Offending occupant index.
|
||||||
|
index: usize,
|
||||||
|
},
|
||||||
|
/// More occupants than [`MAX_OCCUPANTS`].
|
||||||
|
#[error("too many occupants: {len} exceeds maximum {max}")]
|
||||||
|
TooManyOccupants {
|
||||||
|
/// Actual count.
|
||||||
|
len: usize,
|
||||||
|
/// The enforced maximum.
|
||||||
|
max: usize,
|
||||||
|
},
|
||||||
|
}
|
||||||
483
v2/crates/ruview-counterfactual/src/infer.rs
Normal file
483
v2/crates/ruview-counterfactual/src/infer.rs
Normal file
@@ -0,0 +1,483 @@
|
|||||||
|
//! Counterfactual scoring and best-explanation selection (ADR-313 §2, §3).
|
||||||
|
//!
|
||||||
|
//! **SYNTHETIC / L0 — a research-forward generative-scoring scaffold, not a
|
||||||
|
//! measurement system.** This module scores a small set of scene
|
||||||
|
//! [`Hypothesis`](crate::Hypothesis) against an observed link-measurement set,
|
||||||
|
//! using the ADR-315 [`RfTwin`] as the generative forward model. It is a
|
||||||
|
//! *consumer* of the twin, not a second simulator: the twin supplies the
|
||||||
|
//! baseline expected distribution per link (geometry + propagation), and this
|
||||||
|
//! layer applies a **documented SYNTHETIC occupant-attenuation model** on top —
|
||||||
|
//! a hypothesized occupant attenuates any link whose line of sight passes near
|
||||||
|
//! it. Nothing here is a hardware, `MEASURED`, or accuracy claim; this crate
|
||||||
|
//! asserts **no** discrimination-accuracy number (ADR-313 evidence discipline).
|
||||||
|
//!
|
||||||
|
//! ## Likelihood (documented SYNTHETIC)
|
||||||
|
//!
|
||||||
|
//! For each observed link with a known base distribution `N(m0, v0)` from the
|
||||||
|
//! twin, the occupant-adjusted distribution under a hypothesis is
|
||||||
|
//! `N(m0 - att, v0 + extra)`, where `att`/`extra` accumulate a linear-falloff
|
||||||
|
//! body effect over occupants that block the link. The per-link explanatory
|
||||||
|
//! score is the Gaussian log-likelihood of the observed value under that
|
||||||
|
//! adjusted distribution; a hypothesis's score is the sum over evaluated links.
|
||||||
|
//! This is a deliberately simple, deterministic model — clearly a scaffold, not
|
||||||
|
//! real RF.
|
||||||
|
//!
|
||||||
|
//! ## UNKNOWN is first-class (ADR-300 rule 1, ADR-313 §3)
|
||||||
|
//!
|
||||||
|
//! The layer never forces a label. It returns [`BestExplanation::Unknown`] when
|
||||||
|
//! the top two hypotheses are near-indistinguishable (margin below threshold),
|
||||||
|
//! when **no** hypothesis explains the observation well (best mean per-link
|
||||||
|
//! log-likelihood below a floor — the observation is outside what the twin can
|
||||||
|
//! account for, routed to the ADR-302 UNKNOWN verdict rather than a forced
|
||||||
|
//! occupancy label), when no hypotheses are supplied, or when no observed link
|
||||||
|
//! is evaluable against the twin.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use ruview_ontology::{EvidenceLevel, SemanticProvenance};
|
||||||
|
use ruview_twin::{
|
||||||
|
ExpectedDistribution, LinkId, LinkObservation, ObservationSet, RfTwin,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::hypothesis::Hypothesis;
|
||||||
|
|
||||||
|
/// `2π`, used in the Gaussian log-likelihood normaliser.
|
||||||
|
const TAU: f64 = std::f64::consts::TAU;
|
||||||
|
|
||||||
|
/// The SYNTHETIC occupant → link effect. A hypothesized occupant within
|
||||||
|
/// [`body_radius_m`](Self::body_radius_m) of a link's line of sight attenuates
|
||||||
|
/// it (and adds variance), with a linear falloff to zero at the radius edge.
|
||||||
|
///
|
||||||
|
/// **SYNTHETIC.** These are model parameters of a didactic occupant model, not a
|
||||||
|
/// calibrated RF body-shadowing fit.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct OccupantModel {
|
||||||
|
/// Perpendicular distance (metres) within which an occupant blocks a link.
|
||||||
|
/// Must be finite and `> 0`.
|
||||||
|
pub body_radius_m: f64,
|
||||||
|
/// Maximum attenuation (dB) added to a directly-blocked link (falloff → 0 at
|
||||||
|
/// the radius edge). Must be finite and `>= 0`.
|
||||||
|
pub attenuation_db: f64,
|
||||||
|
/// Maximum extra variance (dB²) added to a directly-blocked link's expected
|
||||||
|
/// distribution. Must be finite and `>= 0`.
|
||||||
|
pub extra_variance_db2: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OccupantModel {
|
||||||
|
/// A neutral SYNTHETIC default: `0.9 m` body radius, `6 dB` peak
|
||||||
|
/// attenuation, `4 dB²` peak extra variance. Asserts nothing about any real
|
||||||
|
/// body or environment.
|
||||||
|
#[must_use]
|
||||||
|
pub fn default_body() -> Self {
|
||||||
|
Self {
|
||||||
|
body_radius_m: 0.9,
|
||||||
|
attenuation_db: 6.0,
|
||||||
|
extra_variance_db2: 4.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True when every parameter is in its valid domain.
|
||||||
|
#[must_use]
|
||||||
|
pub fn is_valid(&self) -> bool {
|
||||||
|
self.body_radius_m.is_finite()
|
||||||
|
&& self.body_radius_m > 0.0
|
||||||
|
&& self.attenuation_db.is_finite()
|
||||||
|
&& self.attenuation_db >= 0.0
|
||||||
|
&& self.extra_variance_db2.is_finite()
|
||||||
|
&& self.extra_variance_db2 >= 0.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for OccupantModel {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::default_body()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Thresholds that route a scored hypothesis set to a best explanation or to a
|
||||||
|
/// first-class UNKNOWN verdict (ADR-313 §3).
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct ScoringConfig {
|
||||||
|
/// Minimum log-likelihood-ratio margin (nats) between the top two
|
||||||
|
/// hypotheses required to declare a best explanation; below this the two are
|
||||||
|
/// near-indistinguishable and the result is UNKNOWN. Must be finite `>= 0`.
|
||||||
|
pub min_margin_nats: f64,
|
||||||
|
/// Minimum best *mean per-link* log-likelihood (nats) required for any
|
||||||
|
/// hypothesis to count as explaining the observation; below this the
|
||||||
|
/// observation is outside what the twin can account for and the result is
|
||||||
|
/// UNKNOWN (routed to the ADR-302 verdict). Must be finite.
|
||||||
|
pub min_mean_log_likelihood: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ScoringConfig {
|
||||||
|
/// Neutral SYNTHETIC defaults: a `0.5`-nat margin and a `-10.0`-nat mean
|
||||||
|
/// per-link floor. These are model gates, not calibrated error rates.
|
||||||
|
#[must_use]
|
||||||
|
pub fn default_gates() -> Self {
|
||||||
|
Self {
|
||||||
|
min_margin_nats: 0.5,
|
||||||
|
min_mean_log_likelihood: -10.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ScoringConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::default_gates()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The explanatory score of one hypothesis against the observed measurements.
|
||||||
|
///
|
||||||
|
/// **SYNTHETIC / L0.** `log_likelihood` is a model-relative explanatory score
|
||||||
|
/// (summed Gaussian log-likelihood under the twin + occupant model), never a
|
||||||
|
/// detection or accuracy claim.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct HypothesisScore {
|
||||||
|
/// The scored hypothesis.
|
||||||
|
pub hypothesis: Hypothesis,
|
||||||
|
/// Summed per-link Gaussian log-likelihood over evaluated links (nats).
|
||||||
|
/// Higher ⇒ this hypothesis better explains the observation.
|
||||||
|
pub log_likelihood: f64,
|
||||||
|
/// Number of observed links evaluated against a known twin distribution.
|
||||||
|
pub evaluated_links: usize,
|
||||||
|
/// Number of observed links that could not be evaluated (unknown under the
|
||||||
|
/// twin, or a non-finite / undefined likelihood); first-class, never an
|
||||||
|
/// error.
|
||||||
|
pub unknown_links: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HypothesisScore {
|
||||||
|
/// Mean per-link log-likelihood, or `None` when no link was evaluable.
|
||||||
|
#[must_use]
|
||||||
|
pub fn mean_log_likelihood(&self) -> Option<f64> {
|
||||||
|
(self.evaluated_links > 0).then(|| self.log_likelihood / self.evaluated_links as f64)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The best-explanation outcome. Either one hypothesis explains the observation
|
||||||
|
/// with a positive margin, or the result is a first-class UNKNOWN.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "outcome", rename_all = "snake_case")]
|
||||||
|
pub enum BestExplanation {
|
||||||
|
/// One hypothesis best explains the observation, by `margin` nats over the
|
||||||
|
/// runner-up (`f64::INFINITY` when it is the only hypothesis).
|
||||||
|
Explained {
|
||||||
|
/// The winning hypothesis's stable id.
|
||||||
|
hypothesis_id: String,
|
||||||
|
/// Its hypothesized occupant count.
|
||||||
|
occupant_count: usize,
|
||||||
|
/// Log-likelihood-ratio margin (nats) over the runner-up.
|
||||||
|
margin: f64,
|
||||||
|
},
|
||||||
|
/// No confident best explanation; carries a first-class reason (ADR-300
|
||||||
|
/// rule 1, ADR-313 §3).
|
||||||
|
Unknown {
|
||||||
|
/// Why the result is UNKNOWN.
|
||||||
|
reason: UnknownReason,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Why a counterfactual evaluation resolved to UNKNOWN instead of a confident
|
||||||
|
/// best explanation. UNKNOWN is a value, not an error.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "reason", rename_all = "snake_case")]
|
||||||
|
pub enum UnknownReason {
|
||||||
|
/// No hypotheses were supplied to evaluate.
|
||||||
|
NoHypotheses,
|
||||||
|
/// No observed link was evaluable against the twin (nothing to score).
|
||||||
|
NoEvaluableLinks,
|
||||||
|
/// The top two hypotheses are near-indistinguishable: the margin fell below
|
||||||
|
/// the configured threshold.
|
||||||
|
NearIndistinguishable {
|
||||||
|
/// The observed margin (nats).
|
||||||
|
margin: f64,
|
||||||
|
/// The configured minimum margin (nats).
|
||||||
|
threshold: f64,
|
||||||
|
},
|
||||||
|
/// No hypothesis explains the observation well: the best mean per-link
|
||||||
|
/// log-likelihood fell below the configured floor. The observation is
|
||||||
|
/// outside what the twin can account for (routes to the ADR-302 verdict).
|
||||||
|
NoHypothesisExplains {
|
||||||
|
/// The best hypothesis's mean per-link log-likelihood (nats).
|
||||||
|
best_mean_log_likelihood: f64,
|
||||||
|
/// The configured floor (nats).
|
||||||
|
floor: f64,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The typed result of a counterfactual evaluation.
|
||||||
|
///
|
||||||
|
/// **SYNTHETIC / L0.** Every score and the verdict are model-relative and
|
||||||
|
/// inherit the twin's `L0` evidence level; nothing here is a camera-grade or
|
||||||
|
/// `MEASURED` claim (CLAUDE.md honesty rule, ADR-313 evidence discipline).
|
||||||
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct CounterfactualResult {
|
||||||
|
/// Every hypothesis's score, ranked best-first: descending by
|
||||||
|
/// `log_likelihood`, ties broken by ascending hypothesis id (deterministic).
|
||||||
|
pub ranked: Vec<HypothesisScore>,
|
||||||
|
/// The best explanation, or a first-class UNKNOWN.
|
||||||
|
pub best: BestExplanation,
|
||||||
|
/// Evidence level of the verdict — inherits the weakest input; the twin is
|
||||||
|
/// `L0` (SYNTHETIC), so a counterfactual verdict is always `L0`.
|
||||||
|
pub evidence_level: EvidenceLevel,
|
||||||
|
/// Provenance travelling with the verdict.
|
||||||
|
pub provenance: SemanticProvenance,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CounterfactualResult {
|
||||||
|
/// The top-ranked hypothesis score, if any hypotheses were scored.
|
||||||
|
#[must_use]
|
||||||
|
pub fn top(&self) -> Option<&HypothesisScore> {
|
||||||
|
self.ranked.first()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Perpendicular distance from point `p` to segment `a`–`b` (metres). Clamps to
|
||||||
|
/// the endpoints for a point beyond the segment; handles a degenerate
|
||||||
|
/// (zero-length) segment as point distance. Deterministic and allocation-free.
|
||||||
|
fn point_segment_distance(p: (f64, f64), a: (f64, f64), b: (f64, f64)) -> f64 {
|
||||||
|
let (px, py) = p;
|
||||||
|
let (ax, ay) = a;
|
||||||
|
let (bx, by) = b;
|
||||||
|
let dx = bx - ax;
|
||||||
|
let dy = by - ay;
|
||||||
|
let len2 = dx * dx + dy * dy;
|
||||||
|
if len2 <= f64::EPSILON {
|
||||||
|
return ((px - ax).powi(2) + (py - ay).powi(2)).sqrt();
|
||||||
|
}
|
||||||
|
let t = (((px - ax) * dx + (py - ay) * dy) / len2).clamp(0.0, 1.0);
|
||||||
|
let cx = ax + t * dx;
|
||||||
|
let cy = ay + t * dy;
|
||||||
|
((px - cx).powi(2) + (py - cy).powi(2)).sqrt()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Accumulated occupant effect (`attenuation_db`, `extra_variance_db2`) on one
|
||||||
|
/// link from a hypothesis's occupants under the model. Non-finite occupant
|
||||||
|
/// positions and links whose endpoints are absent from the twin contribute
|
||||||
|
/// nothing (defensive; never panics).
|
||||||
|
fn occupant_effect(
|
||||||
|
twin: &RfTwin,
|
||||||
|
link: &LinkId,
|
||||||
|
hypothesis: &Hypothesis,
|
||||||
|
model: &OccupantModel,
|
||||||
|
) -> (f64, f64) {
|
||||||
|
let (a, b) = match (twin.node(&link.a), twin.node(&link.b)) {
|
||||||
|
(Some(a), Some(b)) => (a.position.xy(), b.position.xy()),
|
||||||
|
_ => return (0.0, 0.0),
|
||||||
|
};
|
||||||
|
let mut att = 0.0;
|
||||||
|
let mut extra = 0.0;
|
||||||
|
for occ in &hypothesis.occupants {
|
||||||
|
if !occ.is_finite() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let d = point_segment_distance(occ.xy(), a, b);
|
||||||
|
if d < model.body_radius_m {
|
||||||
|
// Linear falloff to zero at the radius edge (SYNTHETIC).
|
||||||
|
let shield = 1.0 - d / model.body_radius_m;
|
||||||
|
att += model.attenuation_db * shield;
|
||||||
|
extra += model.extra_variance_db2 * shield;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(att, extra)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The occupant-adjusted expected distribution `(mean, variance)` for a link
|
||||||
|
/// under a hypothesis, or `None` when the twin cannot predict the link or the
|
||||||
|
/// adjusted distribution is degenerate (non-positive / non-finite variance).
|
||||||
|
#[must_use]
|
||||||
|
fn adjusted_distribution(
|
||||||
|
twin: &RfTwin,
|
||||||
|
link: &LinkId,
|
||||||
|
hypothesis: &Hypothesis,
|
||||||
|
model: &OccupantModel,
|
||||||
|
) -> Option<(f64, f64)> {
|
||||||
|
let (m0, v0) = match twin.predict(link) {
|
||||||
|
ExpectedDistribution::Known { mean, variance, .. } => (mean, variance),
|
||||||
|
ExpectedDistribution::Unknown { .. } => return None,
|
||||||
|
};
|
||||||
|
let (att, extra) = occupant_effect(twin, link, hypothesis, model);
|
||||||
|
let mean = m0 - att;
|
||||||
|
let variance = v0 + extra;
|
||||||
|
if mean.is_finite() && variance.is_finite() && variance > 0.0 {
|
||||||
|
Some((mean, variance))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gaussian log-likelihood of `observed` under `N(mean, variance)` (nats).
|
||||||
|
/// `variance` is required `> 0` by the caller; returns `None` on a non-finite
|
||||||
|
/// result rather than propagating a poisoned score.
|
||||||
|
#[must_use]
|
||||||
|
fn gaussian_log_likelihood(observed: f64, mean: f64, variance: f64) -> Option<f64> {
|
||||||
|
let dev = observed - mean;
|
||||||
|
let ll = -0.5 * (TAU * variance).ln() - (dev * dev) / (2.0 * variance);
|
||||||
|
ll.is_finite().then_some(ll)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Score one hypothesis against the observed set under the twin and occupant
|
||||||
|
/// model. Deterministic; allocation is bounded by the observation count.
|
||||||
|
#[must_use]
|
||||||
|
pub fn score_hypothesis(
|
||||||
|
twin: &RfTwin,
|
||||||
|
observed: &ObservationSet,
|
||||||
|
hypothesis: &Hypothesis,
|
||||||
|
model: &OccupantModel,
|
||||||
|
) -> HypothesisScore {
|
||||||
|
let mut log_likelihood = 0.0;
|
||||||
|
let mut evaluated_links = 0usize;
|
||||||
|
let mut unknown_links = 0usize;
|
||||||
|
|
||||||
|
for LinkObservation { link, value } in &observed.observations {
|
||||||
|
match adjusted_distribution(twin, link, hypothesis, model) {
|
||||||
|
Some((mean, variance)) => match gaussian_log_likelihood(*value, mean, variance) {
|
||||||
|
Some(ll) => {
|
||||||
|
log_likelihood += ll;
|
||||||
|
evaluated_links += 1;
|
||||||
|
}
|
||||||
|
None => unknown_links += 1,
|
||||||
|
},
|
||||||
|
None => unknown_links += 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HypothesisScore {
|
||||||
|
hypothesis: hypothesis.clone(),
|
||||||
|
log_likelihood,
|
||||||
|
evaluated_links,
|
||||||
|
unknown_links,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Evaluate counterfactual hypotheses against an observed measurement set using
|
||||||
|
/// the [`OccupantModel::default_body`] and [`ScoringConfig::default_gates`].
|
||||||
|
#[must_use]
|
||||||
|
pub fn evaluate(
|
||||||
|
twin: &RfTwin,
|
||||||
|
observed: &ObservationSet,
|
||||||
|
hypotheses: &[Hypothesis],
|
||||||
|
) -> CounterfactualResult {
|
||||||
|
evaluate_with(
|
||||||
|
twin,
|
||||||
|
observed,
|
||||||
|
hypotheses,
|
||||||
|
&OccupantModel::default_body(),
|
||||||
|
&ScoringConfig::default_gates(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Evaluate counterfactual hypotheses with explicit model and scoring gates.
|
||||||
|
///
|
||||||
|
/// Scores every hypothesis, ranks them best-first (deterministically), and
|
||||||
|
/// selects a best explanation with a margin — or a first-class UNKNOWN when the
|
||||||
|
/// top two are near-indistinguishable, when no hypothesis explains the
|
||||||
|
/// observation, when no hypotheses are supplied, or when no link is evaluable.
|
||||||
|
///
|
||||||
|
/// **SYNTHETIC / L0.** The verdict inherits the twin's `L0` evidence level and
|
||||||
|
/// is never a camera-grade or `MEASURED` claim.
|
||||||
|
#[must_use]
|
||||||
|
pub fn evaluate_with(
|
||||||
|
twin: &RfTwin,
|
||||||
|
observed: &ObservationSet,
|
||||||
|
hypotheses: &[Hypothesis],
|
||||||
|
model: &OccupantModel,
|
||||||
|
config: &ScoringConfig,
|
||||||
|
) -> CounterfactualResult {
|
||||||
|
let provenance = SemanticProvenance::declared("ruview-counterfactual@0 (SYNTHETIC/L0)");
|
||||||
|
let evidence_level = EvidenceLevel::L0;
|
||||||
|
|
||||||
|
// Score every hypothesis, then rank deterministically (bounded input).
|
||||||
|
let capped = hypotheses.len().min(crate::hypothesis::MAX_HYPOTHESES);
|
||||||
|
let mut ranked: Vec<HypothesisScore> = hypotheses[..capped]
|
||||||
|
.iter()
|
||||||
|
.map(|h| score_hypothesis(twin, observed, h, model))
|
||||||
|
.collect();
|
||||||
|
// Descending by log-likelihood; ties broken by ascending hypothesis id.
|
||||||
|
ranked.sort_by(|a, b| {
|
||||||
|
b.log_likelihood
|
||||||
|
.total_cmp(&a.log_likelihood)
|
||||||
|
.then_with(|| a.hypothesis.id.cmp(&b.hypothesis.id))
|
||||||
|
});
|
||||||
|
|
||||||
|
let best = decide(&ranked, config);
|
||||||
|
|
||||||
|
CounterfactualResult {
|
||||||
|
ranked,
|
||||||
|
best,
|
||||||
|
evidence_level,
|
||||||
|
provenance,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Select the best explanation (or UNKNOWN) from a ranked score list.
|
||||||
|
fn decide(ranked: &[HypothesisScore], config: &ScoringConfig) -> BestExplanation {
|
||||||
|
let Some(top) = ranked.first() else {
|
||||||
|
return BestExplanation::Unknown {
|
||||||
|
reason: UnknownReason::NoHypotheses,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Nothing was evaluable against the twin ⇒ nothing to explain.
|
||||||
|
let Some(mean_ll) = top.mean_log_likelihood() else {
|
||||||
|
return BestExplanation::Unknown {
|
||||||
|
reason: UnknownReason::NoEvaluableLinks,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// No hypothesis explains the observation well ⇒ ADR-302 UNKNOWN verdict.
|
||||||
|
if mean_ll < config.min_mean_log_likelihood {
|
||||||
|
return BestExplanation::Unknown {
|
||||||
|
reason: UnknownReason::NoHypothesisExplains {
|
||||||
|
best_mean_log_likelihood: mean_ll,
|
||||||
|
floor: config.min_mean_log_likelihood,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Margin over the runner-up (infinite when the top is the only hypothesis).
|
||||||
|
let margin = match ranked.get(1) {
|
||||||
|
Some(second) => top.log_likelihood - second.log_likelihood,
|
||||||
|
None => f64::INFINITY,
|
||||||
|
};
|
||||||
|
|
||||||
|
if margin < config.min_margin_nats {
|
||||||
|
return BestExplanation::Unknown {
|
||||||
|
reason: UnknownReason::NearIndistinguishable {
|
||||||
|
margin,
|
||||||
|
threshold: config.min_margin_nats,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
BestExplanation::Explained {
|
||||||
|
hypothesis_id: top.hypothesis.id.clone(),
|
||||||
|
occupant_count: top.hypothesis.occupant_count(),
|
||||||
|
margin,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Synthesize the observation set a scene would produce under the twin and
|
||||||
|
/// occupant model — the occupant-adjusted mean of every twin link with a known
|
||||||
|
/// base distribution. The empty hypothesis reproduces the twin's own
|
||||||
|
/// predictions (the zero-occupant reference); a populated hypothesis attenuates
|
||||||
|
/// the blocked links.
|
||||||
|
///
|
||||||
|
/// **SYNTHETIC.** This is a deterministic simulation fixture for exploring and
|
||||||
|
/// testing counterfactual scoring — not a measurement and not a sampler (no
|
||||||
|
/// randomness). Links the twin cannot predict are omitted.
|
||||||
|
#[must_use]
|
||||||
|
pub fn synthesize_observations(
|
||||||
|
twin: &RfTwin,
|
||||||
|
hypothesis: &Hypothesis,
|
||||||
|
model: &OccupantModel,
|
||||||
|
) -> ObservationSet {
|
||||||
|
let mut observed = ObservationSet::new();
|
||||||
|
for link in twin.links() {
|
||||||
|
if let Some((mean, _variance)) = adjusted_distribution(twin, &link, hypothesis, model) {
|
||||||
|
observed = observed.with(link, mean);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
observed
|
||||||
|
}
|
||||||
375
v2/crates/ruview-counterfactual/src/lib.rs
Normal file
375
v2/crates/ruview-counterfactual/src/lib.rs
Normal file
@@ -0,0 +1,375 @@
|
|||||||
|
//! # `ruview-counterfactual` — counterfactual spatial inference (ADR-313, ADR-300 phase 3)
|
||||||
|
//!
|
||||||
|
//! **SYNTHETIC / L0 — a research-forward generative-scoring scaffold, not a
|
||||||
|
//! measurement system.**
|
||||||
|
//!
|
||||||
|
//! This crate is a *phase-3, research-forward primitive*: a step beyond
|
||||||
|
//! discriminative classifiers toward a **generative spatial model**. A
|
||||||
|
//! classifier maps measurements to a label; it cannot say *"the observation is
|
||||||
|
//! better explained by absence"* or *"one occupant explains this better than
|
||||||
|
//! two,"* because it has no model of what a measurement *should* look like under
|
||||||
|
//! a hypothesized world state. This layer supplies that missing piece: given an
|
||||||
|
//! observed link-measurement set and the ADR-315 digital RF twin as the
|
||||||
|
//! generative forward model, it scores a small set of scene
|
||||||
|
//! [`Hypothesis`](Hypothesis) — including the **null hypothesis** (nobody
|
||||||
|
//! present) — and returns the maximum-likelihood explanation with a **margin**,
|
||||||
|
//! or a first-class `UNKNOWN` when the hypotheses are near-indistinguishable.
|
||||||
|
//!
|
||||||
|
//! It is a **consumer** of the twin, never a second simulator (ADR-313 option 3,
|
||||||
|
//! rejecting option 2): the ADR-315 twin supplies each link's baseline expected
|
||||||
|
//! distribution (geometry + propagation), and this layer applies a **documented
|
||||||
|
//! SYNTHETIC occupant-attenuation model** — a hypothesized occupant attenuates
|
||||||
|
//! any link whose line of sight passes near it. Hypotheses are drawn from the
|
||||||
|
//! ADR-311 fused world state and its neighbourhood and are expressed over the
|
||||||
|
//! canonical ADR-306 [`SpaceId`](ruview_ontology::SpaceId), so a counterfactual
|
||||||
|
//! result is a governed spatial statement, not an opaque score (ADR-300 rule 3).
|
||||||
|
//!
|
||||||
|
//! ## Honesty and evidence discipline (CLAUDE.md, ADR-313)
|
||||||
|
//!
|
||||||
|
//! Every likelihood is a **model-relative** score under a twin whose
|
||||||
|
//! distributions are a simulation at evidence level `L0`, labelled `SYNTHETIC`.
|
||||||
|
//! A twin *predicts*; it does not *measure*. A counterfactual verdict inherits
|
||||||
|
//! the `L0` level of its weakest input and is **never** presented as
|
||||||
|
//! camera-grade ground truth. This crate makes **no** hardware, `MEASURED`, or
|
||||||
|
//! accuracy claim, and asserts **no** discrimination-accuracy number (e.g. it
|
||||||
|
//! does not claim to "distinguish one occupant from two"); any such number would
|
||||||
|
//! require the mean-pose-style baseline discipline, a leakage-free held-out
|
||||||
|
//! split, and a reproducer before it could be tagged `MEASURED`.
|
||||||
|
//!
|
||||||
|
//! ## UNKNOWN is a first-class output (ADR-300 rule 1, ADR-313 §3)
|
||||||
|
//!
|
||||||
|
//! The layer never forces a label. The best explanation resolves to a
|
||||||
|
//! first-class [`BestExplanation::Unknown`] when the top two hypotheses are
|
||||||
|
//! near-indistinguishable (margin below threshold), when **no** hypothesis
|
||||||
|
//! explains the observation well (best mean per-link log-likelihood below a
|
||||||
|
//! floor — routed to the ADR-302 `UNKNOWN` verdict), when no hypotheses are
|
||||||
|
//! supplied, or when no observed link is evaluable against the twin. UNKNOWN is
|
||||||
|
//! a value, never an error, a panic, or a confident default.
|
||||||
|
//!
|
||||||
|
//! ## Determinism
|
||||||
|
//!
|
||||||
|
//! Everything is a pure, deterministic function of its inputs: no I/O, no clock,
|
||||||
|
//! and no randomness. Synthetic scenes vary only by the twin's explicit
|
||||||
|
//! [`seed`](ruview_twin::DeploymentDescription::seed); malformed input abstains
|
||||||
|
//! (first-class UNKNOWN or a typed error) rather than panicking, and allocation
|
||||||
|
//! is bounded by [`MAX_OCCUPANTS`] and [`MAX_HYPOTHESES`].
|
||||||
|
//!
|
||||||
|
//! ```
|
||||||
|
//! use ruview_counterfactual::*;
|
||||||
|
//! use ruview_twin::{synthetic_deployment, RfTwin};
|
||||||
|
//! use ruview_ontology::SpaceId;
|
||||||
|
//!
|
||||||
|
//! let twin = RfTwin::build(synthetic_deployment(7)).unwrap();
|
||||||
|
//! let space = SpaceId::new("space-7").unwrap();
|
||||||
|
//! let model = OccupantModel::default_body();
|
||||||
|
//!
|
||||||
|
//! // An empty-room observation set (nobody present) is best explained by the
|
||||||
|
//! // null hypothesis over a one-occupant alternative.
|
||||||
|
//! let empty = Hypothesis::empty("empty", space.clone());
|
||||||
|
//! let one = Hypothesis::new("one", space, vec![Occupant::new(2.5, 2.0)]).unwrap();
|
||||||
|
//! let observed = synthesize_observations(&twin, &empty, &model);
|
||||||
|
//!
|
||||||
|
//! let result = evaluate(&twin, &observed, &[empty, one]);
|
||||||
|
//! match result.best {
|
||||||
|
//! BestExplanation::Explained { hypothesis_id, .. } => assert_eq!(hypothesis_id, "empty"),
|
||||||
|
//! BestExplanation::Unknown { .. } => {} // also honest if indistinguishable
|
||||||
|
//! }
|
||||||
|
//! assert_eq!(result.evidence_level, ruview_ontology::EvidenceLevel::L0);
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
#![forbid(unsafe_code)]
|
||||||
|
#![warn(missing_docs)]
|
||||||
|
|
||||||
|
mod hypothesis;
|
||||||
|
mod infer;
|
||||||
|
|
||||||
|
pub use hypothesis::{Hypothesis, HypothesisError, Occupant, MAX_HYPOTHESES, MAX_OCCUPANTS};
|
||||||
|
pub use infer::{
|
||||||
|
evaluate, evaluate_with, score_hypothesis, synthesize_observations, BestExplanation,
|
||||||
|
CounterfactualResult, HypothesisScore, OccupantModel, ScoringConfig, UnknownReason,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Re-export the canonical ontology and twin vocabulary this crate consumes, so
|
||||||
|
// downstream speaks one semantics (ADR-300 rule 3, ADR-306).
|
||||||
|
pub use ruview_ontology::{EvidenceLevel, SemanticProvenance, SpaceId};
|
||||||
|
pub use ruview_twin::{LinkId, LinkObservation, ObservationSet, RfTwin};
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use ruview_twin::{synthetic_deployment, DeploymentDescription, RfTwin};
|
||||||
|
|
||||||
|
fn space(seed: u64) -> SpaceId {
|
||||||
|
SpaceId::new(format!("space-{seed}")).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn twin(seed: u64) -> RfTwin {
|
||||||
|
RfTwin::build(synthetic_deployment(seed)).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
// The centre of the synthetic 5m×4m room lies on both diagonals, so a
|
||||||
|
// centre occupant blocks the two diagonal links; edge occupants block an
|
||||||
|
// edge link. Positions are explicit — no randomness.
|
||||||
|
fn centre() -> Occupant {
|
||||||
|
Occupant::new(2.5, 2.0)
|
||||||
|
}
|
||||||
|
fn edge() -> Occupant {
|
||||||
|
Occupant::new(2.5, 0.5)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty-room observations favour the null (empty) hypothesis over a
|
||||||
|
// one-occupant hypothesis.
|
||||||
|
#[test]
|
||||||
|
fn empty_room_observations_favour_the_empty_hypothesis() {
|
||||||
|
let t = twin(7);
|
||||||
|
let model = OccupantModel::default_body();
|
||||||
|
let empty = Hypothesis::empty("empty", space(7));
|
||||||
|
let one = Hypothesis::new("one", space(7), vec![centre()]).unwrap();
|
||||||
|
|
||||||
|
// Nobody present ⇒ observations are the twin's own predictions.
|
||||||
|
let observed = synthesize_observations(&t, &empty, &model);
|
||||||
|
|
||||||
|
let result = evaluate(&t, &observed, &[empty, one]);
|
||||||
|
match &result.best {
|
||||||
|
BestExplanation::Explained { hypothesis_id, occupant_count, margin } => {
|
||||||
|
assert_eq!(hypothesis_id, "empty");
|
||||||
|
assert_eq!(*occupant_count, 0);
|
||||||
|
assert!(*margin > 0.0);
|
||||||
|
}
|
||||||
|
other => panic!("expected the empty hypothesis to win, got {other:?}"),
|
||||||
|
}
|
||||||
|
// The empty hypothesis ranks first and out-scores the occupant one.
|
||||||
|
assert_eq!(result.ranked[0].hypothesis.id, "empty");
|
||||||
|
assert!(result.ranked[0].log_likelihood > result.ranked[1].log_likelihood);
|
||||||
|
// A twin verdict is always SYNTHETIC / L0.
|
||||||
|
assert_eq!(result.evidence_level, EvidenceLevel::L0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A clear single-person scene favours one occupant over both zero and two.
|
||||||
|
#[test]
|
||||||
|
fn single_person_scene_favours_one_over_two_and_empty() {
|
||||||
|
let t = twin(7);
|
||||||
|
let model = OccupantModel::default_body();
|
||||||
|
|
||||||
|
let empty = Hypothesis::empty("empty", space(7));
|
||||||
|
let one = Hypothesis::new("one", space(7), vec![centre()]).unwrap();
|
||||||
|
let two = Hypothesis::new("two", space(7), vec![centre(), edge()]).unwrap();
|
||||||
|
|
||||||
|
// A scene with exactly one occupant at the room centre.
|
||||||
|
let observed = synthesize_observations(&t, &one, &model);
|
||||||
|
|
||||||
|
let result = evaluate(&t, &observed, &[empty.clone(), one, two]);
|
||||||
|
match &result.best {
|
||||||
|
BestExplanation::Explained { hypothesis_id, occupant_count, margin } => {
|
||||||
|
assert_eq!(hypothesis_id, "one");
|
||||||
|
assert_eq!(*occupant_count, 1);
|
||||||
|
assert!(*margin > 0.0);
|
||||||
|
}
|
||||||
|
other => panic!("expected the one-occupant hypothesis to win, got {other:?}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
// One out-scores both two and empty explicitly.
|
||||||
|
let ll = |id: &str| {
|
||||||
|
result
|
||||||
|
.ranked
|
||||||
|
.iter()
|
||||||
|
.find(|s| s.hypothesis.id == id)
|
||||||
|
.unwrap()
|
||||||
|
.log_likelihood
|
||||||
|
};
|
||||||
|
assert!(ll("one") > ll("two"));
|
||||||
|
assert!(ll("one") > ll("empty"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// An occupant far outside the room blocks no link, so the one-occupant and
|
||||||
|
// empty hypotheses are near-indistinguishable ⇒ first-class UNKNOWN.
|
||||||
|
#[test]
|
||||||
|
fn ambiguous_scene_returns_unknown_low_margin() {
|
||||||
|
let t = twin(7);
|
||||||
|
let model = OccupantModel::default_body();
|
||||||
|
|
||||||
|
let empty = Hypothesis::empty("empty", space(7));
|
||||||
|
// Far outside the 5m×4m room ⇒ blocks nothing.
|
||||||
|
let ghost = Hypothesis::new("ghost", space(7), vec![Occupant::new(50.0, 50.0)]).unwrap();
|
||||||
|
|
||||||
|
let observed = synthesize_observations(&t, &empty, &model);
|
||||||
|
|
||||||
|
let result = evaluate(&t, &observed, &[empty, ghost]);
|
||||||
|
match result.best {
|
||||||
|
BestExplanation::Unknown {
|
||||||
|
reason: UnknownReason::NearIndistinguishable { margin, threshold },
|
||||||
|
} => {
|
||||||
|
assert!(margin < threshold);
|
||||||
|
assert!(margin.abs() < 1e-9, "blocking nothing ⇒ identical scores");
|
||||||
|
}
|
||||||
|
other => panic!("expected near-indistinguishable UNKNOWN, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Out-of-model measurements (gross deviations the twin cannot account for)
|
||||||
|
// route to UNKNOWN rather than a forced occupancy label (ADR-313 §3).
|
||||||
|
#[test]
|
||||||
|
fn out_of_model_observation_routes_to_unknown() {
|
||||||
|
let t = twin(7);
|
||||||
|
let model = OccupantModel::default_body();
|
||||||
|
let empty = Hypothesis::empty("empty", space(7));
|
||||||
|
let one = Hypothesis::new("one", space(7), vec![centre()]).unwrap();
|
||||||
|
|
||||||
|
// Take the empty-room set and shove every value 100 dB off — nothing the
|
||||||
|
// twin or any hypothesis can explain.
|
||||||
|
let base = synthesize_observations(&t, &empty, &model);
|
||||||
|
let mut scattered = ObservationSet::new();
|
||||||
|
for obs in &base.observations {
|
||||||
|
scattered = scattered.with(obs.link.clone(), obs.value + 100.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = evaluate(&t, &scattered, &[empty, one]);
|
||||||
|
assert!(matches!(
|
||||||
|
result.best,
|
||||||
|
BestExplanation::Unknown {
|
||||||
|
reason: UnknownReason::NoHypothesisExplains { .. }
|
||||||
|
}
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ranking is deterministic: identical inputs (in any hypothesis order)
|
||||||
|
// produce an identical ranked result.
|
||||||
|
#[test]
|
||||||
|
fn ranking_is_deterministic_and_order_independent() {
|
||||||
|
let t = twin(7);
|
||||||
|
let model = OccupantModel::default_body();
|
||||||
|
|
||||||
|
let empty = Hypothesis::empty("empty", space(7));
|
||||||
|
let one = Hypothesis::new("one", space(7), vec![centre()]).unwrap();
|
||||||
|
let two = Hypothesis::new("two", space(7), vec![centre(), edge()]).unwrap();
|
||||||
|
|
||||||
|
let observed = synthesize_observations(&t, &one, &model);
|
||||||
|
|
||||||
|
let r1 = evaluate(&t, &observed, &[empty.clone(), one.clone(), two.clone()]);
|
||||||
|
let r2 = evaluate(&t, &observed, &[empty.clone(), one.clone(), two.clone()]);
|
||||||
|
assert_eq!(r1, r2);
|
||||||
|
|
||||||
|
// Reordering the hypotheses does not change the ranked result or verdict.
|
||||||
|
let r3 = evaluate(&t, &observed, &[two, empty, one]);
|
||||||
|
assert_eq!(r1.ranked, r3.ranked);
|
||||||
|
assert_eq!(r1.best, r3.best);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Boundary validation: malformed input abstains, never panics.
|
||||||
|
#[test]
|
||||||
|
fn boundary_validation_does_not_panic() {
|
||||||
|
let t = twin(9);
|
||||||
|
let model = OccupantModel::default_body();
|
||||||
|
|
||||||
|
// No hypotheses ⇒ first-class UNKNOWN, not an error.
|
||||||
|
let none: Vec<Hypothesis> = Vec::new();
|
||||||
|
let empty_observed = ObservationSet::new();
|
||||||
|
let r = evaluate(&t, &empty_observed, &none);
|
||||||
|
assert!(matches!(
|
||||||
|
r.best,
|
||||||
|
BestExplanation::Unknown { reason: UnknownReason::NoHypotheses }
|
||||||
|
));
|
||||||
|
assert!(r.ranked.is_empty());
|
||||||
|
|
||||||
|
// Hypotheses present but nothing evaluable ⇒ NoEvaluableLinks.
|
||||||
|
let empty = Hypothesis::empty("empty", space(9));
|
||||||
|
let r = evaluate(&t, &empty_observed, std::slice::from_ref(&empty));
|
||||||
|
assert!(matches!(
|
||||||
|
r.best,
|
||||||
|
BestExplanation::Unknown { reason: UnknownReason::NoEvaluableLinks }
|
||||||
|
));
|
||||||
|
|
||||||
|
// Non-finite occupant position is rejected at construction.
|
||||||
|
assert!(matches!(
|
||||||
|
Hypothesis::new("bad", space(9), vec![Occupant::new(f64::NAN, 0.0)]),
|
||||||
|
Err(HypothesisError::NonFinitePosition { index: 0 })
|
||||||
|
));
|
||||||
|
|
||||||
|
// Too many occupants is rejected at construction (bounded allocation).
|
||||||
|
let many = vec![Occupant::new(0.0, 0.0); MAX_OCCUPANTS + 1];
|
||||||
|
assert!(matches!(
|
||||||
|
Hypothesis::new("big", space(9), many),
|
||||||
|
Err(HypothesisError::TooManyOccupants { .. })
|
||||||
|
));
|
||||||
|
|
||||||
|
// An observation for a link outside the twin is counted UNKNOWN, not a
|
||||||
|
// panic. Build a set referencing a ghost node.
|
||||||
|
let ghost_link = LinkId::new(
|
||||||
|
ruview_ontology::SensorId::new("node-0").unwrap(),
|
||||||
|
ruview_ontology::SensorId::new("ghost").unwrap(),
|
||||||
|
);
|
||||||
|
let observed = ObservationSet::new().with(ghost_link, -50.0);
|
||||||
|
let r = evaluate(&t, &observed, std::slice::from_ref(&empty));
|
||||||
|
assert_eq!(r.ranked[0].unknown_links, 1);
|
||||||
|
assert_eq!(r.ranked[0].evaluated_links, 0);
|
||||||
|
assert!(matches!(
|
||||||
|
r.best,
|
||||||
|
BestExplanation::Unknown { reason: UnknownReason::NoEvaluableLinks }
|
||||||
|
));
|
||||||
|
|
||||||
|
// A malformed occupant injected via the struct literal (bypassing the
|
||||||
|
// validating constructor) is treated as non-blocking, not a NaN score.
|
||||||
|
let malformed = Hypothesis {
|
||||||
|
id: "malformed".into(),
|
||||||
|
space: space(9),
|
||||||
|
occupants: vec![Occupant::new(f64::INFINITY, 0.0)],
|
||||||
|
};
|
||||||
|
let good_observed = synthesize_observations(&t, &empty, &model);
|
||||||
|
let score = score_hypothesis(&t, &good_observed, &malformed, &model);
|
||||||
|
assert!(score.log_likelihood.is_finite());
|
||||||
|
}
|
||||||
|
|
||||||
|
// A single hypothesis that fits well is Explained with an infinite margin
|
||||||
|
// (no competitor), still gated by the fit floor.
|
||||||
|
#[test]
|
||||||
|
fn single_hypothesis_has_infinite_margin_when_it_fits() {
|
||||||
|
let t = twin(3);
|
||||||
|
let model = OccupantModel::default_body();
|
||||||
|
let empty = Hypothesis::empty("empty", space(3));
|
||||||
|
let observed = synthesize_observations(&t, &empty, &model);
|
||||||
|
|
||||||
|
let result = evaluate(&t, &observed, std::slice::from_ref(&empty));
|
||||||
|
match result.best {
|
||||||
|
BestExplanation::Explained { margin, occupant_count, .. } => {
|
||||||
|
assert!(margin.is_infinite());
|
||||||
|
assert_eq!(occupant_count, 0);
|
||||||
|
}
|
||||||
|
other => panic!("expected Explained with infinite margin, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The result serde round-trips losslessly (one canonical semantics), and the
|
||||||
|
// SYNTHETIC / L0 discipline is on the wire.
|
||||||
|
#[test]
|
||||||
|
fn result_serde_round_trip_is_lossless() {
|
||||||
|
let t = twin(2);
|
||||||
|
let model = OccupantModel::default_body();
|
||||||
|
let empty = Hypothesis::empty("empty", space(2));
|
||||||
|
let one = Hypothesis::new("one", space(2), vec![centre()]).unwrap();
|
||||||
|
let observed = synthesize_observations(&t, &one, &model);
|
||||||
|
|
||||||
|
let result = evaluate(&t, &observed, &[empty, one]);
|
||||||
|
let json = serde_json::to_string_pretty(&result).unwrap();
|
||||||
|
let back: CounterfactualResult = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(result, back);
|
||||||
|
assert!(json.contains("\"L0\""));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Distinct twin seeds give distinct-but-reproducible synthetic scenes; the
|
||||||
|
// scene variation is driven only by the explicit seed (no wall clock).
|
||||||
|
#[test]
|
||||||
|
fn scenes_vary_only_by_explicit_seed() {
|
||||||
|
let model = OccupantModel::default_body();
|
||||||
|
let a: DeploymentDescription = synthetic_deployment(1);
|
||||||
|
let b: DeploymentDescription = synthetic_deployment(1);
|
||||||
|
assert_eq!(a, b);
|
||||||
|
|
||||||
|
let ta = RfTwin::build(a).unwrap();
|
||||||
|
let tb = twin(1);
|
||||||
|
let one_a = Hypothesis::new("one", space(1), vec![centre()]).unwrap();
|
||||||
|
let one_b = Hypothesis::new("one", space(1), vec![centre()]).unwrap();
|
||||||
|
let obs_a = synthesize_observations(&ta, &one_a, &model);
|
||||||
|
let obs_b = synthesize_observations(&tb, &one_b, &model);
|
||||||
|
assert_eq!(obs_a, obs_b);
|
||||||
|
}
|
||||||
|
}
|
||||||
15
v2/crates/ruview-evidence/Cargo.toml
Normal file
15
v2/crates/ruview-evidence/Cargo.toml
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
[package]
|
||||||
|
name = "ruview-evidence"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
authors.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
thiserror.workspace = true
|
||||||
|
serde = { workspace = true, features = ["derive"] }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
serde_json.workspace = true
|
||||||
977
v2/crates/ruview-evidence/src/lib.rs
Normal file
977
v2/crates/ruview-evidence/src/lib.rs
Normal file
@@ -0,0 +1,977 @@
|
|||||||
|
//! # `ruview-evidence` — the append-only accuracy ledger (ADR-304, ADR-300 §4)
|
||||||
|
//!
|
||||||
|
//! "MLflow for physical sensing." Where an experiment tracker overwrites
|
||||||
|
//! yesterday's number, this crate is an **append-only** record of how a model
|
||||||
|
//! actually performs, keyed per deployment context
|
||||||
|
//! `(room, device, subject-class, model-version)` and carrying, per record,
|
||||||
|
//! the ADR-304 metrics (moving/stationary recall, false-positive rate, drift,
|
||||||
|
//! predictive uncertainty, calibration age, sample count) plus exactly one
|
||||||
|
//! [`EvidenceLevel`] (L0–L5, mirroring ADR-282 semantics).
|
||||||
|
//!
|
||||||
|
//! ## Leaf, deterministic, honest
|
||||||
|
//!
|
||||||
|
//! - **Leaf**: this crate depends only on `serde`/`thiserror`. The
|
||||||
|
//! [`EvidenceLevel`] ladder mirrors ADR-282 (`frame::EvidenceLevel`) but is
|
||||||
|
//! defined locally so the ledger never pulls in the frame crate.
|
||||||
|
//! - **Deterministic**: no wall-clock and no randomness. Record time is
|
||||||
|
//! injected by the caller; the ledger assigns a monotonic append sequence.
|
||||||
|
//! - **Honest by construction**:
|
||||||
|
//! - A record's [`EvidenceLevel`] is fixed by its *provenance* at write time
|
||||||
|
//! ([`EvidenceRecord::synthetic`] is `L0` and cannot be raised — there is
|
||||||
|
//! no `set_level`). This is the ADR-282/288/290 "no upgrade" rule.
|
||||||
|
//! - Records are **append-only**: [`EvidenceLedger::append`] consumes a
|
||||||
|
//! record by value and nothing hands back a mutable reference. A correction
|
||||||
|
//! is a *new* record, never an in-place edit (ADR-304 §1).
|
||||||
|
//! - Aggregation **never pools across contexts** (ADR-304 §2/§Consequences):
|
||||||
|
//! an [`EvidenceSlice`] is minted by [`EvidenceLedger::query`] for exactly
|
||||||
|
//! one context and there is no API that averages two contexts into one
|
||||||
|
//! number. A summary's evidence level is the **floor** (minimum) of the
|
||||||
|
//! levels present in the slice — a slice can never report a level above the
|
||||||
|
//! weakest record it contains.
|
||||||
|
//! - An empty context returns [`SummaryEvidence::NoEvidence`], distinct from a
|
||||||
|
//! present-but-zero-accuracy summary — downstream (ADR-318) must treat
|
||||||
|
//! "no evidence" as "no capability", not as a `0.0` score.
|
||||||
|
|
||||||
|
#![forbid(unsafe_code)]
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// Maximum byte length accepted for any context identifier string. Bounds
|
||||||
|
/// allocation at the untrusted-input boundary (CLAUDE.md).
|
||||||
|
pub const MAX_ID_LEN: usize = 256;
|
||||||
|
|
||||||
|
/// Default upper bound on records held by a single ledger. Bounds allocation;
|
||||||
|
/// [`EvidenceLedger::with_capacity`] can raise or lower it.
|
||||||
|
pub const DEFAULT_MAX_RECORDS: usize = 1_000_000;
|
||||||
|
|
||||||
|
/// The ADR-282 evidence ladder, L0–L5, mirrored locally to keep this crate a
|
||||||
|
/// leaf (no dependency on the frame crate). Exactly one level travels with each
|
||||||
|
/// [`EvidenceRecord`]. Ordering is meaningful and load-bearing: the summary
|
||||||
|
/// floor rule takes `min` over these, so `L0 < L1 < … < L5`.
|
||||||
|
///
|
||||||
|
/// Semantics mirror `frame::EvidenceLevel` (ADR-282 §4): L0 simulation-only,
|
||||||
|
/// rising to L5 production/witnessed evidence. See ADR-282 for the canonical
|
||||||
|
/// ladder; this enum is a faithful local copy, not an independent scale.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
||||||
|
pub enum EvidenceLevel {
|
||||||
|
/// L0 — simulation / synthetic only, no signal evidence (ADR-282).
|
||||||
|
L0,
|
||||||
|
/// L1 — captured replay / heuristic evidence.
|
||||||
|
L1,
|
||||||
|
/// L2 — controlled single-surface signal evidence.
|
||||||
|
L2,
|
||||||
|
/// L3 — corroborated / held-out room-and-subject validation.
|
||||||
|
L3,
|
||||||
|
/// L4 — calibrated multi-site field evidence.
|
||||||
|
L4,
|
||||||
|
/// L5 — production, witnessed / certified (ADR-319).
|
||||||
|
L5,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Accuracy tag for a record (CLAUDE.md honesty rule). The class is fixed by
|
||||||
|
/// the constructor used and cannot alias: synthetic input can never be minted
|
||||||
|
/// as `Measured`.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum ProvenanceClass {
|
||||||
|
/// Produced by a simulator/generator — L0 by construction (ADR-276/301).
|
||||||
|
Synthetic,
|
||||||
|
/// Real inference but no ground-truth reference backs the accuracy.
|
||||||
|
Claimed,
|
||||||
|
/// Backed by an ADR-303 reference plus a reproducer handle.
|
||||||
|
Measured,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The deployment context a record is keyed by: `(room, device, subject-class,
|
||||||
|
/// model-version)`. Identity is caller-supplied (ADR-306 space id, ADR-305
|
||||||
|
/// signed device id); this crate treats the fields as opaque bounded handles
|
||||||
|
/// and never invents them.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
pub struct EvidenceContext {
|
||||||
|
/// Space / room id (ADR-306).
|
||||||
|
pub room: String,
|
||||||
|
/// Signed device id (ADR-305).
|
||||||
|
pub device: String,
|
||||||
|
/// Subject class where consented/available; empty means "no subject"
|
||||||
|
/// (ADR-304 §1 — subject id only where consented).
|
||||||
|
pub subject_class: String,
|
||||||
|
/// Model version that produced the inferences (ADR-136).
|
||||||
|
pub model_version: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EvidenceContext {
|
||||||
|
/// Construct a context, validating every field at the boundary. `room`,
|
||||||
|
/// `device`, and `model_version` must be non-empty; every field is bounded
|
||||||
|
/// to [`MAX_ID_LEN`] bytes. `subject_class` may be empty (no consented
|
||||||
|
/// subject) but is still length-bounded.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns [`EvidenceError::EmptyField`] for a missing required field and
|
||||||
|
/// [`EvidenceError::IdTooLong`] for any over-length field.
|
||||||
|
pub fn new(
|
||||||
|
room: impl Into<String>,
|
||||||
|
device: impl Into<String>,
|
||||||
|
subject_class: impl Into<String>,
|
||||||
|
model_version: impl Into<String>,
|
||||||
|
) -> Result<Self, EvidenceError> {
|
||||||
|
let room = room.into();
|
||||||
|
let device = device.into();
|
||||||
|
let subject_class = subject_class.into();
|
||||||
|
let model_version = model_version.into();
|
||||||
|
|
||||||
|
check_bound("room", &room)?;
|
||||||
|
check_bound("device", &device)?;
|
||||||
|
check_bound("subject_class", &subject_class)?;
|
||||||
|
check_bound("model_version", &model_version)?;
|
||||||
|
check_nonempty("room", &room)?;
|
||||||
|
check_nonempty("device", &device)?;
|
||||||
|
check_nonempty("model_version", &model_version)?;
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
room,
|
||||||
|
device,
|
||||||
|
subject_class,
|
||||||
|
model_version,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_bound(field: &'static str, value: &str) -> Result<(), EvidenceError> {
|
||||||
|
if value.len() > MAX_ID_LEN {
|
||||||
|
return Err(EvidenceError::IdTooLong {
|
||||||
|
field,
|
||||||
|
len: value.len(),
|
||||||
|
max: MAX_ID_LEN,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_nonempty(field: &'static str, value: &str) -> Result<(), EvidenceError> {
|
||||||
|
if value.is_empty() {
|
||||||
|
return Err(EvidenceError::EmptyField { field });
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The per-inference-window accuracy metrics accumulated into a record
|
||||||
|
/// (ADR-304 §1). Rates are fractions in `[0, 1]`; `drift` and `uncertainty`
|
||||||
|
/// are non-negative finite magnitudes; `sample_count` is the number of
|
||||||
|
/// inferences the record summarizes and must be at least one.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct AccuracyMetrics {
|
||||||
|
/// Recall on moving subjects, `[0, 1]`.
|
||||||
|
pub moving_recall: f64,
|
||||||
|
/// Recall on stationary subjects, `[0, 1]`.
|
||||||
|
pub stationary_recall: f64,
|
||||||
|
/// False-positive rate, `[0, 1]`.
|
||||||
|
pub false_positive_rate: f64,
|
||||||
|
/// Drift magnitude — fingerprint distance from the calibration baseline
|
||||||
|
/// (ADR-301); non-negative.
|
||||||
|
pub drift: f64,
|
||||||
|
/// Predictive uncertainty; non-negative.
|
||||||
|
pub uncertainty: f64,
|
||||||
|
/// Age of the calibration certificate in effect, seconds (ADR-301).
|
||||||
|
pub calibration_age_secs: u64,
|
||||||
|
/// Number of inferences this record summarizes; at least one.
|
||||||
|
pub sample_count: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AccuracyMetrics {
|
||||||
|
/// Validate the metrics at the boundary. Rates must be finite and within
|
||||||
|
/// `[0, 1]`; `drift`/`uncertainty` must be finite and non-negative;
|
||||||
|
/// `sample_count` must be `>= 1` (a record represents at least one
|
||||||
|
/// inference, which also guarantees non-zero aggregation weight).
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// [`EvidenceError::RateOutOfRange`], [`EvidenceError::NegativeMagnitude`],
|
||||||
|
/// or [`EvidenceError::ZeroSamples`].
|
||||||
|
pub fn validate(&self) -> Result<(), EvidenceError> {
|
||||||
|
check_rate("moving_recall", self.moving_recall)?;
|
||||||
|
check_rate("stationary_recall", self.stationary_recall)?;
|
||||||
|
check_rate("false_positive_rate", self.false_positive_rate)?;
|
||||||
|
check_magnitude("drift", self.drift)?;
|
||||||
|
check_magnitude("uncertainty", self.uncertainty)?;
|
||||||
|
if self.sample_count == 0 {
|
||||||
|
return Err(EvidenceError::ZeroSamples);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_rate(field: &'static str, v: f64) -> Result<(), EvidenceError> {
|
||||||
|
if !v.is_finite() || !(0.0..=1.0).contains(&v) {
|
||||||
|
return Err(EvidenceError::RateOutOfRange { field, value: v });
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_magnitude(field: &'static str, v: f64) -> Result<(), EvidenceError> {
|
||||||
|
if !v.is_finite() || v < 0.0 {
|
||||||
|
return Err(EvidenceError::NegativeMagnitude { field, value: v });
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One immutable, append-only accuracy record (ADR-304 §1). All fields are
|
||||||
|
/// private: there is no setter and no `&mut` accessor, so a level can never be
|
||||||
|
/// upgraded and a record can never be edited in place — a correction is a new
|
||||||
|
/// record. Construct via [`EvidenceRecord::synthetic`],
|
||||||
|
/// [`EvidenceRecord::claimed`], or [`EvidenceRecord::measured`]; the sequence
|
||||||
|
/// number is assigned by the ledger on [`EvidenceLedger::append`].
|
||||||
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct EvidenceRecord {
|
||||||
|
context: EvidenceContext,
|
||||||
|
metrics: AccuracyMetrics,
|
||||||
|
level: EvidenceLevel,
|
||||||
|
class: ProvenanceClass,
|
||||||
|
/// Reproducer handle for `Measured` records (ADR-303); empty otherwise.
|
||||||
|
reproducer: String,
|
||||||
|
/// Caller-injected record time, nanoseconds. Never read from a clock here.
|
||||||
|
timestamp_ns: u64,
|
||||||
|
/// Ledger-assigned monotonic append sequence; `None` until appended.
|
||||||
|
seq: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EvidenceRecord {
|
||||||
|
/// Mint a **synthetic** record. Class is [`ProvenanceClass::Synthetic`] and
|
||||||
|
/// the evidence level is forced to [`EvidenceLevel::L0`] — synthetic input
|
||||||
|
/// is L0 by construction (ADR-304 §3) and there is no way to raise it.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Propagates [`AccuracyMetrics::validate`] failures.
|
||||||
|
pub fn synthetic(
|
||||||
|
context: EvidenceContext,
|
||||||
|
metrics: AccuracyMetrics,
|
||||||
|
timestamp_ns: u64,
|
||||||
|
) -> Result<Self, EvidenceError> {
|
||||||
|
metrics.validate()?;
|
||||||
|
Ok(Self {
|
||||||
|
context,
|
||||||
|
metrics,
|
||||||
|
level: EvidenceLevel::L0,
|
||||||
|
class: ProvenanceClass::Synthetic,
|
||||||
|
reproducer: String::new(),
|
||||||
|
timestamp_ns,
|
||||||
|
seq: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mint a **claimed** record: a real inference with no ADR-303 reference
|
||||||
|
/// backing its accuracy. The level is set by the caller's provenance at
|
||||||
|
/// write time and is never MEASURED. A claimed record may not be minted at
|
||||||
|
/// `L0`, which is reserved for synthetic input.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Propagates metric validation; [`EvidenceError::SyntheticOnlyL0`] if
|
||||||
|
/// `level` is `L0`.
|
||||||
|
pub fn claimed(
|
||||||
|
context: EvidenceContext,
|
||||||
|
metrics: AccuracyMetrics,
|
||||||
|
level: EvidenceLevel,
|
||||||
|
timestamp_ns: u64,
|
||||||
|
) -> Result<Self, EvidenceError> {
|
||||||
|
metrics.validate()?;
|
||||||
|
if level == EvidenceLevel::L0 {
|
||||||
|
return Err(EvidenceError::SyntheticOnlyL0);
|
||||||
|
}
|
||||||
|
Ok(Self {
|
||||||
|
context,
|
||||||
|
metrics,
|
||||||
|
level,
|
||||||
|
class: ProvenanceClass::Claimed,
|
||||||
|
reproducer: String::new(),
|
||||||
|
timestamp_ns,
|
||||||
|
seq: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mint a **measured** record: accuracy backed by an ADR-303 reference and
|
||||||
|
/// a non-empty reproducer handle. The level is set by provenance and must
|
||||||
|
/// not be `L0`.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Propagates metric validation; [`EvidenceError::MissingReproducer`] if
|
||||||
|
/// the reproducer handle is empty or over-length;
|
||||||
|
/// [`EvidenceError::SyntheticOnlyL0`] if `level` is `L0`.
|
||||||
|
pub fn measured(
|
||||||
|
context: EvidenceContext,
|
||||||
|
metrics: AccuracyMetrics,
|
||||||
|
level: EvidenceLevel,
|
||||||
|
reproducer: impl Into<String>,
|
||||||
|
timestamp_ns: u64,
|
||||||
|
) -> Result<Self, EvidenceError> {
|
||||||
|
metrics.validate()?;
|
||||||
|
if level == EvidenceLevel::L0 {
|
||||||
|
return Err(EvidenceError::SyntheticOnlyL0);
|
||||||
|
}
|
||||||
|
let reproducer = reproducer.into();
|
||||||
|
check_bound("reproducer", &reproducer)?;
|
||||||
|
if reproducer.is_empty() {
|
||||||
|
return Err(EvidenceError::MissingReproducer);
|
||||||
|
}
|
||||||
|
Ok(Self {
|
||||||
|
context,
|
||||||
|
metrics,
|
||||||
|
level,
|
||||||
|
class: ProvenanceClass::Measured,
|
||||||
|
reproducer,
|
||||||
|
timestamp_ns,
|
||||||
|
seq: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The context this record is keyed by.
|
||||||
|
#[must_use]
|
||||||
|
pub fn context(&self) -> &EvidenceContext {
|
||||||
|
&self.context
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The record's metrics.
|
||||||
|
#[must_use]
|
||||||
|
pub fn metrics(&self) -> &AccuracyMetrics {
|
||||||
|
&self.metrics
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The record's evidence level, fixed at write time.
|
||||||
|
#[must_use]
|
||||||
|
pub fn level(&self) -> EvidenceLevel {
|
||||||
|
self.level
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The record's provenance class.
|
||||||
|
#[must_use]
|
||||||
|
pub fn class(&self) -> ProvenanceClass {
|
||||||
|
self.class
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The reproducer handle (empty unless [`ProvenanceClass::Measured`]).
|
||||||
|
#[must_use]
|
||||||
|
pub fn reproducer(&self) -> &str {
|
||||||
|
&self.reproducer
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Caller-injected record time in nanoseconds.
|
||||||
|
#[must_use]
|
||||||
|
pub fn timestamp_ns(&self) -> u64 {
|
||||||
|
self.timestamp_ns
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ledger-assigned append sequence, or `None` before the record is
|
||||||
|
/// appended.
|
||||||
|
#[must_use]
|
||||||
|
pub fn seq(&self) -> Option<u64> {
|
||||||
|
self.seq
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The append-only evidence ledger (ADR-304). The record vector is private and
|
||||||
|
/// exposed only through read-only queries; nothing returns a mutable reference
|
||||||
|
/// to a stored record, so the append-only and no-upgrade invariants hold at the
|
||||||
|
/// type level.
|
||||||
|
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||||
|
pub struct EvidenceLedger {
|
||||||
|
records: Vec<EvidenceRecord>,
|
||||||
|
next_seq: u64,
|
||||||
|
max_records: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EvidenceLedger {
|
||||||
|
/// A new empty ledger bounded to [`DEFAULT_MAX_RECORDS`] records.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::with_capacity(DEFAULT_MAX_RECORDS)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A new empty ledger bounded to `max_records`.
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_capacity(max_records: usize) -> Self {
|
||||||
|
Self {
|
||||||
|
records: Vec::new(),
|
||||||
|
next_seq: 0,
|
||||||
|
max_records,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Append a record. The ledger stamps it with the next monotonic sequence
|
||||||
|
/// and stores it; the record is consumed by value, so the caller cannot
|
||||||
|
/// retain a handle to mutate the stored copy. Returns the assigned
|
||||||
|
/// sequence.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// [`EvidenceError::LedgerFull`] once the bounded capacity is reached, so
|
||||||
|
/// a malformed or runaway producer cannot exhaust memory.
|
||||||
|
pub fn append(&mut self, mut record: EvidenceRecord) -> Result<u64, EvidenceError> {
|
||||||
|
if self.records.len() >= self.max_records {
|
||||||
|
return Err(EvidenceError::LedgerFull {
|
||||||
|
max: self.max_records,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let seq = self.next_seq;
|
||||||
|
record.seq = Some(seq);
|
||||||
|
self.next_seq += 1;
|
||||||
|
self.records.push(record);
|
||||||
|
Ok(seq)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Total number of records in the ledger.
|
||||||
|
#[must_use]
|
||||||
|
pub fn len(&self) -> usize {
|
||||||
|
self.records.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the ledger holds no records.
|
||||||
|
#[must_use]
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.records.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every record, in append order (read-only).
|
||||||
|
#[must_use]
|
||||||
|
pub fn records(&self) -> &[EvidenceRecord] {
|
||||||
|
&self.records
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Query the records for exactly one context, in append order. The returned
|
||||||
|
/// [`EvidenceSlice`] carries only records whose context equals `context`,
|
||||||
|
/// so aggregation over it can never mix two contexts (ADR-304 §2 — no
|
||||||
|
/// pooling).
|
||||||
|
#[must_use]
|
||||||
|
pub fn query<'a>(&'a self, context: &EvidenceContext) -> EvidenceSlice<'a> {
|
||||||
|
let records: Vec<&'a EvidenceRecord> = self
|
||||||
|
.records
|
||||||
|
.iter()
|
||||||
|
.filter(|r| &r.context == context)
|
||||||
|
.collect();
|
||||||
|
EvidenceSlice {
|
||||||
|
context: context.clone(),
|
||||||
|
records,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The distinct contexts present in the ledger, in first-append order.
|
||||||
|
#[must_use]
|
||||||
|
pub fn contexts(&self) -> Vec<EvidenceContext> {
|
||||||
|
let mut out: Vec<EvidenceContext> = Vec::new();
|
||||||
|
for r in &self.records {
|
||||||
|
if !out.contains(&r.context) {
|
||||||
|
out.push(r.context.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Summarize **each** context independently and return one summary per
|
||||||
|
/// context — never a single pooled number across contexts (ADR-304
|
||||||
|
/// §Consequences: "never paper over a thin context with a global average").
|
||||||
|
#[must_use]
|
||||||
|
pub fn summarize(&self) -> Vec<ContextSummary> {
|
||||||
|
self.contexts()
|
||||||
|
.into_iter()
|
||||||
|
.map(|ctx| self.query(&ctx).summarize())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A read-only view of the records for exactly one context. It can only be
|
||||||
|
/// minted by [`EvidenceLedger::query`], so a slice is always single-context —
|
||||||
|
/// there is no constructor that merges two contexts, which is what makes
|
||||||
|
/// pooling impossible through the API.
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct EvidenceSlice<'a> {
|
||||||
|
context: EvidenceContext,
|
||||||
|
records: Vec<&'a EvidenceRecord>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> EvidenceSlice<'a> {
|
||||||
|
/// The single context this slice covers.
|
||||||
|
#[must_use]
|
||||||
|
pub fn context(&self) -> &EvidenceContext {
|
||||||
|
&self.context
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The records in the slice, in append order (read-only).
|
||||||
|
#[must_use]
|
||||||
|
pub fn records(&self) -> &[&'a EvidenceRecord] {
|
||||||
|
&self.records
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Number of records in the slice.
|
||||||
|
#[must_use]
|
||||||
|
pub fn len(&self) -> usize {
|
||||||
|
self.records.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the slice has no records (the context has no evidence).
|
||||||
|
#[must_use]
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.records.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Aggregate the slice into a per-context summary. This is a **pure**
|
||||||
|
/// function of the records (deterministic; no clock, no randomness):
|
||||||
|
///
|
||||||
|
/// - An empty slice yields [`SummaryEvidence::NoEvidence`] — distinct from
|
||||||
|
/// a zero-accuracy summary (ADR-304 §3).
|
||||||
|
/// - The summary's evidence level is the **floor** — the minimum level over
|
||||||
|
/// the records — so a slice can never report a level above its weakest
|
||||||
|
/// record (the "no upgrade" honesty rule). Synthetic (L0) records pin the
|
||||||
|
/// floor to L0.
|
||||||
|
/// - Rates and uncertainty are sample-count-weighted means; `drift` and
|
||||||
|
/// `calibration_age` report the latest (by append sequence) value with
|
||||||
|
/// the running maximum; `sample_count` is the sum. All within this one
|
||||||
|
/// context — nothing is pooled across contexts.
|
||||||
|
#[must_use]
|
||||||
|
pub fn summarize(&self) -> ContextSummary {
|
||||||
|
if self.records.is_empty() {
|
||||||
|
return ContextSummary {
|
||||||
|
context: self.context.clone(),
|
||||||
|
evidence: SummaryEvidence::NoEvidence,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Floor over evidence levels — never an upgrade. Safe: non-empty.
|
||||||
|
let level = self
|
||||||
|
.records
|
||||||
|
.iter()
|
||||||
|
.map(|r| r.level)
|
||||||
|
.min()
|
||||||
|
.expect("slice is non-empty");
|
||||||
|
|
||||||
|
// The class is Measured only if *every* record is Measured; any weaker
|
||||||
|
// record downgrades the aggregate class (honesty, no upgrade).
|
||||||
|
let aggregate_class = self.aggregate_class();
|
||||||
|
|
||||||
|
let mut total_samples: u128 = 0;
|
||||||
|
let mut w_moving: f64 = 0.0;
|
||||||
|
let mut w_stationary: f64 = 0.0;
|
||||||
|
let mut w_fpr: f64 = 0.0;
|
||||||
|
let mut w_uncertainty: f64 = 0.0;
|
||||||
|
let mut max_drift: f64 = 0.0;
|
||||||
|
let mut max_calibration_age_secs: u64 = 0;
|
||||||
|
|
||||||
|
// Latest by append sequence (deterministic, no clock). Records without
|
||||||
|
// a seq (never appended) sort before any appended record.
|
||||||
|
let latest = self
|
||||||
|
.records
|
||||||
|
.iter()
|
||||||
|
.max_by_key(|r| r.seq.unwrap_or(0))
|
||||||
|
.expect("slice is non-empty");
|
||||||
|
|
||||||
|
for r in &self.records {
|
||||||
|
let m = &r.metrics;
|
||||||
|
let w = m.sample_count as f64;
|
||||||
|
total_samples += u128::from(m.sample_count);
|
||||||
|
w_moving += m.moving_recall * w;
|
||||||
|
w_stationary += m.stationary_recall * w;
|
||||||
|
w_fpr += m.false_positive_rate * w;
|
||||||
|
w_uncertainty += m.uncertainty * w;
|
||||||
|
if m.drift > max_drift {
|
||||||
|
max_drift = m.drift;
|
||||||
|
}
|
||||||
|
if m.calibration_age_secs > max_calibration_age_secs {
|
||||||
|
max_calibration_age_secs = m.calibration_age_secs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every record has sample_count >= 1, so the divisor is never zero.
|
||||||
|
let denom = total_samples as f64;
|
||||||
|
let agg = AggregateMetrics {
|
||||||
|
record_count: self.records.len(),
|
||||||
|
sample_count: total_samples,
|
||||||
|
moving_recall: w_moving / denom,
|
||||||
|
stationary_recall: w_stationary / denom,
|
||||||
|
false_positive_rate: w_fpr / denom,
|
||||||
|
uncertainty: w_uncertainty / denom,
|
||||||
|
latest_drift: latest.metrics.drift,
|
||||||
|
max_drift,
|
||||||
|
latest_calibration_age_secs: latest.metrics.calibration_age_secs,
|
||||||
|
max_calibration_age_secs,
|
||||||
|
};
|
||||||
|
|
||||||
|
ContextSummary {
|
||||||
|
context: self.context.clone(),
|
||||||
|
evidence: SummaryEvidence::Aggregated {
|
||||||
|
level,
|
||||||
|
class: aggregate_class,
|
||||||
|
metrics: agg,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn aggregate_class(&self) -> ProvenanceClass {
|
||||||
|
let mut any_synthetic = false;
|
||||||
|
let mut all_measured = true;
|
||||||
|
for r in &self.records {
|
||||||
|
match r.class {
|
||||||
|
ProvenanceClass::Synthetic => any_synthetic = true,
|
||||||
|
ProvenanceClass::Claimed => all_measured = false,
|
||||||
|
ProvenanceClass::Measured => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if any_synthetic {
|
||||||
|
ProvenanceClass::Synthetic
|
||||||
|
} else if all_measured {
|
||||||
|
ProvenanceClass::Measured
|
||||||
|
} else {
|
||||||
|
ProvenanceClass::Claimed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A per-context summary. Always carries the context it belongs to, so a
|
||||||
|
/// summary can never be mistaken for a global rollup.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct ContextSummary {
|
||||||
|
/// The context this summary covers.
|
||||||
|
pub context: EvidenceContext,
|
||||||
|
/// Either "no evidence" or the aggregated metrics for this one context.
|
||||||
|
pub evidence: SummaryEvidence,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ContextSummary {
|
||||||
|
/// Whether this context has any evidence at all.
|
||||||
|
#[must_use]
|
||||||
|
pub fn has_evidence(&self) -> bool {
|
||||||
|
matches!(self.evidence, SummaryEvidence::Aggregated { .. })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The evidence outcome for a context: explicitly absent, or aggregated.
|
||||||
|
///
|
||||||
|
/// [`SummaryEvidence::NoEvidence`] is deliberately **not** a zero-accuracy
|
||||||
|
/// summary: an empty context has *no capability*, which downstream (ADR-318)
|
||||||
|
/// must not read as a `0.0` score.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub enum SummaryEvidence {
|
||||||
|
/// The context has no records — no evidence, not zero accuracy.
|
||||||
|
NoEvidence,
|
||||||
|
/// Aggregated metrics for the one context.
|
||||||
|
Aggregated {
|
||||||
|
/// Floor evidence level (min over the slice) — never upgraded.
|
||||||
|
level: EvidenceLevel,
|
||||||
|
/// Aggregate provenance class (Measured only if all records are).
|
||||||
|
class: ProvenanceClass,
|
||||||
|
/// The aggregated metrics for this context.
|
||||||
|
metrics: AggregateMetrics,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Aggregated metrics for a single context. Every field is derived purely from
|
||||||
|
/// that context's records; nothing here is pooled across contexts.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct AggregateMetrics {
|
||||||
|
/// Number of records aggregated.
|
||||||
|
pub record_count: usize,
|
||||||
|
/// Sum of `sample_count` across records.
|
||||||
|
pub sample_count: u128,
|
||||||
|
/// Sample-weighted mean moving recall.
|
||||||
|
pub moving_recall: f64,
|
||||||
|
/// Sample-weighted mean stationary recall.
|
||||||
|
pub stationary_recall: f64,
|
||||||
|
/// Sample-weighted mean false-positive rate.
|
||||||
|
pub false_positive_rate: f64,
|
||||||
|
/// Sample-weighted mean predictive uncertainty.
|
||||||
|
pub uncertainty: f64,
|
||||||
|
/// Drift of the latest record (by append sequence) — trajectory endpoint.
|
||||||
|
pub latest_drift: f64,
|
||||||
|
/// Maximum drift observed in the context.
|
||||||
|
pub max_drift: f64,
|
||||||
|
/// Calibration age of the latest record, seconds.
|
||||||
|
pub latest_calibration_age_secs: u64,
|
||||||
|
/// Maximum calibration age observed, seconds.
|
||||||
|
pub max_calibration_age_secs: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Errors raised at the ledger's input boundaries. No variant panics; malformed
|
||||||
|
/// input is always a returned error (CLAUDE.md).
|
||||||
|
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
|
||||||
|
pub enum EvidenceError {
|
||||||
|
/// A required context field was empty.
|
||||||
|
#[error("context field `{field}` must not be empty")]
|
||||||
|
EmptyField {
|
||||||
|
/// The offending field name.
|
||||||
|
field: &'static str,
|
||||||
|
},
|
||||||
|
/// A context/reproducer identifier exceeded [`MAX_ID_LEN`].
|
||||||
|
#[error("identifier `{field}` is {len} bytes, exceeds max {max}")]
|
||||||
|
IdTooLong {
|
||||||
|
/// The offending field name.
|
||||||
|
field: &'static str,
|
||||||
|
/// Actual byte length.
|
||||||
|
len: usize,
|
||||||
|
/// Allowed maximum.
|
||||||
|
max: usize,
|
||||||
|
},
|
||||||
|
/// A rate metric was outside `[0, 1]` or non-finite.
|
||||||
|
#[error("rate `{field}` = {value} is out of range [0, 1] or non-finite")]
|
||||||
|
RateOutOfRange {
|
||||||
|
/// The offending field name.
|
||||||
|
field: &'static str,
|
||||||
|
/// The rejected value.
|
||||||
|
value: f64,
|
||||||
|
},
|
||||||
|
/// A magnitude metric was negative or non-finite.
|
||||||
|
#[error("magnitude `{field}` = {value} must be finite and non-negative")]
|
||||||
|
NegativeMagnitude {
|
||||||
|
/// The offending field name.
|
||||||
|
field: &'static str,
|
||||||
|
/// The rejected value.
|
||||||
|
value: f64,
|
||||||
|
},
|
||||||
|
/// A record claimed zero samples.
|
||||||
|
#[error("sample_count must be at least 1")]
|
||||||
|
ZeroSamples,
|
||||||
|
/// A non-synthetic record was minted at L0, which is reserved for
|
||||||
|
/// synthetic input.
|
||||||
|
#[error("L0 is reserved for synthetic records")]
|
||||||
|
SyntheticOnlyL0,
|
||||||
|
/// A measured record was minted without a reproducer handle.
|
||||||
|
#[error("a measured record requires a non-empty reproducer handle")]
|
||||||
|
MissingReproducer,
|
||||||
|
/// The bounded ledger is full.
|
||||||
|
#[error("ledger is full ({max} records)")]
|
||||||
|
LedgerFull {
|
||||||
|
/// The capacity that was reached.
|
||||||
|
max: usize,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn ctx(room: &str, subject: &str) -> EvidenceContext {
|
||||||
|
EvidenceContext::new(room, "dev-esp32-A", subject, "model-v1").expect("valid context")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn metrics(sample_count: u64) -> AccuracyMetrics {
|
||||||
|
AccuracyMetrics {
|
||||||
|
moving_recall: 0.8,
|
||||||
|
stationary_recall: 0.6,
|
||||||
|
false_positive_rate: 0.05,
|
||||||
|
drift: 0.1,
|
||||||
|
uncertainty: 0.2,
|
||||||
|
calibration_age_secs: 3600,
|
||||||
|
sample_count,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn append_assigns_monotonic_seq_and_query_filters_by_context() {
|
||||||
|
let mut ledger = EvidenceLedger::new();
|
||||||
|
let kitchen = ctx("kitchen", "adult");
|
||||||
|
let bedroom = ctx("bedroom", "adult");
|
||||||
|
|
||||||
|
let s0 = ledger
|
||||||
|
.append(EvidenceRecord::synthetic(kitchen.clone(), metrics(10), 1).unwrap())
|
||||||
|
.unwrap();
|
||||||
|
let s1 = ledger
|
||||||
|
.append(EvidenceRecord::synthetic(bedroom.clone(), metrics(20), 2).unwrap())
|
||||||
|
.unwrap();
|
||||||
|
let s2 = ledger
|
||||||
|
.append(EvidenceRecord::synthetic(kitchen.clone(), metrics(30), 3).unwrap())
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!((s0, s1, s2), (0, 1, 2));
|
||||||
|
assert_eq!(ledger.len(), 3);
|
||||||
|
|
||||||
|
let k = ledger.query(&kitchen);
|
||||||
|
assert_eq!(k.len(), 2);
|
||||||
|
assert!(k.records().iter().all(|r| r.context() == &kitchen));
|
||||||
|
|
||||||
|
let b = ledger.query(&bedroom);
|
||||||
|
assert_eq!(b.len(), 1);
|
||||||
|
assert_eq!(b.records()[0].metrics().sample_count, 20);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn records_are_append_only_no_in_place_edit() {
|
||||||
|
// The only mutation is `append`, which consumes by value and stamps a
|
||||||
|
// seq. Corrections are new records; the original is unchanged.
|
||||||
|
let mut ledger = EvidenceLedger::new();
|
||||||
|
let c = ctx("lab", "adult");
|
||||||
|
|
||||||
|
ledger
|
||||||
|
.append(EvidenceRecord::measured(c.clone(), metrics(100), EvidenceLevel::L3, "repro-1", 1).unwrap())
|
||||||
|
.unwrap();
|
||||||
|
// A "correction" is appended, not edited in place.
|
||||||
|
ledger
|
||||||
|
.append(EvidenceRecord::measured(c.clone(), metrics(50), EvidenceLevel::L3, "repro-2", 2).unwrap())
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let slice = ledger.query(&c);
|
||||||
|
assert_eq!(slice.len(), 2);
|
||||||
|
// Original record still present and unmodified.
|
||||||
|
assert_eq!(slice.records()[0].metrics().sample_count, 100);
|
||||||
|
assert_eq!(slice.records()[0].reproducer(), "repro-1");
|
||||||
|
assert_eq!(slice.records()[0].seq(), Some(0));
|
||||||
|
// `records()` returns shared references — no path mutates a stored
|
||||||
|
// record. (If a `&mut` accessor existed this test would need to change;
|
||||||
|
// its absence is the invariant.)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_pooling_across_contexts() {
|
||||||
|
// The API only ever summarizes one context at a time. `summarize()`
|
||||||
|
// returns one entry per context; there is no call that averages two
|
||||||
|
// contexts into a single number.
|
||||||
|
let mut ledger = EvidenceLedger::new();
|
||||||
|
let kitchen = ctx("kitchen", "adult");
|
||||||
|
let bedroom = ctx("bedroom", "adult");
|
||||||
|
|
||||||
|
// Kitchen: perfect. Bedroom: poor. A pooled average would hide the poor
|
||||||
|
// context; per-context summaries must not.
|
||||||
|
let good = AccuracyMetrics { moving_recall: 1.0, ..metrics(100) };
|
||||||
|
let bad = AccuracyMetrics { moving_recall: 0.0, ..metrics(100) };
|
||||||
|
ledger.append(EvidenceRecord::measured(kitchen.clone(), good, EvidenceLevel::L3, "r", 1).unwrap()).unwrap();
|
||||||
|
ledger.append(EvidenceRecord::measured(bedroom.clone(), bad, EvidenceLevel::L3, "r", 2).unwrap()).unwrap();
|
||||||
|
|
||||||
|
let summaries = ledger.summarize();
|
||||||
|
assert_eq!(summaries.len(), 2, "one summary per context, never pooled");
|
||||||
|
|
||||||
|
let k = ledger.query(&kitchen).summarize();
|
||||||
|
let b = ledger.query(&bedroom).summarize();
|
||||||
|
match (k.evidence, b.evidence) {
|
||||||
|
(
|
||||||
|
SummaryEvidence::Aggregated { metrics: km, .. },
|
||||||
|
SummaryEvidence::Aggregated { metrics: bm, .. },
|
||||||
|
) => {
|
||||||
|
assert_eq!(km.moving_recall, 1.0);
|
||||||
|
assert_eq!(bm.moving_recall, 0.0);
|
||||||
|
// No global average exists; if it did it would be 0.5 and hide
|
||||||
|
// the bad context. The API offers no such value.
|
||||||
|
}
|
||||||
|
_ => panic!("both contexts should have evidence"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn evidence_level_floor_is_the_minimum_never_an_upgrade() {
|
||||||
|
let mut ledger = EvidenceLedger::new();
|
||||||
|
let c = ctx("lab", "adult");
|
||||||
|
|
||||||
|
// A strong measured record...
|
||||||
|
ledger.append(EvidenceRecord::measured(c.clone(), metrics(100), EvidenceLevel::L4, "repro", 1).unwrap()).unwrap();
|
||||||
|
// ...alongside a synthetic (L0) record in the same context.
|
||||||
|
ledger.append(EvidenceRecord::synthetic(c.clone(), metrics(100), 2).unwrap()).unwrap();
|
||||||
|
|
||||||
|
let summary = ledger.query(&c).summarize();
|
||||||
|
match summary.evidence {
|
||||||
|
SummaryEvidence::Aggregated { level, class, .. } => {
|
||||||
|
// Floor: the L0 synthetic record pins the level to L0 — the
|
||||||
|
// slice cannot report the higher L4.
|
||||||
|
assert_eq!(level, EvidenceLevel::L0);
|
||||||
|
// And the class downgrades to Synthetic (no upgrade).
|
||||||
|
assert_eq!(class, ProvenanceClass::Synthetic);
|
||||||
|
}
|
||||||
|
SummaryEvidence::NoEvidence => panic!("context has records"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn synthetic_is_forced_l0_and_cannot_be_upgraded() {
|
||||||
|
let c = ctx("sim", "adult");
|
||||||
|
let rec = EvidenceRecord::synthetic(c, metrics(10), 1).unwrap();
|
||||||
|
assert_eq!(rec.level(), EvidenceLevel::L0);
|
||||||
|
assert_eq!(rec.class(), ProvenanceClass::Synthetic);
|
||||||
|
// There is no setter to raise the level: the type has no `set_level`.
|
||||||
|
|
||||||
|
// A non-synthetic record cannot occupy L0.
|
||||||
|
let c2 = ctx("sim", "adult");
|
||||||
|
assert_eq!(
|
||||||
|
EvidenceRecord::claimed(c2, metrics(10), EvidenceLevel::L0, 1).unwrap_err(),
|
||||||
|
EvidenceError::SyntheticOnlyL0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_context_is_no_evidence_not_zero_accuracy() {
|
||||||
|
let ledger = EvidenceLedger::new();
|
||||||
|
let never_seen = ctx("attic", "adult");
|
||||||
|
|
||||||
|
let slice = ledger.query(&never_seen);
|
||||||
|
assert!(slice.is_empty());
|
||||||
|
|
||||||
|
let summary = slice.summarize();
|
||||||
|
assert!(!summary.has_evidence());
|
||||||
|
assert_eq!(summary.evidence, SummaryEvidence::NoEvidence);
|
||||||
|
// Explicitly NOT a zero-accuracy Aggregated summary.
|
||||||
|
assert!(!matches!(summary.evidence, SummaryEvidence::Aggregated { .. }));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn summarize_is_deterministic_and_serde_round_trips() {
|
||||||
|
let build = || {
|
||||||
|
let mut ledger = EvidenceLedger::new();
|
||||||
|
let c = ctx("kitchen", "adult");
|
||||||
|
ledger.append(EvidenceRecord::measured(c.clone(), metrics(100), EvidenceLevel::L3, "r1", 10).unwrap()).unwrap();
|
||||||
|
ledger.append(EvidenceRecord::measured(c.clone(), metrics(300), EvidenceLevel::L4, "r2", 20).unwrap()).unwrap();
|
||||||
|
ledger
|
||||||
|
};
|
||||||
|
|
||||||
|
let a = build().summarize();
|
||||||
|
let b = build().summarize();
|
||||||
|
assert_eq!(a, b, "aggregation is a pure function of the records");
|
||||||
|
|
||||||
|
// Sample-weighted mean check: same metrics, weights 100 and 300 → 0.8.
|
||||||
|
let c = ctx("kitchen", "adult");
|
||||||
|
let s = build().query(&c).summarize();
|
||||||
|
if let SummaryEvidence::Aggregated { level, metrics: m, .. } = &s.evidence {
|
||||||
|
assert_eq!(*level, EvidenceLevel::L3); // floor of L3 and L4
|
||||||
|
assert_eq!(m.sample_count, 400);
|
||||||
|
assert!((m.moving_recall - 0.8).abs() < 1e-12);
|
||||||
|
assert_eq!(m.latest_calibration_age_secs, 3600);
|
||||||
|
} else {
|
||||||
|
panic!("expected aggregated evidence");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serde round-trip of a summary is stable.
|
||||||
|
let json = serde_json::to_string(&a).unwrap();
|
||||||
|
let back: Vec<ContextSummary> = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(a, back);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn boundary_validation_rejects_malformed_input_without_panicking() {
|
||||||
|
assert_eq!(
|
||||||
|
EvidenceContext::new("", "d", "s", "m").unwrap_err(),
|
||||||
|
EvidenceError::EmptyField { field: "room" }
|
||||||
|
);
|
||||||
|
let long = "x".repeat(MAX_ID_LEN + 1);
|
||||||
|
assert!(matches!(
|
||||||
|
EvidenceContext::new(long, "d", "s", "m").unwrap_err(),
|
||||||
|
EvidenceError::IdTooLong { .. }
|
||||||
|
));
|
||||||
|
|
||||||
|
let bad_rate = AccuracyMetrics { moving_recall: 1.5, ..metrics(1) };
|
||||||
|
assert!(matches!(
|
||||||
|
bad_rate.validate().unwrap_err(),
|
||||||
|
EvidenceError::RateOutOfRange { .. }
|
||||||
|
));
|
||||||
|
let nan = AccuracyMetrics { uncertainty: f64::NAN, ..metrics(1) };
|
||||||
|
assert!(matches!(
|
||||||
|
nan.validate().unwrap_err(),
|
||||||
|
EvidenceError::NegativeMagnitude { .. }
|
||||||
|
));
|
||||||
|
let zero = AccuracyMetrics { sample_count: 0, ..metrics(1) };
|
||||||
|
assert_eq!(zero.validate().unwrap_err(), EvidenceError::ZeroSamples);
|
||||||
|
|
||||||
|
let c = ctx("lab", "adult");
|
||||||
|
assert_eq!(
|
||||||
|
EvidenceRecord::measured(c, metrics(1), EvidenceLevel::L3, "", 1).unwrap_err(),
|
||||||
|
EvidenceError::MissingReproducer
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ledger_capacity_is_bounded() {
|
||||||
|
let mut ledger = EvidenceLedger::with_capacity(1);
|
||||||
|
let c = ctx("lab", "adult");
|
||||||
|
ledger.append(EvidenceRecord::synthetic(c.clone(), metrics(1), 1).unwrap()).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
ledger.append(EvidenceRecord::synthetic(c, metrics(1), 2).unwrap()).unwrap_err(),
|
||||||
|
EvidenceError::LedgerFull { max: 1 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
16
v2/crates/ruview-fusion/Cargo.toml
Normal file
16
v2/crates/ruview-fusion/Cargo.toml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
[package]
|
||||||
|
name = "ruview-fusion"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
authors.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
thiserror.workspace = true
|
||||||
|
serde = { workspace = true, features = ["derive"] }
|
||||||
|
ruview-ontology = { path = "../ruview-ontology" }
|
||||||
|
ruview-hal = { path = "../ruview-hal" }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
serde_json.workspace = true
|
||||||
200
v2/crates/ruview-fusion/src/engine.rs
Normal file
200
v2/crates/ruview-fusion/src/engine.rs
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
//! The fusion engine (ADR-311 §2): many observations → one world state.
|
||||||
|
//!
|
||||||
|
//! [`FusionEngine::fuse`] groups the input observations by their canonical
|
||||||
|
//! container and, for each container, combines the usable presence estimates by
|
||||||
|
//! inverse-variance weighting into one [`ZoneState`]. It is a pure, deterministic
|
||||||
|
//! function of its inputs: no I/O, no clock, no randomness. The disagreement
|
||||||
|
//! between sources is measured against their stated uncertainty; when it exceeds
|
||||||
|
//! the configured threshold the zone resolves to [`UnknownReason::IrreconcilableConflict`]
|
||||||
|
//! rather than a confident average, and when too few sources cover a zone it
|
||||||
|
//! resolves to [`UnknownReason::InsufficientCoverage`].
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
use ruview_ontology::{Container, EvidenceLevel};
|
||||||
|
|
||||||
|
use crate::estimate::{combine, Estimate};
|
||||||
|
use crate::observation::PresenceObservation;
|
||||||
|
use crate::world::{Contribution, Presence, UnknownReason, WorldState, ZoneState};
|
||||||
|
|
||||||
|
/// Configuration for the fusion engine.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||||
|
pub struct FusionConfig {
|
||||||
|
/// Reduced chi-square disagreement threshold. When contributing sources
|
||||||
|
/// disagree by more than this (relative to their stated uncertainty), the
|
||||||
|
/// zone resolves to UNKNOWN (irreconcilable conflict) instead of a confident
|
||||||
|
/// average. The default `9.0` corresponds to roughly a 3-sigma pairwise
|
||||||
|
/// disagreement.
|
||||||
|
pub conflict_reduced_chi_square: f64,
|
||||||
|
/// Minimum number of usable (non-degraded, quantified) observations required
|
||||||
|
/// to resolve a zone. Below this the zone is UNKNOWN (insufficient
|
||||||
|
/// coverage). Values below `1` are treated as `1`.
|
||||||
|
pub min_observations: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for FusionConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
conflict_reduced_chi_square: 9.0,
|
||||||
|
min_observations: 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FusionConfig {
|
||||||
|
/// The effective minimum observation count (never below `1`).
|
||||||
|
fn effective_min(&self) -> usize {
|
||||||
|
self.min_observations.max(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A deterministic, uncertainty-aware multimodal fusion engine.
|
||||||
|
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||||
|
pub struct FusionEngine {
|
||||||
|
config: FusionConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FusionEngine {
|
||||||
|
/// Build an engine with the given configuration.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(config: FusionConfig) -> Self {
|
||||||
|
Self { config }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The engine's configuration.
|
||||||
|
#[must_use]
|
||||||
|
pub fn config(&self) -> FusionConfig {
|
||||||
|
self.config
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fuse a set of observations into one probabilistic world state.
|
||||||
|
///
|
||||||
|
/// Observations are grouped by their canonical container; each group becomes
|
||||||
|
/// one [`ZoneState`]. Malformed input never panics: a degraded observation
|
||||||
|
/// simply abstains. The result is independent of input order (observations
|
||||||
|
/// are combined in a canonical order), so the fusion is deterministic.
|
||||||
|
#[must_use]
|
||||||
|
pub fn fuse(&self, observations: &[PresenceObservation]) -> WorldState {
|
||||||
|
// Group observation indices by a stable container key so the output
|
||||||
|
// order is deterministic and independent of input order.
|
||||||
|
let mut groups: BTreeMap<(u8, String), Vec<usize>> = BTreeMap::new();
|
||||||
|
for (i, obs) in observations.iter().enumerate() {
|
||||||
|
let (kind, id) = container_key(&obs.hal.observation.located_in);
|
||||||
|
groups.entry((kind, id.to_string())).or_default().push(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
let at_unix_ms = observations
|
||||||
|
.iter()
|
||||||
|
.map(|o| o.hal.observation.at_unix_ms)
|
||||||
|
.max()
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
let zones = groups
|
||||||
|
.into_values()
|
||||||
|
.map(|idxs| self.fuse_zone(observations, &idxs))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
WorldState { at_unix_ms, zones }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fuse the observations that share one container into a single zone state.
|
||||||
|
fn fuse_zone(&self, observations: &[PresenceObservation], idxs: &[usize]) -> ZoneState {
|
||||||
|
let container = observations[idxs[0]].hal.observation.located_in.clone();
|
||||||
|
|
||||||
|
// Canonical order: sort by observation id so the fused value and the
|
||||||
|
// provenance ordering do not depend on input order.
|
||||||
|
let mut order = idxs.to_vec();
|
||||||
|
order.sort_by(|&a, &b| {
|
||||||
|
observations[a]
|
||||||
|
.hal
|
||||||
|
.observation
|
||||||
|
.id
|
||||||
|
.as_str()
|
||||||
|
.cmp(observations[b].hal.observation.id.as_str())
|
||||||
|
});
|
||||||
|
|
||||||
|
// Split into contributing (usable estimate) and abstaining sources.
|
||||||
|
let mut contributors: Vec<(usize, Estimate)> = Vec::new();
|
||||||
|
for &i in &order {
|
||||||
|
if let Some(est) = observations[i].usable_estimate() {
|
||||||
|
contributors.push((i, est));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut weight_by_idx: BTreeMap<usize, f64> = BTreeMap::new();
|
||||||
|
let (presence, evidence_level) = if contributors.len() < self.config.effective_min() {
|
||||||
|
// Not enough usable coverage to resolve this zone.
|
||||||
|
(
|
||||||
|
Presence::Unknown {
|
||||||
|
reason: UnknownReason::InsufficientCoverage,
|
||||||
|
},
|
||||||
|
EvidenceLevel::L0,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
let estimates: Vec<Estimate> = contributors.iter().map(|(_, e)| *e).collect();
|
||||||
|
// Safe: contributors is non-empty here (>= effective_min >= 1).
|
||||||
|
let combined = combine(&estimates).expect("non-empty contributor set");
|
||||||
|
|
||||||
|
// Evidence never rises above the weakest contributing input.
|
||||||
|
let evidence = contributors
|
||||||
|
.iter()
|
||||||
|
.map(|(i, _)| observations[*i].hal.evidence_level())
|
||||||
|
.min()
|
||||||
|
.unwrap_or(EvidenceLevel::L0);
|
||||||
|
|
||||||
|
// Record normalized inverse-variance weights for auditability.
|
||||||
|
let precision_sum: f64 = estimates.iter().map(Estimate::precision).sum();
|
||||||
|
for (i, e) in &contributors {
|
||||||
|
weight_by_idx.insert(*i, e.precision() / precision_sum);
|
||||||
|
}
|
||||||
|
|
||||||
|
let presence = if combined.reduced_chi_square > self.config.conflict_reduced_chi_square {
|
||||||
|
// Sources disagree beyond their stated uncertainty: refuse to
|
||||||
|
// emit a confident average of irreconcilable evidence.
|
||||||
|
Presence::Unknown {
|
||||||
|
reason: UnknownReason::IrreconcilableConflict {
|
||||||
|
reduced_chi_square: combined.reduced_chi_square,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Presence::Estimated {
|
||||||
|
probability: combined.probability,
|
||||||
|
variance: combined.variance,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
(presence, evidence)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Per-observation provenance for every observation in the group.
|
||||||
|
let contributions = order
|
||||||
|
.iter()
|
||||||
|
.map(|&i| {
|
||||||
|
let obs = &observations[i];
|
||||||
|
Contribution {
|
||||||
|
observation: obs.hal.observation.id.clone(),
|
||||||
|
sensor: obs.hal.sensor().clone(),
|
||||||
|
modality: obs.hal.modality.clone(),
|
||||||
|
evidence_level: obs.hal.evidence_level(),
|
||||||
|
estimate: obs.usable_estimate(),
|
||||||
|
weight: weight_by_idx.get(&i).copied().unwrap_or(0.0),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
ZoneState {
|
||||||
|
container,
|
||||||
|
presence,
|
||||||
|
evidence_level,
|
||||||
|
contributions,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A stable ordering/grouping key for a container: a kind discriminant plus its
|
||||||
|
/// id string. Two containers with the same key are the same container.
|
||||||
|
fn container_key(container: &Container) -> (u8, &str) {
|
||||||
|
match container {
|
||||||
|
Container::Space { id } => (0, id.as_str()),
|
||||||
|
Container::Zone { id } => (1, id.as_str()),
|
||||||
|
}
|
||||||
|
}
|
||||||
99
v2/crates/ruview-fusion/src/estimate.rs
Normal file
99
v2/crates/ruview-fusion/src/estimate.rs
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
//! The presence estimate and its uncertainty-aware combination (ADR-311 §2).
|
||||||
|
//!
|
||||||
|
//! An [`Estimate`] is a single sensor's belief about zone occupancy expressed as
|
||||||
|
//! a probability with a variance. Estimates combine by **inverse-variance
|
||||||
|
//! weighting** — the standard optimal linear combination of independent
|
||||||
|
//! Gaussian estimates (equivalently a product of Gaussians / a static Kalman
|
||||||
|
//! update): a low-variance (confident) estimate dominates and a high-variance
|
||||||
|
//! (uncertain) one is down-weighted, and combining agreeing estimates *lowers*
|
||||||
|
//! the fused variance (the belief sharpens). This is deliberately **not** a
|
||||||
|
//! naive mean, which would ignore how certain each source is and could never
|
||||||
|
//! sharpen (ADR-311: "uncertainty-weighted ... not a silently averaged value").
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// A presence estimate: the probability of occupancy and its variance.
|
||||||
|
///
|
||||||
|
/// `probability` is bounded to `[0.0, 1.0]` and `variance` is strictly
|
||||||
|
/// positive and finite — both enforced by [`Estimate::new`], so a malformed
|
||||||
|
/// estimate can never enter the fusion arithmetic (it is rejected at the
|
||||||
|
/// boundary and the source abstains instead).
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct Estimate {
|
||||||
|
/// Probability of occupancy, in the closed unit interval `[0.0, 1.0]`.
|
||||||
|
pub probability: f64,
|
||||||
|
/// Variance of the estimate; strictly positive. Smaller ⇒ more certain.
|
||||||
|
pub variance: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Estimate {
|
||||||
|
/// Construct a validated estimate, or `None` when the inputs cannot form a
|
||||||
|
/// weightable estimate (non-finite value, or variance `<= 0`). A NaN/inf
|
||||||
|
/// probability or a zero/negative variance is rejected rather than
|
||||||
|
/// propagated as a poisoned weight; the probability is clamped into
|
||||||
|
/// `[0.0, 1.0]`.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(probability: f64, variance: f64) -> Option<Self> {
|
||||||
|
if !probability.is_finite() || !variance.is_finite() || variance <= 0.0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(Self {
|
||||||
|
probability: probability.clamp(0.0, 1.0),
|
||||||
|
variance,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The precision (inverse variance) — the weight this estimate carries in an
|
||||||
|
/// inverse-variance combination.
|
||||||
|
#[must_use]
|
||||||
|
pub fn precision(&self) -> f64 {
|
||||||
|
1.0 / self.variance
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The result of inverse-variance combination over a non-empty set of
|
||||||
|
/// estimates: the fused mean/variance plus the reduced chi-square disagreement
|
||||||
|
/// statistic used to detect irreconcilable conflict.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||||
|
pub struct Combined {
|
||||||
|
/// The inverse-variance-weighted mean probability, clamped to `[0.0, 1.0]`.
|
||||||
|
pub probability: f64,
|
||||||
|
/// The fused variance `1 / Σ precision` — never larger than the smallest
|
||||||
|
/// contributing variance, so agreeing estimates sharpen the belief.
|
||||||
|
pub variance: f64,
|
||||||
|
/// Reduced chi-square `Σ wᵢ (pᵢ − mean)² / dof` (dof = `n − 1`, floored at
|
||||||
|
/// 1). Near 0 when sources agree relative to their stated uncertainty;
|
||||||
|
/// large when they disagree by more than that uncertainty allows.
|
||||||
|
pub reduced_chi_square: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Combine independent presence estimates by inverse-variance weighting.
|
||||||
|
///
|
||||||
|
/// Returns `None` for an empty input (there is nothing to fuse — the caller
|
||||||
|
/// resolves that to UNKNOWN / insufficient coverage). For a single estimate the
|
||||||
|
/// fused mean and variance are that estimate's own (pass-through) and the
|
||||||
|
/// disagreement statistic is 0.
|
||||||
|
#[must_use]
|
||||||
|
pub fn combine(estimates: &[Estimate]) -> Option<Combined> {
|
||||||
|
if estimates.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let precision_sum: f64 = estimates.iter().map(Estimate::precision).sum();
|
||||||
|
// precision_sum is strictly positive because every Estimate has variance > 0.
|
||||||
|
let mean = estimates
|
||||||
|
.iter()
|
||||||
|
.map(|e| e.probability * e.precision())
|
||||||
|
.sum::<f64>()
|
||||||
|
/ precision_sum;
|
||||||
|
let variance = 1.0 / precision_sum;
|
||||||
|
let chi_square: f64 = estimates
|
||||||
|
.iter()
|
||||||
|
.map(|e| e.precision() * (e.probability - mean).powi(2))
|
||||||
|
.sum();
|
||||||
|
let dof = (estimates.len() - 1).max(1) as f64;
|
||||||
|
Some(Combined {
|
||||||
|
probability: mean.clamp(0.0, 1.0),
|
||||||
|
variance,
|
||||||
|
reduced_chi_square: chi_square / dof,
|
||||||
|
})
|
||||||
|
}
|
||||||
489
v2/crates/ruview-fusion/src/lib.rs
Normal file
489
v2/crates/ruview-fusion/src/lib.rs
Normal file
@@ -0,0 +1,489 @@
|
|||||||
|
//! # `ruview-fusion` — uncertainty-aware sensor fusion (ADR-311, ADR-300 §11)
|
||||||
|
//!
|
||||||
|
//! **Many observations resolve to one probabilistic world state, not many feeds
|
||||||
|
//! into a visualization.** This is the defining invariant of ADR-311: a
|
||||||
|
//! dashboard that shows a WiFi layer, a mmWave layer, and a BLE layer side by
|
||||||
|
//! side is not fusion — it pushes reconciliation onto the human. Real fusion
|
||||||
|
//! produces *one* uncertainty-aware [`WorldState`] that every downstream
|
||||||
|
//! consumer (ADR-312 spatial memory, ADR-313 counterfactual, ADR-315 RF twin)
|
||||||
|
//! reads, with each contributing observation's provenance and confidence still
|
||||||
|
//! recoverable.
|
||||||
|
//!
|
||||||
|
//! [`FusionEngine`] ingests a set of [`PresenceObservation`]s — canonical
|
||||||
|
//! ADR-306 [`HalObservation`](ruview_hal::HalObservation)s paired with a
|
||||||
|
//! per-source occupancy [`Claim`] — that may span modalities (WiFi/CSI, BLE,
|
||||||
|
//! UWB, mmWave, …) and may conflict, and emits a single [`WorldState`]: a fused
|
||||||
|
//! per-container occupancy probability with a fused variance, the set of
|
||||||
|
//! contributing observations as recoverable provenance, and an aggregate
|
||||||
|
//! evidence level.
|
||||||
|
//!
|
||||||
|
//! ## How sources are combined
|
||||||
|
//!
|
||||||
|
//! Presence estimates combine by **inverse-variance weighting** (see
|
||||||
|
//! [`estimate::combine`]), the optimal linear combination of independent
|
||||||
|
//! Gaussian estimates. Concretely, for sources with probabilities `pᵢ` and
|
||||||
|
//! variances `vᵢ`, with precisions `wᵢ = 1/vᵢ`:
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! fused mean = Σ wᵢ pᵢ / Σ wᵢ
|
||||||
|
//! fused variance = 1 / Σ wᵢ
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! This is deliberately **not** a naive average:
|
||||||
|
//!
|
||||||
|
//! - **Agreement sharpens.** Two agreeing sources yield a fused variance
|
||||||
|
//! *smaller* than either input — the belief gets more certain, which a mean
|
||||||
|
//! can never do.
|
||||||
|
//! - **Uncertainty is respected.** A high-variance source gets a small weight
|
||||||
|
//! and barely moves the fused value; it is down-weighted, not averaged in as
|
||||||
|
//! if trustworthy.
|
||||||
|
//!
|
||||||
|
//! ## When the answer is UNKNOWN (ADR-300 rule 1)
|
||||||
|
//!
|
||||||
|
//! UNKNOWN is a first-class world-state value, never an error or a panic:
|
||||||
|
//!
|
||||||
|
//! - **Irreconcilable conflict.** When sources disagree by more than their
|
||||||
|
//! stated uncertainty allows — measured by a reduced chi-square statistic
|
||||||
|
//! against a configured threshold — the zone resolves to
|
||||||
|
//! [`UnknownReason::IrreconcilableConflict`] instead of a confident average
|
||||||
|
//! near the midpoint of two contradictory claims.
|
||||||
|
//! - **Insufficient coverage.** When too few usable observations cover a zone
|
||||||
|
//! (all degraded/abstaining, or below the configured minimum), the zone
|
||||||
|
//! resolves to [`UnknownReason::InsufficientCoverage`].
|
||||||
|
//!
|
||||||
|
//! ## Evidence and honesty discipline
|
||||||
|
//!
|
||||||
|
//! The fused evidence level is the **minimum** over contributing observations —
|
||||||
|
//! never lifted above the weakest necessary input (ADR-311). This crate asserts
|
||||||
|
//! **no accuracy number and makes no camera-grade claim** (CLAUDE.md, ADR-282);
|
||||||
|
//! its tests use synthetic in-code fixtures only (SYNTHETIC / L0..L2). It is a
|
||||||
|
//! pure, deterministic function of its inputs: no I/O, no clock, no randomness,
|
||||||
|
//! and malformed input abstains rather than panicking.
|
||||||
|
//!
|
||||||
|
//! ## Example
|
||||||
|
//!
|
||||||
|
//! ```
|
||||||
|
//! use ruview_fusion::{FusionEngine, PresenceObservation, Presence};
|
||||||
|
//! use ruview_hal::{HalObservation, Modality, Uncertainty};
|
||||||
|
//! use ruview_ontology::{
|
||||||
|
//! Container, EvidenceLevel, Observation, ObservationId, SemanticProvenance, SensorId, SpaceId,
|
||||||
|
//! };
|
||||||
|
//!
|
||||||
|
//! fn hal(id: &str, sensor: &str, modality: Modality) -> HalObservation {
|
||||||
|
//! HalObservation {
|
||||||
|
//! modality,
|
||||||
|
//! uncertainty: Uncertainty::known(0.9),
|
||||||
|
//! observation: Observation {
|
||||||
|
//! id: ObservationId::new(id).unwrap(),
|
||||||
|
//! sensor: SensorId::new(sensor).unwrap(),
|
||||||
|
//! located_in: Container::Space { id: SpaceId::new("kitchen").unwrap() },
|
||||||
|
//! at_unix_ms: 1_000,
|
||||||
|
//! evidence_level: EvidenceLevel::L2,
|
||||||
|
//! provenance: SemanticProvenance::declared("fusion@1"),
|
||||||
|
//! },
|
||||||
|
//! }
|
||||||
|
//! }
|
||||||
|
//!
|
||||||
|
//! let engine = FusionEngine::default();
|
||||||
|
//! // WiFi and mmWave agree the kitchen is occupied — the belief sharpens.
|
||||||
|
//! let world = engine.fuse(&[
|
||||||
|
//! PresenceObservation::estimated(hal("o1", "csi-1", Modality::Csi), 0.90, 0.04),
|
||||||
|
//! PresenceObservation::estimated(hal("o2", "mm-1", Modality::Mmwave), 0.88, 0.04),
|
||||||
|
//! ]);
|
||||||
|
//!
|
||||||
|
//! let kitchen = Container::Space { id: SpaceId::new("kitchen").unwrap() };
|
||||||
|
//! let zone = world.zone(&kitchen).unwrap();
|
||||||
|
//! match zone.presence {
|
||||||
|
//! Presence::Estimated { probability, variance } => {
|
||||||
|
//! assert!(probability > 0.85 && probability < 0.92);
|
||||||
|
//! assert!(variance < 0.04); // sharper than either input
|
||||||
|
//! }
|
||||||
|
//! Presence::Unknown { .. } => unreachable!(),
|
||||||
|
//! }
|
||||||
|
//! // Both observations' provenance is recoverable.
|
||||||
|
//! assert_eq!(zone.contributions.len(), 2);
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
#![forbid(unsafe_code)]
|
||||||
|
#![warn(missing_docs)]
|
||||||
|
|
||||||
|
pub mod estimate;
|
||||||
|
mod engine;
|
||||||
|
mod observation;
|
||||||
|
mod world;
|
||||||
|
|
||||||
|
pub use engine::{FusionConfig, FusionEngine};
|
||||||
|
pub use estimate::Estimate;
|
||||||
|
pub use observation::{Claim, PresenceObservation};
|
||||||
|
pub use world::{Contribution, Presence, UnknownReason, WorldState, ZoneState};
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use ruview_hal::{HalObservation, Modality, Uncertainty};
|
||||||
|
use ruview_ontology::{
|
||||||
|
Container, EvidenceLevel, Observation, ObservationId, SemanticProvenance, SensorId, SpaceId,
|
||||||
|
};
|
||||||
|
|
||||||
|
const KITCHEN: &str = "kitchen";
|
||||||
|
|
||||||
|
fn approx(a: f64, b: f64) -> bool {
|
||||||
|
(a - b).abs() < 1e-9
|
||||||
|
}
|
||||||
|
|
||||||
|
fn container(space: &str) -> Container {
|
||||||
|
Container::Space {
|
||||||
|
id: SpaceId::new(space).unwrap(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A synthetic, non-degraded HAL observation in the given space.
|
||||||
|
fn hal(id: &str, sensor: &str, modality: Modality, space: &str, ev: EvidenceLevel) -> HalObservation {
|
||||||
|
HalObservation {
|
||||||
|
modality,
|
||||||
|
uncertainty: Uncertainty::known(0.9),
|
||||||
|
observation: Observation {
|
||||||
|
id: ObservationId::new(id).unwrap(),
|
||||||
|
sensor: SensorId::new(sensor).unwrap(),
|
||||||
|
located_in: container(space),
|
||||||
|
at_unix_ms: 1_700_000_000_000,
|
||||||
|
evidence_level: ev,
|
||||||
|
provenance: SemanticProvenance::declared("synthetic-fusion@0"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A degraded (malformed-input) HAL observation, as the HAL emits for bad
|
||||||
|
/// raw frames: UNKNOWN/degraded uncertainty.
|
||||||
|
fn degraded_hal(id: &str, sensor: &str, space: &str) -> HalObservation {
|
||||||
|
HalObservation {
|
||||||
|
modality: Modality::Csi,
|
||||||
|
uncertainty: Uncertainty::degraded(),
|
||||||
|
observation: Observation {
|
||||||
|
id: ObservationId::new(id).unwrap(),
|
||||||
|
sensor: SensorId::new(sensor).unwrap(),
|
||||||
|
located_in: container(space),
|
||||||
|
at_unix_ms: 1_700_000_000_000,
|
||||||
|
evidence_level: EvidenceLevel::L0,
|
||||||
|
provenance: SemanticProvenance::declared("synthetic-fusion@0"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn est_probability(p: &Presence) -> f64 {
|
||||||
|
match *p {
|
||||||
|
Presence::Estimated { probability, .. } => probability,
|
||||||
|
Presence::Unknown { .. } => panic!("expected Estimated"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn est_variance(p: &Presence) -> f64 {
|
||||||
|
match *p {
|
||||||
|
Presence::Estimated { variance, .. } => variance,
|
||||||
|
Presence::Unknown { .. } => panic!("expected Estimated"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two agreeing observations SHARPEN the estimate: the fused variance is
|
||||||
|
// strictly smaller than either contributing variance.
|
||||||
|
#[test]
|
||||||
|
fn agreeing_observations_sharpen() {
|
||||||
|
let engine = FusionEngine::default();
|
||||||
|
let world = engine.fuse(&[
|
||||||
|
PresenceObservation::estimated(
|
||||||
|
hal("o1", "csi-1", Modality::Csi, KITCHEN, EvidenceLevel::L2),
|
||||||
|
0.90,
|
||||||
|
0.04,
|
||||||
|
),
|
||||||
|
PresenceObservation::estimated(
|
||||||
|
hal("o2", "mm-1", Modality::Mmwave, KITCHEN, EvidenceLevel::L2),
|
||||||
|
0.88,
|
||||||
|
0.04,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert_eq!(world.zones.len(), 1, "one world state, one zone — not two feeds");
|
||||||
|
let zone = world.zone(&container(KITCHEN)).unwrap();
|
||||||
|
assert!(!zone.is_unknown());
|
||||||
|
// Inverse-variance of two equal variances: 1/(1/0.04 + 1/0.04) = 0.02.
|
||||||
|
assert!(approx(est_variance(&zone.presence), 0.02));
|
||||||
|
assert!(est_variance(&zone.presence) < 0.04);
|
||||||
|
// Mean lies between the two agreeing inputs.
|
||||||
|
let p = est_probability(&zone.presence);
|
||||||
|
assert!(p > 0.88 && p < 0.90);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A high-uncertainty observation is DOWN-WEIGHTED: it barely moves the fused
|
||||||
|
// value away from the precise source, and its recorded weight is tiny.
|
||||||
|
#[test]
|
||||||
|
fn high_uncertainty_observation_is_down_weighted() {
|
||||||
|
let engine = FusionEngine::default();
|
||||||
|
let world = engine.fuse(&[
|
||||||
|
// Precise: p=0.9, v=0.01 (precision 100).
|
||||||
|
PresenceObservation::estimated(
|
||||||
|
hal("o1", "csi-1", Modality::Csi, KITCHEN, EvidenceLevel::L2),
|
||||||
|
0.90,
|
||||||
|
0.01,
|
||||||
|
),
|
||||||
|
// Very uncertain: p=0.2, v=1.0 (precision 1).
|
||||||
|
PresenceObservation::estimated(
|
||||||
|
hal("o2", "ble-1", Modality::Ble, KITCHEN, EvidenceLevel::L2),
|
||||||
|
0.20,
|
||||||
|
1.0,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let zone = world.zone(&container(KITCHEN)).unwrap();
|
||||||
|
assert!(!zone.is_unknown());
|
||||||
|
// The uncertain source pulls the fused value only slightly off 0.9.
|
||||||
|
let p = est_probability(&zone.presence);
|
||||||
|
assert!((p - 0.9).abs() < 0.02, "fused {p} should stay near the precise 0.9");
|
||||||
|
|
||||||
|
// The precise source carries almost all the weight.
|
||||||
|
let precise = zone
|
||||||
|
.contributions
|
||||||
|
.iter()
|
||||||
|
.find(|c| c.observation.as_str() == "o1")
|
||||||
|
.unwrap();
|
||||||
|
let uncertain = zone
|
||||||
|
.contributions
|
||||||
|
.iter()
|
||||||
|
.find(|c| c.observation.as_str() == "o2")
|
||||||
|
.unwrap();
|
||||||
|
assert!(precise.weight > 0.98);
|
||||||
|
assert!(uncertain.weight < 0.02);
|
||||||
|
assert!(uncertain.weight < precise.weight);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Irreconcilable conflict yields UNKNOWN, NOT a confident average near 0.5.
|
||||||
|
#[test]
|
||||||
|
fn irreconcilable_conflict_yields_unknown() {
|
||||||
|
let engine = FusionEngine::default();
|
||||||
|
let world = engine.fuse(&[
|
||||||
|
// Confident "occupied".
|
||||||
|
PresenceObservation::estimated(
|
||||||
|
hal("o1", "csi-1", Modality::Csi, KITCHEN, EvidenceLevel::L2),
|
||||||
|
0.95,
|
||||||
|
0.01,
|
||||||
|
),
|
||||||
|
// Confident "empty" — directly contradicts, both low-variance.
|
||||||
|
PresenceObservation::estimated(
|
||||||
|
hal("o2", "mm-1", Modality::Mmwave, KITCHEN, EvidenceLevel::L2),
|
||||||
|
0.05,
|
||||||
|
0.01,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let zone = world.zone(&container(KITCHEN)).unwrap();
|
||||||
|
assert!(zone.is_unknown(), "conflict must not collapse to a confident average");
|
||||||
|
match zone.presence {
|
||||||
|
Presence::Unknown {
|
||||||
|
reason: UnknownReason::IrreconcilableConflict { reduced_chi_square },
|
||||||
|
} => {
|
||||||
|
assert!(reduced_chi_square > 9.0);
|
||||||
|
}
|
||||||
|
other => panic!("expected IrreconcilableConflict, got {other:?}"),
|
||||||
|
}
|
||||||
|
// Both contradictory observations are still recorded as provenance.
|
||||||
|
assert_eq!(zone.contributions.len(), 2);
|
||||||
|
assert_eq!(zone.contributing_observations().count(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A single observation PASSES THROUGH with its own probability and
|
||||||
|
// uncertainty (no artificial sharpening, no conflict).
|
||||||
|
#[test]
|
||||||
|
fn single_observation_passes_through() {
|
||||||
|
let engine = FusionEngine::default();
|
||||||
|
let world = engine.fuse(&[PresenceObservation::estimated(
|
||||||
|
hal("o1", "csi-1", Modality::Csi, KITCHEN, EvidenceLevel::L2),
|
||||||
|
0.70,
|
||||||
|
0.05,
|
||||||
|
)]);
|
||||||
|
|
||||||
|
let zone = world.zone(&container(KITCHEN)).unwrap();
|
||||||
|
assert!(!zone.is_unknown());
|
||||||
|
assert!(approx(est_probability(&zone.presence), 0.70));
|
||||||
|
assert!(approx(est_variance(&zone.presence), 0.05));
|
||||||
|
assert_eq!(zone.contributions.len(), 1);
|
||||||
|
// The lone source carries all the weight.
|
||||||
|
assert!(approx(zone.contributions[0].weight, 1.0));
|
||||||
|
assert_eq!(zone.evidence_level, EvidenceLevel::L2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Provenance is preserved: contributing observation ids and modalities are
|
||||||
|
// recoverable from the fused state.
|
||||||
|
#[test]
|
||||||
|
fn provenance_is_preserved() {
|
||||||
|
let engine = FusionEngine::default();
|
||||||
|
let world = engine.fuse(&[
|
||||||
|
PresenceObservation::estimated(
|
||||||
|
hal("wifi-obs", "csi-1", Modality::Csi, KITCHEN, EvidenceLevel::L2),
|
||||||
|
0.80,
|
||||||
|
0.05,
|
||||||
|
),
|
||||||
|
PresenceObservation::estimated(
|
||||||
|
hal("mm-obs", "mm-1", Modality::Mmwave, KITCHEN, EvidenceLevel::L3),
|
||||||
|
0.82,
|
||||||
|
0.05,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let zone = world.zone(&container(KITCHEN)).unwrap();
|
||||||
|
let ids: Vec<&str> = zone.contributions.iter().map(|c| c.observation.as_str()).collect();
|
||||||
|
assert!(ids.contains(&"wifi-obs"));
|
||||||
|
assert!(ids.contains(&"mm-obs"));
|
||||||
|
let modalities: Vec<&Modality> = zone.contributions.iter().map(|c| &c.modality).collect();
|
||||||
|
assert!(modalities.contains(&&Modality::Csi));
|
||||||
|
assert!(modalities.contains(&&Modality::Mmwave));
|
||||||
|
// Aggregate evidence is the minimum (weakest) contributing level.
|
||||||
|
assert_eq!(zone.evidence_level, EvidenceLevel::L2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A degraded / abstaining observation is not counted as coverage: a zone
|
||||||
|
// with no usable estimate resolves to UNKNOWN (insufficient coverage), and
|
||||||
|
// the abstaining observation is still recorded (weight 0, no estimate).
|
||||||
|
#[test]
|
||||||
|
fn insufficient_coverage_yields_unknown() {
|
||||||
|
let engine = FusionEngine::default();
|
||||||
|
let world = engine.fuse(&[PresenceObservation::estimated(
|
||||||
|
degraded_hal("bad-obs", "csi-1", KITCHEN),
|
||||||
|
0.9,
|
||||||
|
0.01,
|
||||||
|
)]);
|
||||||
|
|
||||||
|
let zone = world.zone(&container(KITCHEN)).unwrap();
|
||||||
|
assert!(zone.is_unknown());
|
||||||
|
assert!(matches!(
|
||||||
|
zone.presence,
|
||||||
|
Presence::Unknown {
|
||||||
|
reason: UnknownReason::InsufficientCoverage
|
||||||
|
}
|
||||||
|
));
|
||||||
|
// Provenance still records the abstaining observation.
|
||||||
|
assert_eq!(zone.contributions.len(), 1);
|
||||||
|
assert!(!zone.contributions[0].contributed());
|
||||||
|
assert!(approx(zone.contributions[0].weight, 0.0));
|
||||||
|
assert_eq!(zone.contributing_observations().count(), 0);
|
||||||
|
assert_eq!(zone.evidence_level, EvidenceLevel::L0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// An explicitly abstaining source (Claim::Unknown) is uncertainty-first-class
|
||||||
|
// and does not error.
|
||||||
|
#[test]
|
||||||
|
fn explicit_unknown_claim_abstains() {
|
||||||
|
let engine = FusionEngine::default();
|
||||||
|
let world = engine.fuse(&[
|
||||||
|
PresenceObservation::unknown(hal("abstain", "ble-1", Modality::Ble, KITCHEN, EvidenceLevel::L1)),
|
||||||
|
PresenceObservation::estimated(
|
||||||
|
hal("real", "csi-1", Modality::Csi, KITCHEN, EvidenceLevel::L2),
|
||||||
|
0.75,
|
||||||
|
0.05,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let zone = world.zone(&container(KITCHEN)).unwrap();
|
||||||
|
// The one real source resolves the zone; the abstainer only adds provenance.
|
||||||
|
assert!(!zone.is_unknown());
|
||||||
|
assert!(approx(est_probability(&zone.presence), 0.75));
|
||||||
|
assert_eq!(zone.contributions.len(), 2);
|
||||||
|
assert_eq!(zone.contributing_observations().count(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Distinct containers fuse independently into one world state.
|
||||||
|
#[test]
|
||||||
|
fn distinct_zones_fuse_independently() {
|
||||||
|
let engine = FusionEngine::default();
|
||||||
|
let world = engine.fuse(&[
|
||||||
|
PresenceObservation::estimated(
|
||||||
|
hal("k1", "csi-1", Modality::Csi, "kitchen", EvidenceLevel::L2),
|
||||||
|
0.9,
|
||||||
|
0.04,
|
||||||
|
),
|
||||||
|
PresenceObservation::estimated(
|
||||||
|
hal("b1", "csi-2", Modality::Csi, "bedroom", EvidenceLevel::L2),
|
||||||
|
0.1,
|
||||||
|
0.04,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert_eq!(world.zones.len(), 2);
|
||||||
|
assert!(approx(est_probability(&world.zone(&container("kitchen")).unwrap().presence), 0.9));
|
||||||
|
assert!(approx(est_probability(&world.zone(&container("bedroom")).unwrap().presence), 0.1));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determinism: identical inputs (in any order) fuse to the identical world
|
||||||
|
// state.
|
||||||
|
#[test]
|
||||||
|
fn fusion_is_deterministic_and_order_independent() {
|
||||||
|
let engine = FusionEngine::default();
|
||||||
|
let a = PresenceObservation::estimated(
|
||||||
|
hal("o1", "csi-1", Modality::Csi, KITCHEN, EvidenceLevel::L2),
|
||||||
|
0.90,
|
||||||
|
0.03,
|
||||||
|
);
|
||||||
|
let b = PresenceObservation::estimated(
|
||||||
|
hal("o2", "mm-1", Modality::Mmwave, KITCHEN, EvidenceLevel::L3),
|
||||||
|
0.86,
|
||||||
|
0.07,
|
||||||
|
);
|
||||||
|
|
||||||
|
let world1 = engine.fuse(&[a.clone(), b.clone()]);
|
||||||
|
let world2 = engine.fuse(&[a.clone(), b.clone()]);
|
||||||
|
assert_eq!(world1, world2);
|
||||||
|
|
||||||
|
// Reordering the inputs does not change the fused state.
|
||||||
|
let world3 = engine.fuse(&[b, a]);
|
||||||
|
assert_eq!(world1, world3);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The world state serde round-trips losslessly (one canonical semantics).
|
||||||
|
#[test]
|
||||||
|
fn world_state_serde_round_trip() {
|
||||||
|
let engine = FusionEngine::default();
|
||||||
|
let world = engine.fuse(&[
|
||||||
|
PresenceObservation::estimated(
|
||||||
|
hal("o1", "csi-1", Modality::Csi, KITCHEN, EvidenceLevel::L2),
|
||||||
|
0.9,
|
||||||
|
0.04,
|
||||||
|
),
|
||||||
|
PresenceObservation::estimated(
|
||||||
|
degraded_hal("o2", "csi-2", KITCHEN),
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
let json = serde_json::to_string(&world).unwrap();
|
||||||
|
let back: WorldState = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(world, back);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A configured higher minimum coverage forces UNKNOWN when too few sources
|
||||||
|
// cover a zone, even if the single source is confident.
|
||||||
|
#[test]
|
||||||
|
fn min_observations_gate() {
|
||||||
|
let engine = FusionEngine::new(FusionConfig {
|
||||||
|
conflict_reduced_chi_square: 9.0,
|
||||||
|
min_observations: 2,
|
||||||
|
});
|
||||||
|
let world = engine.fuse(&[PresenceObservation::estimated(
|
||||||
|
hal("o1", "csi-1", Modality::Csi, KITCHEN, EvidenceLevel::L2),
|
||||||
|
0.95,
|
||||||
|
0.01,
|
||||||
|
)]);
|
||||||
|
let zone = world.zone(&container(KITCHEN)).unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
zone.presence,
|
||||||
|
Presence::Unknown {
|
||||||
|
reason: UnknownReason::InsufficientCoverage
|
||||||
|
}
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_input_is_empty_world_not_panic() {
|
||||||
|
let engine = FusionEngine::default();
|
||||||
|
let world = engine.fuse(&[]);
|
||||||
|
assert_eq!(world.zones.len(), 0);
|
||||||
|
assert_eq!(world.at_unix_ms, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
116
v2/crates/ruview-fusion/src/observation.rs
Normal file
116
v2/crates/ruview-fusion/src/observation.rs
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
//! The fusion input: a canonical HAL observation plus its presence claim
|
||||||
|
//! (ADR-311 §1).
|
||||||
|
//!
|
||||||
|
//! Fusion consumes authenticated, ontology-typed observations. A
|
||||||
|
//! [`HalObservation`] carries the modality, evidence level, sensor identity,
|
||||||
|
//! container, and provenance (the canonical ADR-306 vocabulary, reused rather
|
||||||
|
//! than reinvented — ADR-300 rule 3); a [`PresenceObservation`] pairs it with
|
||||||
|
//! that sensor's [`Claim`] about whether its container is occupied. Keeping the
|
||||||
|
//! claim separate from the HAL frame lets the engine gate on the observation's
|
||||||
|
//! own health: a malformed / degraded HAL observation abstains no matter what
|
||||||
|
//! number it reports, and a source that cannot quantify presence says
|
||||||
|
//! [`Claim::Unknown`] rather than defaulting to a confident value (ADR-300
|
||||||
|
//! rule 1).
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use ruview_hal::HalObservation;
|
||||||
|
|
||||||
|
use crate::estimate::Estimate;
|
||||||
|
|
||||||
|
/// A single sensor's occupancy claim for the container its observation is in.
|
||||||
|
///
|
||||||
|
/// UNKNOWN is a first-class value here, never an error: a source may quantify
|
||||||
|
/// its belief ([`Claim::Estimated`]) or explicitly abstain ([`Claim::Unknown`]).
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "claim", rename_all = "snake_case")]
|
||||||
|
pub enum Claim {
|
||||||
|
/// A quantified presence claim: probability of occupancy and its variance.
|
||||||
|
Estimated {
|
||||||
|
/// Probability of occupancy in `[0.0, 1.0]`.
|
||||||
|
probability: f64,
|
||||||
|
/// Variance of the estimate; strictly positive.
|
||||||
|
variance: f64,
|
||||||
|
},
|
||||||
|
/// The source abstains — it contributes provenance but no numeric estimate.
|
||||||
|
Unknown,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Claim {
|
||||||
|
/// Construct a quantified claim, validating the numbers at the boundary. A
|
||||||
|
/// non-finite value or a non-positive variance cannot form a weightable
|
||||||
|
/// estimate, so the claim degrades to [`Claim::Unknown`] rather than
|
||||||
|
/// erroring or poisoning the fusion; the probability is clamped to
|
||||||
|
/// `[0.0, 1.0]`.
|
||||||
|
#[must_use]
|
||||||
|
pub fn estimated(probability: f64, variance: f64) -> Self {
|
||||||
|
match Estimate::new(probability, variance) {
|
||||||
|
Some(e) => Self::Estimated {
|
||||||
|
probability: e.probability,
|
||||||
|
variance: e.variance,
|
||||||
|
},
|
||||||
|
None => Self::Unknown,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The weightable [`Estimate`] this claim carries, or `None` when it
|
||||||
|
/// abstains or its numbers are not weightable.
|
||||||
|
#[must_use]
|
||||||
|
pub fn estimate(&self) -> Option<Estimate> {
|
||||||
|
match *self {
|
||||||
|
Self::Estimated {
|
||||||
|
probability,
|
||||||
|
variance,
|
||||||
|
} => Estimate::new(probability, variance),
|
||||||
|
Self::Unknown => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One fusion input: a canonical HAL observation and its presence claim.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct PresenceObservation {
|
||||||
|
/// The canonical HAL observation (modality, evidence, sensor, container,
|
||||||
|
/// provenance, and per-observation uncertainty).
|
||||||
|
pub hal: HalObservation,
|
||||||
|
/// This sensor's occupancy claim for its container.
|
||||||
|
pub claim: Claim,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PresenceObservation {
|
||||||
|
/// Build a fusion input with a quantified claim (validated; see
|
||||||
|
/// [`Claim::estimated`]).
|
||||||
|
#[must_use]
|
||||||
|
pub fn estimated(hal: HalObservation, probability: f64, variance: f64) -> Self {
|
||||||
|
Self {
|
||||||
|
claim: Claim::estimated(probability, variance),
|
||||||
|
hal,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a fusion input whose source abstains ([`Claim::Unknown`]).
|
||||||
|
#[must_use]
|
||||||
|
pub fn unknown(hal: HalObservation) -> Self {
|
||||||
|
Self {
|
||||||
|
hal,
|
||||||
|
claim: Claim::Unknown,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The usable presence estimate this observation contributes, or `None` when
|
||||||
|
/// it abstains.
|
||||||
|
///
|
||||||
|
/// An observation abstains when its HAL frame is `degraded` (malformed /
|
||||||
|
/// out-of-bounds raw input — the HAL already flagged it UNKNOWN) or when its
|
||||||
|
/// claim is not a weightable estimate. Abstaining observations still carry
|
||||||
|
/// their provenance into the fused state; they simply do not move the fused
|
||||||
|
/// value. A merely *high-variance* claim is **not** abstaining — it
|
||||||
|
/// contributes, but is down-weighted by inverse-variance.
|
||||||
|
#[must_use]
|
||||||
|
pub fn usable_estimate(&self) -> Option<Estimate> {
|
||||||
|
if self.hal.uncertainty.degraded {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
self.claim.estimate()
|
||||||
|
}
|
||||||
|
}
|
||||||
147
v2/crates/ruview-fusion/src/world.rs
Normal file
147
v2/crates/ruview-fusion/src/world.rs
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
//! The fused output: one probabilistic world state (ADR-311 §3).
|
||||||
|
//!
|
||||||
|
//! The invariant of ADR-311 is the *shape* of the output: many observations
|
||||||
|
//! resolve to **one** [`WorldState`], not many feeds into a visualization. A
|
||||||
|
//! [`WorldState`] holds a per-container [`ZoneState`], each carrying either a
|
||||||
|
//! fused [`Presence::Estimated`] belief or a first-class [`Presence::Unknown`]
|
||||||
|
//! when the evidence cannot support a confident single value. Every fused value
|
||||||
|
//! keeps recoverable per-observation provenance ([`Contribution`]s) and an
|
||||||
|
//! aggregate evidence level that is never lifted above the weakest contributing
|
||||||
|
//! input (ADR-311: "never upgraded above the weakest contributing L-level").
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use ruview_hal::Modality;
|
||||||
|
use ruview_ontology::{Container, EvidenceLevel, ObservationId, SensorId};
|
||||||
|
|
||||||
|
use crate::estimate::Estimate;
|
||||||
|
|
||||||
|
/// Why a zone resolved to UNKNOWN instead of a confident estimate. UNKNOWN is a
|
||||||
|
/// value, not an error (ADR-300 rule 1): the reason stays legible.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "reason", rename_all = "snake_case")]
|
||||||
|
pub enum UnknownReason {
|
||||||
|
/// Fewer usable (non-degraded, quantified) observations covered the zone
|
||||||
|
/// than the engine's minimum, so there is not enough evidence to resolve it.
|
||||||
|
InsufficientCoverage,
|
||||||
|
/// Contributing observations disagree by more than their stated uncertainty
|
||||||
|
/// allows. The engine refuses to emit a confident average of irreconcilable
|
||||||
|
/// sources and reports the disagreement instead.
|
||||||
|
IrreconcilableConflict {
|
||||||
|
/// The reduced chi-square disagreement statistic that crossed the
|
||||||
|
/// configured threshold.
|
||||||
|
reduced_chi_square: f64,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The fused occupancy belief for one container.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "presence", rename_all = "snake_case")]
|
||||||
|
pub enum Presence {
|
||||||
|
/// A fused probabilistic belief: probability of occupancy and its variance.
|
||||||
|
Estimated {
|
||||||
|
/// Fused probability of occupancy in `[0.0, 1.0]`.
|
||||||
|
probability: f64,
|
||||||
|
/// Fused variance — sharpened (smaller) when sources agree.
|
||||||
|
variance: f64,
|
||||||
|
},
|
||||||
|
/// UNKNOWN — the evidence could not resolve to one confident estimate.
|
||||||
|
Unknown {
|
||||||
|
/// Why the zone is UNKNOWN.
|
||||||
|
reason: UnknownReason,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Presence {
|
||||||
|
/// True when this is [`Presence::Unknown`].
|
||||||
|
#[must_use]
|
||||||
|
pub fn is_unknown(&self) -> bool {
|
||||||
|
matches!(self, Self::Unknown { .. })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One contributing observation's recoverable provenance in a fused zone.
|
||||||
|
///
|
||||||
|
/// Every observation grouped into a zone yields a `Contribution`, whether or not
|
||||||
|
/// it moved the fused value. `estimate` is `Some` for a contributing source and
|
||||||
|
/// `None` for an abstaining one (degraded / UNKNOWN); `weight` is its normalized
|
||||||
|
/// inverse-variance weight in `[0.0, 1.0]` (`0.0` when it did not contribute),
|
||||||
|
/// which makes down-weighting of uncertain sources auditable.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct Contribution {
|
||||||
|
/// The contributing observation's id.
|
||||||
|
pub observation: ObservationId,
|
||||||
|
/// The authenticated sensor that produced it.
|
||||||
|
pub sensor: SensorId,
|
||||||
|
/// The modality it was sensed through.
|
||||||
|
pub modality: Modality,
|
||||||
|
/// The observation's own evidence level.
|
||||||
|
pub evidence_level: EvidenceLevel,
|
||||||
|
/// The presence estimate it contributed, or `None` if it abstained.
|
||||||
|
pub estimate: Option<Estimate>,
|
||||||
|
/// Its normalized weight in the fused value, in `[0.0, 1.0]`.
|
||||||
|
pub weight: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Contribution {
|
||||||
|
/// True when this observation contributed a weighted estimate (did not
|
||||||
|
/// abstain).
|
||||||
|
#[must_use]
|
||||||
|
pub fn contributed(&self) -> bool {
|
||||||
|
self.estimate.is_some()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The fused belief and provenance for a single container.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct ZoneState {
|
||||||
|
/// The container (space or zone) this state describes.
|
||||||
|
pub container: Container,
|
||||||
|
/// The fused occupancy belief, or UNKNOWN.
|
||||||
|
pub presence: Presence,
|
||||||
|
/// Aggregate evidence level — the minimum over contributing observations,
|
||||||
|
/// never above the weakest necessary input; `L0` when nothing contributed.
|
||||||
|
pub evidence_level: EvidenceLevel,
|
||||||
|
/// Per-observation provenance for every observation grouped into this zone,
|
||||||
|
/// in a deterministic (observation-id) order.
|
||||||
|
pub contributions: Vec<Contribution>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ZoneState {
|
||||||
|
/// True when this zone resolved to UNKNOWN.
|
||||||
|
#[must_use]
|
||||||
|
pub fn is_unknown(&self) -> bool {
|
||||||
|
self.presence.is_unknown()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The ids of the observations that contributed a weighted estimate.
|
||||||
|
pub fn contributing_observations(&self) -> impl Iterator<Item = &ObservationId> {
|
||||||
|
self.contributions
|
||||||
|
.iter()
|
||||||
|
.filter(|c| c.contributed())
|
||||||
|
.map(|c| &c.observation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One probabilistic world state fused from many observations.
|
||||||
|
///
|
||||||
|
/// This is the single object every downstream consumer reads (ADR-312/310/312):
|
||||||
|
/// one probabilistic world, not a modality stack. Zones are held in a
|
||||||
|
/// deterministic order so the state is reproducible.
|
||||||
|
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct WorldState {
|
||||||
|
/// The "as-of" time of the state (Unix ms) — the maximum contributing
|
||||||
|
/// observation timestamp, injected via the observations, never sampled from
|
||||||
|
/// a clock. `0` when there were no observations.
|
||||||
|
pub at_unix_ms: i64,
|
||||||
|
/// The fused per-container states, ordered deterministically by container.
|
||||||
|
pub zones: Vec<ZoneState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WorldState {
|
||||||
|
/// The fused state for a container, if present.
|
||||||
|
#[must_use]
|
||||||
|
pub fn zone(&self, container: &Container) -> Option<&ZoneState> {
|
||||||
|
self.zones.iter().find(|z| &z.container == container)
|
||||||
|
}
|
||||||
|
}
|
||||||
16
v2/crates/ruview-groundtruth/Cargo.toml
Normal file
16
v2/crates/ruview-groundtruth/Cargo.toml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
[package]
|
||||||
|
name = "ruview-groundtruth"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
authors.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
thiserror.workspace = true
|
||||||
|
serde = { workspace = true, features = ["derive"] }
|
||||||
|
ruview-ontology = { path = "../ruview-ontology" }
|
||||||
|
ruview-evidence = { path = "../ruview-evidence" }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
serde_json.workspace = true
|
||||||
332
v2/crates/ruview-groundtruth/src/agreement.rs
Normal file
332
v2/crates/ruview-groundtruth/src/agreement.rs
Normal file
@@ -0,0 +1,332 @@
|
|||||||
|
//! Agreement as validation, not fusion (ADR-303 §3–§4).
|
||||||
|
//!
|
||||||
|
//! An [`AgreementReport`] compares an RF [`EstimateSeries`] against an
|
||||||
|
//! independent [`ReferenceSeries`] after time alignment, computes
|
||||||
|
//! modality-appropriate agreement metrics (MAE/RMSE/bias/within-tolerance for
|
||||||
|
//! continuous measurands; label-agreement for categorical ones), grades the
|
||||||
|
//! result on the ADR-293/301 evidence ladder, and feeds a per-context record
|
||||||
|
//! into the [`ruview_evidence`] ledger. Reference sensors are strictly a
|
||||||
|
//! validation plane here — this crate never returns a reference reading to an
|
||||||
|
//! estimator.
|
||||||
|
|
||||||
|
use ruview_evidence::{AccuracyMetrics, EvidenceContext, EvidenceRecord};
|
||||||
|
use ruview_ontology::EvidenceLevel as OntEvidenceLevel;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::align::{estimate_alignment, paired_at, Alignment, AlignmentConfig};
|
||||||
|
use crate::error::{check_bound, GroundTruthError};
|
||||||
|
use crate::model::{DataProvenance, Measurand, Reading};
|
||||||
|
use crate::scope::SessionScope;
|
||||||
|
use crate::series::{EstimateSeries, ReferenceSeries};
|
||||||
|
use crate::source::ReferenceSource;
|
||||||
|
|
||||||
|
/// Map the ontology's canonical evidence ladder onto the evidence ledger's
|
||||||
|
/// (structurally identical) ladder, so the report speaks the ADR-306 vocabulary
|
||||||
|
/// while still writing an ADR-304 record.
|
||||||
|
fn to_ledger_level(level: OntEvidenceLevel) -> ruview_evidence::EvidenceLevel {
|
||||||
|
match level {
|
||||||
|
OntEvidenceLevel::L0 => ruview_evidence::EvidenceLevel::L0,
|
||||||
|
OntEvidenceLevel::L1 => ruview_evidence::EvidenceLevel::L1,
|
||||||
|
OntEvidenceLevel::L2 => ruview_evidence::EvidenceLevel::L2,
|
||||||
|
OntEvidenceLevel::L3 => ruview_evidence::EvidenceLevel::L3,
|
||||||
|
OntEvidenceLevel::L4 => ruview_evidence::EvidenceLevel::L4,
|
||||||
|
OntEvidenceLevel::L5 => ruview_evidence::EvidenceLevel::L5,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The honesty grade of an agreement report (mirrors ADR-293/301). Fixed by the
|
||||||
|
/// data provenance, the reference, coverage, paired samples, and a reproducer —
|
||||||
|
/// never aliasable upward.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum EvidenceGrade {
|
||||||
|
/// Generated input — L0 by construction.
|
||||||
|
Synthetic,
|
||||||
|
/// Real data, but not backed by a reference + coverage + reproducer.
|
||||||
|
Claimed,
|
||||||
|
/// Backed by an independent reference, sufficient coverage, paired samples,
|
||||||
|
/// and a reproducer handle.
|
||||||
|
Measured,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Modality-appropriate agreement metrics.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case", tag = "family")]
|
||||||
|
pub enum AgreementMetrics {
|
||||||
|
/// Continuous measurand agreement (ADR-293 statistics).
|
||||||
|
Continuous {
|
||||||
|
/// Mean absolute error.
|
||||||
|
mae: f64,
|
||||||
|
/// Root-mean-square error.
|
||||||
|
rmse: f64,
|
||||||
|
/// Mean error (estimate − reference), i.e. bias.
|
||||||
|
bias: f64,
|
||||||
|
/// Fraction of pairs within the configured tolerance, `[0, 1]`.
|
||||||
|
within_tolerance: f64,
|
||||||
|
},
|
||||||
|
/// Categorical / detection agreement.
|
||||||
|
Categorical {
|
||||||
|
/// Fraction of pairs whose labels matched, `[0, 1]`.
|
||||||
|
agreement: f64,
|
||||||
|
/// Number of matching pairs.
|
||||||
|
n_agree: usize,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The policy that decides an agreement report's grade and stamped level.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct GradingPolicy {
|
||||||
|
/// Minimum coverage fraction required for a MEASURED grade, `[0, 1]`.
|
||||||
|
pub min_coverage: f64,
|
||||||
|
/// The evidence level stamped on a `Claimed`/`Measured` record. Must not be
|
||||||
|
/// `L0` (which is reserved for synthetic input).
|
||||||
|
pub level: OntEvidenceLevel,
|
||||||
|
/// The reproducer command handle. Required (non-empty) for a MEASURED
|
||||||
|
/// grade; ignored otherwise.
|
||||||
|
pub reproducer: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GradingPolicy {
|
||||||
|
/// Construct and validate a grading policy.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// [`GroundTruthError::InvalidCoverage`] if `min_coverage` is outside
|
||||||
|
/// `[0, 1]`, [`GroundTruthError::GradeLevelConflict`] if `level` is `L0`,
|
||||||
|
/// or [`GroundTruthError::TooLong`] for an over-length reproducer.
|
||||||
|
pub fn new(
|
||||||
|
min_coverage: f64,
|
||||||
|
level: OntEvidenceLevel,
|
||||||
|
reproducer: Option<String>,
|
||||||
|
) -> Result<Self, GroundTruthError> {
|
||||||
|
if !min_coverage.is_finite() || !(0.0..=1.0).contains(&min_coverage) {
|
||||||
|
return Err(GroundTruthError::InvalidCoverage {
|
||||||
|
value: min_coverage,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if level == OntEvidenceLevel::L0 {
|
||||||
|
return Err(GroundTruthError::GradeLevelConflict {
|
||||||
|
reason: "L0 is reserved for synthetic input; use L1+ for a graded record",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if let Some(r) = &reproducer {
|
||||||
|
check_bound("reproducer", r)?;
|
||||||
|
}
|
||||||
|
Ok(Self {
|
||||||
|
min_coverage,
|
||||||
|
level,
|
||||||
|
reproducer,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A validation-plane agreement report: how RF inference compared against an
|
||||||
|
/// independent reference, under a mandatory session scope, graded on the
|
||||||
|
/// evidence ladder and ready to feed the evidence ledger.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct AgreementReport {
|
||||||
|
/// The independent reference source.
|
||||||
|
pub source: ReferenceSource,
|
||||||
|
/// The measurand compared.
|
||||||
|
pub measurand: Measurand,
|
||||||
|
/// The estimating model version.
|
||||||
|
pub model_version: String,
|
||||||
|
/// Mandatory session scope — a report cannot exist without it.
|
||||||
|
pub scope: SessionScope,
|
||||||
|
/// The recovered time alignment.
|
||||||
|
pub alignment: Alignment,
|
||||||
|
/// Number of aligned pairs the metrics summarize.
|
||||||
|
pub n_pairs: usize,
|
||||||
|
/// Coverage: paired points over total overlap grid points, `[0, 1]`.
|
||||||
|
pub coverage: f64,
|
||||||
|
/// The agreement metrics.
|
||||||
|
pub metrics: AgreementMetrics,
|
||||||
|
/// The honesty grade.
|
||||||
|
pub grade: EvidenceGrade,
|
||||||
|
/// The evidence level (canonical ADR-306 ladder) stamped on emission.
|
||||||
|
pub evidence_level: OntEvidenceLevel,
|
||||||
|
/// The reproducer handle, when the report is MEASURED.
|
||||||
|
pub reproducer: Option<String>,
|
||||||
|
/// Whether the estimate data was real or synthetic.
|
||||||
|
pub data_provenance: DataProvenance,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AgreementReport {
|
||||||
|
/// Build an agreement report. `scope` is a required argument, so a report
|
||||||
|
/// can never be constructed without it (ADR-303 §3).
|
||||||
|
///
|
||||||
|
/// The estimate and reference must describe the same measurand. `tolerance`
|
||||||
|
/// is the within-tolerance band for continuous measurands (ignored for
|
||||||
|
/// categorical). Insufficient overlap is **not** an error: it yields a
|
||||||
|
/// report with zero pairs and a non-MEASURED grade — a first-class UNKNOWN.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// [`GroundTruthError::MeasurandMismatch`],
|
||||||
|
/// [`GroundTruthError::InvalidTolerance`], a configuration error from
|
||||||
|
/// alignment, or [`GroundTruthError::GradeLevelConflict`] if the policy
|
||||||
|
/// level is inconsistent with a non-synthetic grade.
|
||||||
|
pub fn build(
|
||||||
|
estimate: &EstimateSeries,
|
||||||
|
reference: &ReferenceSeries,
|
||||||
|
scope: SessionScope,
|
||||||
|
align_cfg: &AlignmentConfig,
|
||||||
|
tolerance: f64,
|
||||||
|
policy: &GradingPolicy,
|
||||||
|
) -> Result<Self, GroundTruthError> {
|
||||||
|
if estimate.measurand != reference.measurand {
|
||||||
|
return Err(GroundTruthError::MeasurandMismatch {
|
||||||
|
estimate: estimate.measurand.label(),
|
||||||
|
reference: reference.measurand.label(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if !tolerance.is_finite() || tolerance < 0.0 {
|
||||||
|
return Err(GroundTruthError::InvalidTolerance { value: tolerance });
|
||||||
|
}
|
||||||
|
|
||||||
|
let alignment = estimate_alignment(estimate, reference, align_cfg)?;
|
||||||
|
let (total, pairs) = paired_at(estimate, reference, alignment.offset_ms, align_cfg);
|
||||||
|
let n_pairs = pairs.len();
|
||||||
|
let coverage = if total == 0 {
|
||||||
|
0.0
|
||||||
|
} else {
|
||||||
|
n_pairs as f64 / total as f64
|
||||||
|
};
|
||||||
|
let metrics = compute_metrics(estimate.measurand, &pairs, tolerance);
|
||||||
|
|
||||||
|
// Grade: synthetic input is always Synthetic; otherwise MEASURED only
|
||||||
|
// with an independent reference, coverage, paired samples, and a
|
||||||
|
// reproducer — else Claimed.
|
||||||
|
let grade = match estimate.provenance {
|
||||||
|
DataProvenance::Synthetic => EvidenceGrade::Synthetic,
|
||||||
|
DataProvenance::Real => {
|
||||||
|
let reproducer_ok = policy
|
||||||
|
.reproducer
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(|r| !r.is_empty());
|
||||||
|
if reference.source.modality.is_independent_reference()
|
||||||
|
&& n_pairs > 0
|
||||||
|
&& coverage >= policy.min_coverage
|
||||||
|
&& reproducer_ok
|
||||||
|
{
|
||||||
|
EvidenceGrade::Measured
|
||||||
|
} else {
|
||||||
|
EvidenceGrade::Claimed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let (evidence_level, reproducer) = match grade {
|
||||||
|
EvidenceGrade::Synthetic => (OntEvidenceLevel::L0, None),
|
||||||
|
EvidenceGrade::Claimed => (policy.level, None),
|
||||||
|
EvidenceGrade::Measured => (policy.level, policy.reproducer.clone()),
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
source: reference.source.clone(),
|
||||||
|
measurand: estimate.measurand,
|
||||||
|
model_version: estimate.model_version.clone(),
|
||||||
|
scope,
|
||||||
|
alignment,
|
||||||
|
n_pairs,
|
||||||
|
coverage,
|
||||||
|
metrics,
|
||||||
|
grade,
|
||||||
|
evidence_level,
|
||||||
|
reproducer,
|
||||||
|
data_provenance: estimate.provenance,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Emit this report as an evidence-ledger record, keyed by `context`, with
|
||||||
|
/// caller-supplied per-context [`AccuracyMetrics`]. The record's provenance
|
||||||
|
/// class and level follow the report's grade: `Synthetic → L0 synthetic`,
|
||||||
|
/// `Claimed → claimed`, `Measured → measured` (with the reproducer). The
|
||||||
|
/// evidence crate enforces the honesty invariants; failures surface as
|
||||||
|
/// [`GroundTruthError::Evidence`].
|
||||||
|
///
|
||||||
|
/// The agreement statistics (MAE/RMSE/coverage/label-agreement) live on the
|
||||||
|
/// report for the benchmark (ADR-317); the ledger record carries the
|
||||||
|
/// per-context accuracy metrics with the correct, non-upgradable grade.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// [`GroundTruthError::GradeLevelConflict`] if a MEASURED report lacks its
|
||||||
|
/// reproducer, or [`GroundTruthError::Evidence`] from the ledger boundary.
|
||||||
|
pub fn to_evidence_record(
|
||||||
|
&self,
|
||||||
|
context: EvidenceContext,
|
||||||
|
metrics: AccuracyMetrics,
|
||||||
|
timestamp_ns: u64,
|
||||||
|
) -> Result<EvidenceRecord, GroundTruthError> {
|
||||||
|
let level = to_ledger_level(self.evidence_level);
|
||||||
|
let record = match self.grade {
|
||||||
|
EvidenceGrade::Synthetic => {
|
||||||
|
EvidenceRecord::synthetic(context, metrics, timestamp_ns)?
|
||||||
|
}
|
||||||
|
EvidenceGrade::Claimed => {
|
||||||
|
EvidenceRecord::claimed(context, metrics, level, timestamp_ns)?
|
||||||
|
}
|
||||||
|
EvidenceGrade::Measured => {
|
||||||
|
let reproducer = self.reproducer.as_deref().ok_or(
|
||||||
|
GroundTruthError::GradeLevelConflict {
|
||||||
|
reason: "measured report is missing its reproducer handle",
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
EvidenceRecord::measured(context, metrics, level, reproducer, timestamp_ns)?
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Ok(record)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute agreement metrics for the measurand's family from aligned pairs.
|
||||||
|
fn compute_metrics(
|
||||||
|
measurand: Measurand,
|
||||||
|
pairs: &[(Reading, Reading)],
|
||||||
|
tolerance: f64,
|
||||||
|
) -> AgreementMetrics {
|
||||||
|
if measurand.is_continuous() {
|
||||||
|
let n = pairs.len();
|
||||||
|
if n == 0 {
|
||||||
|
return AgreementMetrics::Continuous {
|
||||||
|
mae: 0.0,
|
||||||
|
rmse: 0.0,
|
||||||
|
bias: 0.0,
|
||||||
|
within_tolerance: 0.0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let mut sum_abs = 0.0;
|
||||||
|
let mut sum_sq = 0.0;
|
||||||
|
let mut sum_err = 0.0;
|
||||||
|
let mut within = 0usize;
|
||||||
|
for (e, r) in pairs {
|
||||||
|
// Both are scalars for a continuous measurand (validated at ingest).
|
||||||
|
let ev = e.as_scalar().unwrap_or(0.0);
|
||||||
|
let rv = r.as_scalar().unwrap_or(0.0);
|
||||||
|
let err = ev - rv;
|
||||||
|
sum_abs += err.abs();
|
||||||
|
sum_sq += err * err;
|
||||||
|
sum_err += err;
|
||||||
|
if err.abs() <= tolerance {
|
||||||
|
within += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let nf = n as f64;
|
||||||
|
AgreementMetrics::Continuous {
|
||||||
|
mae: sum_abs / nf,
|
||||||
|
rmse: (sum_sq / nf).sqrt(),
|
||||||
|
bias: sum_err / nf,
|
||||||
|
within_tolerance: within as f64 / nf,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let n = pairs.len();
|
||||||
|
let n_agree = pairs
|
||||||
|
.iter()
|
||||||
|
.filter(|(e, r)| e.as_label() == r.as_label())
|
||||||
|
.count();
|
||||||
|
let agreement = if n == 0 {
|
||||||
|
0.0
|
||||||
|
} else {
|
||||||
|
n_agree as f64 / n as f64
|
||||||
|
};
|
||||||
|
AgreementMetrics::Categorical { agreement, n_agree }
|
||||||
|
}
|
||||||
|
}
|
||||||
323
v2/crates/ruview-groundtruth/src/align.rs
Normal file
323
v2/crates/ruview-groundtruth/src/align.rs
Normal file
@@ -0,0 +1,323 @@
|
|||||||
|
//! Deterministic time alignment (ADR-303 §2, generalizing ADR-293).
|
||||||
|
//!
|
||||||
|
//! Estimate and reference series rarely share a clock. This module recovers a
|
||||||
|
//! **constant offset** by resampling both series onto a common grid
|
||||||
|
//! (nearest-sample, never bridging gaps larger than a configured limit) and
|
||||||
|
//! searching a bounded lag window for the offset that best aligns them:
|
||||||
|
//! normalized cross-correlation for continuous measurands, label-agreement
|
||||||
|
//! fraction for categorical ones. Every step is deterministic — no wall clock,
|
||||||
|
//! no randomness — and the chosen offset is *reported*, never silently applied.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::error::GroundTruthError;
|
||||||
|
use crate::model::Reading;
|
||||||
|
use crate::series::{EstimateSeries, ReferenceObservation, ReferenceSeries};
|
||||||
|
|
||||||
|
/// The largest common grid, in points, bounding allocation.
|
||||||
|
pub const MAX_GRID_POINTS: usize = 2_000_000;
|
||||||
|
/// The largest lag search window, in candidate steps, bounding work.
|
||||||
|
pub const MAX_LAG_STEPS: usize = 200_000;
|
||||||
|
|
||||||
|
/// Floating-point tie margin for selecting the best-scoring offset.
|
||||||
|
const SCORE_EPS: f64 = 1e-9;
|
||||||
|
|
||||||
|
/// Configuration for the alignment search. All fields are in milliseconds.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct AlignmentConfig {
|
||||||
|
/// Common resampling grid step (must be positive).
|
||||||
|
pub grid_ms: i64,
|
||||||
|
/// Half-width of the lag search window; offsets in `[-max_lag, +max_lag]`
|
||||||
|
/// are considered (must be non-negative).
|
||||||
|
pub max_lag_ms: i64,
|
||||||
|
/// Largest gap bridged when resampling: a grid point with no sample within
|
||||||
|
/// this distance is left empty rather than interpolated (must be
|
||||||
|
/// non-negative).
|
||||||
|
pub max_gap_ms: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for AlignmentConfig {
|
||||||
|
/// ADR-293 defaults: 1 s grid, ±30 s lag window, 2 s max gap.
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
grid_ms: 1_000,
|
||||||
|
max_lag_ms: 30_000,
|
||||||
|
max_gap_ms: 2_000,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AlignmentConfig {
|
||||||
|
/// Validate the configuration and the bounded work it implies for the given
|
||||||
|
/// series time spans.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// [`GroundTruthError::InvalidConfig`] for non-positive/negative fields,
|
||||||
|
/// [`GroundTruthError::GridTooLarge`], or
|
||||||
|
/// [`GroundTruthError::LagWindowTooLarge`].
|
||||||
|
fn validate(&self, est_span_ms: i64) -> Result<(), GroundTruthError> {
|
||||||
|
if self.grid_ms <= 0 {
|
||||||
|
return Err(GroundTruthError::InvalidConfig {
|
||||||
|
reason: "grid_ms must be positive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if self.max_lag_ms < 0 {
|
||||||
|
return Err(GroundTruthError::InvalidConfig {
|
||||||
|
reason: "max_lag_ms must be non-negative",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if self.max_gap_ms < 0 {
|
||||||
|
return Err(GroundTruthError::InvalidConfig {
|
||||||
|
reason: "max_gap_ms must be non-negative",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// The estimate span bounds the widest possible grid (overlap ⊆ estimate
|
||||||
|
// range), so this caps every per-lag resample.
|
||||||
|
let grid_points = (est_span_ms / self.grid_ms) as usize + 1;
|
||||||
|
if grid_points > MAX_GRID_POINTS {
|
||||||
|
return Err(GroundTruthError::GridTooLarge {
|
||||||
|
max: MAX_GRID_POINTS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let lag_steps = (self.max_lag_ms / self.grid_ms) as usize * 2 + 1;
|
||||||
|
if lag_steps > MAX_LAG_STEPS {
|
||||||
|
return Err(GroundTruthError::LagWindowTooLarge {
|
||||||
|
max: MAX_LAG_STEPS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The recovered constant offset and the quality of the alignment at it.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct Alignment {
|
||||||
|
/// Recovered constant offset, milliseconds: the reference is sampled at
|
||||||
|
/// `grid_time + offset_ms` to align with the estimate.
|
||||||
|
pub offset_ms: i64,
|
||||||
|
/// The grid step used.
|
||||||
|
pub grid_ms: i64,
|
||||||
|
/// Alignment quality at the chosen offset: normalized cross-correlation for
|
||||||
|
/// continuous measurands, label-agreement fraction for categorical ones.
|
||||||
|
/// `None` when it could not be computed (too few overlapping points, or a
|
||||||
|
/// constant/zero-variance continuous signal) — a first-class UNKNOWN, not
|
||||||
|
/// an error.
|
||||||
|
pub score: Option<f64>,
|
||||||
|
/// Total grid points spanning the overlap at the chosen offset.
|
||||||
|
pub grid_points: usize,
|
||||||
|
/// Grid points where both series had a sample within `max_gap_ms`.
|
||||||
|
pub paired_points: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resample `samples` (sorted by time) onto `grid` by nearest sample within
|
||||||
|
/// `max_gap_ms`; a grid point with no sample in range yields `None` (no
|
||||||
|
/// bridging). `sample_times` must correspond 1:1 to `samples`.
|
||||||
|
fn resample(
|
||||||
|
samples: &[ReferenceObservation],
|
||||||
|
sample_times: &[i64],
|
||||||
|
grid: &[i64],
|
||||||
|
max_gap_ms: i64,
|
||||||
|
) -> Vec<Option<Reading>> {
|
||||||
|
let mut out = Vec::with_capacity(grid.len());
|
||||||
|
for &t in grid {
|
||||||
|
// Nearest neighbour by binary search over the sorted timestamps.
|
||||||
|
let idx = sample_times.partition_point(|&x| x < t);
|
||||||
|
let mut best: Option<(i64, usize)> = None;
|
||||||
|
for cand in [idx.wrapping_sub(1), idx] {
|
||||||
|
if cand < samples.len() {
|
||||||
|
let dt = (sample_times[cand] - t).abs();
|
||||||
|
let better = match best {
|
||||||
|
None => true,
|
||||||
|
Some((bd, _)) => dt < bd,
|
||||||
|
};
|
||||||
|
if better {
|
||||||
|
best = Some((dt, cand));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match best {
|
||||||
|
Some((dt, ci)) if dt <= max_gap_ms => out.push(Some(samples[ci].reading.clone())),
|
||||||
|
_ => out.push(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Score a set of aligned readings: NCC for scalars, agreement fraction for
|
||||||
|
/// labels. `None` when not computable (fewer than two paired scalars, zero
|
||||||
|
/// variance, or no paired labels).
|
||||||
|
fn score_pairs(pairs: &[(Reading, Reading)]) -> Option<f64> {
|
||||||
|
if pairs.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
match &pairs[0].0 {
|
||||||
|
Reading::Scalar(_) => {
|
||||||
|
let xs: Vec<f64> = pairs.iter().filter_map(|(e, _)| e.as_scalar()).collect();
|
||||||
|
let ys: Vec<f64> = pairs.iter().filter_map(|(_, r)| r.as_scalar()).collect();
|
||||||
|
if xs.len() < 2 || xs.len() != ys.len() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
normalized_cross_correlation(&xs, &ys)
|
||||||
|
}
|
||||||
|
Reading::Label(_) => {
|
||||||
|
let n = pairs.len();
|
||||||
|
let agree = pairs
|
||||||
|
.iter()
|
||||||
|
.filter(|(e, r)| e.as_label() == r.as_label())
|
||||||
|
.count();
|
||||||
|
Some(agree as f64 / n as f64)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Normalized cross-correlation of two equal-length vectors; `None` if either
|
||||||
|
/// has zero variance.
|
||||||
|
fn normalized_cross_correlation(xs: &[f64], ys: &[f64]) -> Option<f64> {
|
||||||
|
let n = xs.len() as f64;
|
||||||
|
let mx = xs.iter().sum::<f64>() / n;
|
||||||
|
let my = ys.iter().sum::<f64>() / n;
|
||||||
|
let mut num = 0.0;
|
||||||
|
let mut dx = 0.0;
|
||||||
|
let mut dy = 0.0;
|
||||||
|
for (&x, &y) in xs.iter().zip(ys.iter()) {
|
||||||
|
let a = x - mx;
|
||||||
|
let b = y - my;
|
||||||
|
num += a * b;
|
||||||
|
dx += a * a;
|
||||||
|
dy += b * b;
|
||||||
|
}
|
||||||
|
let denom = (dx * dy).sqrt();
|
||||||
|
if denom <= 0.0 || !denom.is_finite() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(num / denom)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the grid over the overlap of the estimate and offset reference ranges,
|
||||||
|
/// on the estimate timeline. Returns an empty vector when there is no overlap.
|
||||||
|
fn overlap_grid(
|
||||||
|
est_lo: i64,
|
||||||
|
est_hi: i64,
|
||||||
|
ref_lo: i64,
|
||||||
|
ref_hi: i64,
|
||||||
|
offset: i64,
|
||||||
|
grid_ms: i64,
|
||||||
|
) -> Vec<i64> {
|
||||||
|
// Reference is sampled at grid_time + offset, so the reference range maps to
|
||||||
|
// [ref_lo - offset, ref_hi - offset] on the estimate timeline.
|
||||||
|
let lo = est_lo.max(ref_lo.saturating_sub(offset));
|
||||||
|
let hi = est_hi.min(ref_hi.saturating_sub(offset));
|
||||||
|
if lo > hi {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
let mut grid = Vec::new();
|
||||||
|
let mut t = lo;
|
||||||
|
while t <= hi {
|
||||||
|
grid.push(t);
|
||||||
|
// grid_ms > 0 guaranteed by config validation.
|
||||||
|
match t.checked_add(grid_ms) {
|
||||||
|
Some(next) => t = next,
|
||||||
|
None => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
grid
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Produce the aligned reading pairs at a given offset, plus the total grid
|
||||||
|
/// point count over the overlap (used for coverage).
|
||||||
|
pub(crate) fn paired_at(
|
||||||
|
estimate: &EstimateSeries,
|
||||||
|
reference: &ReferenceSeries,
|
||||||
|
offset: i64,
|
||||||
|
cfg: &AlignmentConfig,
|
||||||
|
) -> (usize, Vec<(Reading, Reading)>) {
|
||||||
|
let est = estimate.samples();
|
||||||
|
let refs = reference.samples();
|
||||||
|
let est_times: Vec<i64> = est.iter().map(|o| o.at_unix_ms).collect();
|
||||||
|
let ref_times: Vec<i64> = refs.iter().map(|o| o.at_unix_ms).collect();
|
||||||
|
let (est_lo, est_hi) = (est_times[0], est_times[est_times.len() - 1]);
|
||||||
|
let (ref_lo, ref_hi) = (ref_times[0], ref_times[ref_times.len() - 1]);
|
||||||
|
|
||||||
|
let grid = overlap_grid(est_lo, est_hi, ref_lo, ref_hi, offset, cfg.grid_ms);
|
||||||
|
let total = grid.len();
|
||||||
|
if total == 0 {
|
||||||
|
return (0, Vec::new());
|
||||||
|
}
|
||||||
|
// Estimate sampled on the grid; reference sampled at grid + offset.
|
||||||
|
let ref_grid: Vec<i64> = grid
|
||||||
|
.iter()
|
||||||
|
.map(|&g| g.saturating_add(offset))
|
||||||
|
.collect();
|
||||||
|
let est_r = resample(est, &est_times, &grid, cfg.max_gap_ms);
|
||||||
|
let ref_r = resample(refs, &ref_times, &ref_grid, cfg.max_gap_ms);
|
||||||
|
|
||||||
|
let mut pairs = Vec::new();
|
||||||
|
for (e, r) in est_r.into_iter().zip(ref_r.into_iter()) {
|
||||||
|
if let (Some(e), Some(r)) = (e, r) {
|
||||||
|
pairs.push((e, r));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(total, pairs)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Estimate the constant offset that best aligns `estimate` to `reference`.
|
||||||
|
///
|
||||||
|
/// Searches offsets in `[-max_lag_ms, +max_lag_ms]` stepped by `grid_ms`,
|
||||||
|
/// scoring each by NCC (continuous) or agreement (categorical). Ties are broken
|
||||||
|
/// deterministically toward the smallest absolute offset, then the smallest
|
||||||
|
/// signed offset. When no offset yields any paired points the result reports
|
||||||
|
/// offset `0` with a `None` score — a first-class UNKNOWN.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// [`GroundTruthError::MeasurandMismatch`] if the two series describe different
|
||||||
|
/// measurands, or a configuration error from [`AlignmentConfig::validate`].
|
||||||
|
pub fn estimate_alignment(
|
||||||
|
estimate: &EstimateSeries,
|
||||||
|
reference: &ReferenceSeries,
|
||||||
|
cfg: &AlignmentConfig,
|
||||||
|
) -> Result<Alignment, GroundTruthError> {
|
||||||
|
if estimate.measurand != reference.measurand {
|
||||||
|
return Err(GroundTruthError::MeasurandMismatch {
|
||||||
|
estimate: estimate.measurand.label(),
|
||||||
|
reference: reference.measurand.label(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let est_times = estimate.samples();
|
||||||
|
let span = est_times[est_times.len() - 1].at_unix_ms - est_times[0].at_unix_ms;
|
||||||
|
cfg.validate(span.max(0))?;
|
||||||
|
|
||||||
|
let mut best_offset: i64 = 0;
|
||||||
|
let mut best_score: Option<f64> = None;
|
||||||
|
|
||||||
|
let mut offset = -cfg.max_lag_ms;
|
||||||
|
while offset <= cfg.max_lag_ms {
|
||||||
|
let (_, pairs) = paired_at(estimate, reference, offset, cfg);
|
||||||
|
let score = score_pairs(&pairs);
|
||||||
|
if let Some(s) = score {
|
||||||
|
let replace = match best_score {
|
||||||
|
None => true,
|
||||||
|
Some(b) => {
|
||||||
|
s > b + SCORE_EPS
|
||||||
|
|| ((s - b).abs() <= SCORE_EPS && offset.abs() < best_offset.abs())
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if replace {
|
||||||
|
best_score = Some(s);
|
||||||
|
best_offset = offset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match offset.checked_add(cfg.grid_ms) {
|
||||||
|
Some(next) => offset = next,
|
||||||
|
None => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let (total, pairs) = paired_at(estimate, reference, best_offset, cfg);
|
||||||
|
Ok(Alignment {
|
||||||
|
offset_ms: best_offset,
|
||||||
|
grid_ms: cfg.grid_ms,
|
||||||
|
score: best_score,
|
||||||
|
grid_points: total,
|
||||||
|
paired_points: pairs.len(),
|
||||||
|
})
|
||||||
|
}
|
||||||
148
v2/crates/ruview-groundtruth/src/error.rs
Normal file
148
v2/crates/ruview-groundtruth/src/error.rs
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
//! Boundary errors for the ground-truth validation plane (ADR-303).
|
||||||
|
//!
|
||||||
|
//! No variant panics: malformed reference/estimate input is always a returned
|
||||||
|
//! error, and UNKNOWN/uncertainty are represented as first-class *values*
|
||||||
|
//! elsewhere (an inconclusive [`crate::AgreementReport`] with zero pairs), not
|
||||||
|
//! as errors. `EvidenceError` from the ledger boundary is wrapped transparently
|
||||||
|
//! so a caller sees one error type.
|
||||||
|
|
||||||
|
/// Maximum accepted string-handle length, in bytes. Mirrors
|
||||||
|
/// [`ruview_ontology::MAX_ID_LEN`] and bounds allocation on untrusted input.
|
||||||
|
pub const MAX_STR_LEN: usize = ruview_ontology::MAX_ID_LEN;
|
||||||
|
|
||||||
|
/// Errors raised while ingesting references/estimates, aligning them, or
|
||||||
|
/// emitting an evidence record. Every variant is a returned error, never a
|
||||||
|
/// panic (CLAUDE.md).
|
||||||
|
#[derive(Clone, Debug, PartialEq, thiserror::Error)]
|
||||||
|
pub enum GroundTruthError {
|
||||||
|
/// A required string field was empty.
|
||||||
|
#[error("field `{field}` must not be empty")]
|
||||||
|
EmptyField {
|
||||||
|
/// The offending field name.
|
||||||
|
field: &'static str,
|
||||||
|
},
|
||||||
|
/// A string field exceeded [`MAX_STR_LEN`] bytes.
|
||||||
|
#[error("field `{field}` is {len} bytes, exceeds max {max}")]
|
||||||
|
TooLong {
|
||||||
|
/// The offending field name.
|
||||||
|
field: &'static str,
|
||||||
|
/// Actual byte length.
|
||||||
|
len: usize,
|
||||||
|
/// Enforced maximum.
|
||||||
|
max: usize,
|
||||||
|
},
|
||||||
|
/// A series carried no samples; a reference/estimate must have at least one.
|
||||||
|
#[error("series has no samples")]
|
||||||
|
EmptySeries,
|
||||||
|
/// A series exceeded the bounded sample cap.
|
||||||
|
#[error("series has {len} samples, exceeds max {max}")]
|
||||||
|
TooManySamples {
|
||||||
|
/// Actual sample count.
|
||||||
|
len: usize,
|
||||||
|
/// Enforced maximum.
|
||||||
|
max: usize,
|
||||||
|
},
|
||||||
|
/// Timestamps were not strictly increasing — rejected, never silently
|
||||||
|
/// sorted (ADR-293 ingest discipline).
|
||||||
|
#[error("non-monotonic timestamp at sample {index}: {this_ms} does not follow {prev_ms}")]
|
||||||
|
NonMonotonic {
|
||||||
|
/// Index of the offending sample.
|
||||||
|
index: usize,
|
||||||
|
/// Previous sample timestamp.
|
||||||
|
prev_ms: i64,
|
||||||
|
/// Offending sample timestamp.
|
||||||
|
this_ms: i64,
|
||||||
|
},
|
||||||
|
/// A continuous reading carried a non-finite value.
|
||||||
|
#[error("non-finite value at sample {index}")]
|
||||||
|
NonFiniteValue {
|
||||||
|
/// Index of the offending sample.
|
||||||
|
index: usize,
|
||||||
|
},
|
||||||
|
/// A sample's reading kind (scalar vs label) did not match the measurand's
|
||||||
|
/// family.
|
||||||
|
#[error("sample {index}: reading kind does not match measurand `{measurand}`")]
|
||||||
|
ReadingKindMismatch {
|
||||||
|
/// Index of the offending sample.
|
||||||
|
index: usize,
|
||||||
|
/// The declared measurand.
|
||||||
|
measurand: &'static str,
|
||||||
|
},
|
||||||
|
/// The estimate and reference described different measurands, so they
|
||||||
|
/// cannot be compared.
|
||||||
|
#[error("measurand mismatch: estimate `{estimate}` vs reference `{reference}`")]
|
||||||
|
MeasurandMismatch {
|
||||||
|
/// The estimate measurand.
|
||||||
|
estimate: &'static str,
|
||||||
|
/// The reference measurand.
|
||||||
|
reference: &'static str,
|
||||||
|
},
|
||||||
|
/// The alignment configuration was invalid (e.g. a non-positive grid step).
|
||||||
|
#[error("invalid alignment config: {reason}")]
|
||||||
|
InvalidConfig {
|
||||||
|
/// Human-readable reason.
|
||||||
|
reason: &'static str,
|
||||||
|
},
|
||||||
|
/// The resampling grid would exceed the bounded point cap.
|
||||||
|
#[error("grid would exceed {max} points; widen the grid step or narrow the range")]
|
||||||
|
GridTooLarge {
|
||||||
|
/// Enforced maximum.
|
||||||
|
max: usize,
|
||||||
|
},
|
||||||
|
/// The lag search window would exceed the bounded step cap.
|
||||||
|
#[error("lag window would exceed {max} steps; narrow max_lag_ms or widen grid_ms")]
|
||||||
|
LagWindowTooLarge {
|
||||||
|
/// Enforced maximum.
|
||||||
|
max: usize,
|
||||||
|
},
|
||||||
|
/// A tolerance was negative or non-finite.
|
||||||
|
#[error("tolerance must be finite and non-negative, got {value}")]
|
||||||
|
InvalidTolerance {
|
||||||
|
/// The rejected value.
|
||||||
|
value: f64,
|
||||||
|
},
|
||||||
|
/// A coverage threshold was outside `[0, 1]` or non-finite.
|
||||||
|
#[error("min_coverage must be within [0, 1], got {value}")]
|
||||||
|
InvalidCoverage {
|
||||||
|
/// The rejected value.
|
||||||
|
value: f64,
|
||||||
|
},
|
||||||
|
/// The mandatory subject count exceeded the bounded maximum.
|
||||||
|
#[error("subject_count {count} exceeds max {max}")]
|
||||||
|
SubjectCountTooLarge {
|
||||||
|
/// The rejected count.
|
||||||
|
count: u32,
|
||||||
|
/// Enforced maximum.
|
||||||
|
max: u32,
|
||||||
|
},
|
||||||
|
/// The requested evidence grade was inconsistent with its level/reproducer.
|
||||||
|
#[error("grade/level conflict: {reason}")]
|
||||||
|
GradeLevelConflict {
|
||||||
|
/// Human-readable reason.
|
||||||
|
reason: &'static str,
|
||||||
|
},
|
||||||
|
/// A failure raised by the [`ruview_evidence`] ledger boundary when
|
||||||
|
/// emitting a record.
|
||||||
|
#[error(transparent)]
|
||||||
|
Evidence(#[from] ruview_evidence::EvidenceError),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reject an over-length string field at the boundary.
|
||||||
|
pub(crate) fn check_bound(field: &'static str, value: &str) -> Result<(), GroundTruthError> {
|
||||||
|
if value.len() > MAX_STR_LEN {
|
||||||
|
return Err(GroundTruthError::TooLong {
|
||||||
|
field,
|
||||||
|
len: value.len(),
|
||||||
|
max: MAX_STR_LEN,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reject an empty required string field at the boundary.
|
||||||
|
pub(crate) fn check_nonempty(field: &'static str, value: &str) -> Result<(), GroundTruthError> {
|
||||||
|
if value.is_empty() {
|
||||||
|
return Err(GroundTruthError::EmptyField { field });
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
532
v2/crates/ruview-groundtruth/src/lib.rs
Normal file
532
v2/crates/ruview-groundtruth/src/lib.rs
Normal file
@@ -0,0 +1,532 @@
|
|||||||
|
//! # `ruview-groundtruth` — reference sensors as a formal validation plane (ADR-303)
|
||||||
|
//!
|
||||||
|
//! This crate generalizes the ADR-293 vitals ground-truth rig from a single
|
||||||
|
//! measurand to **any** phenomenon RuView senses (presence, count, range,
|
||||||
|
//! posture, activity, heart rate, breathing rate) and **any** reference
|
||||||
|
//! modality (camera, mmWave, pressure mat, wearable, pulse oximeter,
|
||||||
|
//! microphone, manual label). Its defining design decision (ADR-303) is that
|
||||||
|
//! reference sensors are a **validation plane, never inference inputs**: this
|
||||||
|
//! crate compares RF estimates against independent observation and never hands
|
||||||
|
//! a reference reading back to an estimator.
|
||||||
|
//!
|
||||||
|
//! ## Pipeline
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! ReferenceObservation… ─► ReferenceSeries ─┐
|
||||||
|
//! ├─► estimate_alignment (constant
|
||||||
|
//! EstimateSeries (RF, real|synthetic) ───────┘ offset, bounded xcorr on
|
||||||
|
//! a common grid) ─► Alignment
|
||||||
|
//! │
|
||||||
|
//! └─► AgreementReport::build(scope, cfg, tolerance, policy)
|
||||||
|
//! ├─ n pairs, coverage, MAE/RMSE/bias | label-agreement
|
||||||
|
//! ├─ mandatory SessionScope (subjects, motion, LOS, distance)
|
||||||
|
//! ├─ EvidenceGrade (Measured|Claimed|Synthetic)
|
||||||
|
//! └─ to_evidence_record → ruview_evidence ledger
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! ## Honesty and determinism
|
||||||
|
//!
|
||||||
|
//! - **Canonical vocabulary (ADR-300 rule 3):** the report speaks the
|
||||||
|
//! [`ruview_ontology`] evidence ladder ([`EvidenceLevel`]) and writes an
|
||||||
|
//! [`ruview_evidence`] record — no per-crate reinvention of evidence shapes.
|
||||||
|
//! - **UNKNOWN is first-class (ADR-300 rule 1):** insufficient overlap yields a
|
||||||
|
//! report with zero pairs and a non-MEASURED grade, and an uncomputable
|
||||||
|
//! alignment score is `None` — never an error, never a fabricated number.
|
||||||
|
//! - **Deterministic:** no wall clock and no randomness. All timestamps are
|
||||||
|
//! injected; the alignment search and metrics are pure functions of the
|
||||||
|
//! inputs.
|
||||||
|
//! - **Bounded & validated:** every reference/estimate is validated at the
|
||||||
|
//! boundary (monotonic timestamps, finite scalars, matching reading family)
|
||||||
|
//! and sample/grid/lag counts are capped so malformed input cannot exhaust
|
||||||
|
//! memory.
|
||||||
|
//! - **Grade in types (ADR-293/301):** `Measured` requires an independent
|
||||||
|
//! reference, coverage, paired samples, and a reproducer; synthetic input is
|
||||||
|
//! `Synthetic`/L0 by construction and cannot be raised.
|
||||||
|
|
||||||
|
#![forbid(unsafe_code)]
|
||||||
|
#![warn(missing_docs)]
|
||||||
|
|
||||||
|
mod agreement;
|
||||||
|
mod align;
|
||||||
|
mod error;
|
||||||
|
mod model;
|
||||||
|
mod scope;
|
||||||
|
mod series;
|
||||||
|
mod source;
|
||||||
|
|
||||||
|
pub use agreement::{AgreementMetrics, AgreementReport, EvidenceGrade, GradingPolicy};
|
||||||
|
pub use align::{
|
||||||
|
estimate_alignment, Alignment, AlignmentConfig, MAX_GRID_POINTS, MAX_LAG_STEPS,
|
||||||
|
};
|
||||||
|
pub use error::{GroundTruthError, MAX_STR_LEN};
|
||||||
|
pub use model::{DataProvenance, Measurand, Reading, ReadingKind};
|
||||||
|
pub use scope::{DistanceBand, LineOfSight, MotionState, SessionScope, MAX_SUBJECTS};
|
||||||
|
pub use series::{EstimateSeries, ReferenceObservation, ReferenceSeries, MAX_SAMPLES};
|
||||||
|
pub use source::{ReferenceModality, ReferenceSource};
|
||||||
|
|
||||||
|
// The canonical evidence ladder is the ontology's, re-exported so downstream
|
||||||
|
// crates use one vocabulary (ADR-300 rule 3).
|
||||||
|
pub use ruview_ontology::EvidenceLevel;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use ruview_evidence::{
|
||||||
|
AccuracyMetrics, EvidenceContext, EvidenceLedger, ProvenanceClass,
|
||||||
|
EvidenceLevel as LedgerLevel,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn src() -> ReferenceSource {
|
||||||
|
ReferenceSource::new(
|
||||||
|
ReferenceModality::Wearable,
|
||||||
|
"chest-strap-A",
|
||||||
|
"Polar H10",
|
||||||
|
"ecg",
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scope() -> SessionScope {
|
||||||
|
SessionScope::new(
|
||||||
|
1,
|
||||||
|
MotionState::Static,
|
||||||
|
LineOfSight::Los,
|
||||||
|
DistanceBand::Near,
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn measured_policy() -> GradingPolicy {
|
||||||
|
GradingPolicy::new(0.5, EvidenceLevel::L3, Some("cargo test -p ruview-groundtruth".into()))
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scalar_series_est(measurand: Measurand, prov: DataProvenance, vals: &[(i64, f64)]) -> EstimateSeries {
|
||||||
|
let samples = vals
|
||||||
|
.iter()
|
||||||
|
.map(|&(t, v)| ReferenceObservation::scalar(t, v))
|
||||||
|
.collect();
|
||||||
|
EstimateSeries::new(measurand, "rf-model-v1", prov, samples).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scalar_series_ref(measurand: Measurand, vals: &[(i64, f64)]) -> ReferenceSeries {
|
||||||
|
let samples = vals
|
||||||
|
.iter()
|
||||||
|
.map(|&(t, v)| ReferenceObservation::scalar(t, v))
|
||||||
|
.collect();
|
||||||
|
ReferenceSeries::new(src(), measurand, samples).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
// A distinctive, non-periodic pattern so the cross-correlation peaks
|
||||||
|
// uniquely at the true lag (digits of pi).
|
||||||
|
const PATTERN: [f64; 11] = [3., 1., 4., 1., 5., 9., 2., 6., 5., 3., 5.];
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn alignment_recovers_known_synthetic_offset() {
|
||||||
|
// Estimate on a 1 s grid, reference the same pattern shifted +2000 ms.
|
||||||
|
let est_vals: Vec<(i64, f64)> = PATTERN
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, &v)| (i as i64 * 1000, v))
|
||||||
|
.collect();
|
||||||
|
let ref_vals: Vec<(i64, f64)> = PATTERN
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, &v)| (i as i64 * 1000 + 2000, v))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let est = scalar_series_est(Measurand::HeartRateBpm, DataProvenance::Real, &est_vals);
|
||||||
|
let refr = scalar_series_ref(Measurand::HeartRateBpm, &ref_vals);
|
||||||
|
let cfg = AlignmentConfig {
|
||||||
|
grid_ms: 1000,
|
||||||
|
max_lag_ms: 5000,
|
||||||
|
max_gap_ms: 400,
|
||||||
|
};
|
||||||
|
|
||||||
|
let a = estimate_alignment(&est, &refr, &cfg).unwrap();
|
||||||
|
assert_eq!(a.offset_ms, 2000);
|
||||||
|
// Perfect match at the true lag.
|
||||||
|
assert!((a.score.unwrap() - 1.0).abs() < 1e-9);
|
||||||
|
assert!(a.paired_points >= 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn alignment_is_deterministic() {
|
||||||
|
let est_vals: Vec<(i64, f64)> = PATTERN
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, &v)| (i as i64 * 1000, v))
|
||||||
|
.collect();
|
||||||
|
let ref_vals: Vec<(i64, f64)> = PATTERN
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, &v)| (i as i64 * 1000 + 3000, v))
|
||||||
|
.collect();
|
||||||
|
let est = scalar_series_est(Measurand::HeartRateBpm, DataProvenance::Real, &est_vals);
|
||||||
|
let refr = scalar_series_ref(Measurand::HeartRateBpm, &ref_vals);
|
||||||
|
let cfg = AlignmentConfig { grid_ms: 1000, max_lag_ms: 6000, max_gap_ms: 400 };
|
||||||
|
let a1 = estimate_alignment(&est, &refr, &cfg).unwrap();
|
||||||
|
let a2 = estimate_alignment(&est, &refr, &cfg).unwrap();
|
||||||
|
assert_eq!(a1, a2);
|
||||||
|
assert_eq!(a1.offset_ms, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn continuous_agreement_matches_hand_computed_fixture() {
|
||||||
|
// Aligned at offset 0; errors (e - r) = [-2, 1, -3].
|
||||||
|
let est = scalar_series_est(
|
||||||
|
Measurand::HeartRateBpm,
|
||||||
|
DataProvenance::Real,
|
||||||
|
&[(0, 10.0), (1000, 20.0), (2000, 30.0)],
|
||||||
|
);
|
||||||
|
let refr = scalar_series_ref(
|
||||||
|
Measurand::HeartRateBpm,
|
||||||
|
&[(0, 12.0), (1000, 19.0), (2000, 33.0)],
|
||||||
|
);
|
||||||
|
let cfg = AlignmentConfig { grid_ms: 1000, max_lag_ms: 0, max_gap_ms: 400 };
|
||||||
|
|
||||||
|
let report = AgreementReport::build(&est, &refr, scope(), &cfg, 2.5, &measured_policy())
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(report.n_pairs, 3);
|
||||||
|
assert!((report.coverage - 1.0).abs() < 1e-9);
|
||||||
|
match report.metrics {
|
||||||
|
AgreementMetrics::Continuous { mae, rmse, bias, within_tolerance } => {
|
||||||
|
assert!((mae - 2.0).abs() < 1e-9); // (2+1+3)/3
|
||||||
|
assert!((rmse - (14.0f64 / 3.0).sqrt()).abs() < 1e-9); // sqrt((4+1+9)/3)
|
||||||
|
assert!((bias - (-4.0 / 3.0)).abs() < 1e-9); // (-2+1-3)/3
|
||||||
|
assert!((within_tolerance - 2.0 / 3.0).abs() < 1e-9); // |−2|,|1| in, |−3| out
|
||||||
|
}
|
||||||
|
other => panic!("expected continuous metrics, got {other:?}"),
|
||||||
|
}
|
||||||
|
// Real reference + full coverage + reproducer => Measured.
|
||||||
|
assert_eq!(report.grade, EvidenceGrade::Measured);
|
||||||
|
assert_eq!(report.evidence_level, EvidenceLevel::L3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn categorical_label_agreement_matches_fixture() {
|
||||||
|
let est_samples = vec![
|
||||||
|
ReferenceObservation::label(0, "present"),
|
||||||
|
ReferenceObservation::label(1000, "absent"),
|
||||||
|
ReferenceObservation::label(2000, "present"),
|
||||||
|
ReferenceObservation::label(3000, "present"),
|
||||||
|
];
|
||||||
|
let ref_samples = vec![
|
||||||
|
ReferenceObservation::label(0, "present"),
|
||||||
|
ReferenceObservation::label(1000, "absent"),
|
||||||
|
ReferenceObservation::label(2000, "absent"),
|
||||||
|
ReferenceObservation::label(3000, "present"),
|
||||||
|
];
|
||||||
|
let est = EstimateSeries::new(
|
||||||
|
Measurand::Presence,
|
||||||
|
"rf-model-v1",
|
||||||
|
DataProvenance::Real,
|
||||||
|
est_samples,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let refr = ReferenceSeries::new(
|
||||||
|
ReferenceSource::new(ReferenceModality::Camera, "cam-1", "RealSense", "labels").unwrap(),
|
||||||
|
Measurand::Presence,
|
||||||
|
ref_samples,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let cfg = AlignmentConfig { grid_ms: 1000, max_lag_ms: 0, max_gap_ms: 400 };
|
||||||
|
|
||||||
|
let report =
|
||||||
|
AgreementReport::build(&est, &refr, scope(), &cfg, 0.0, &measured_policy()).unwrap();
|
||||||
|
assert_eq!(report.n_pairs, 4);
|
||||||
|
match report.metrics {
|
||||||
|
AgreementMetrics::Categorical { agreement, n_agree } => {
|
||||||
|
assert_eq!(n_agree, 3);
|
||||||
|
assert!((agreement - 0.75).abs() < 1e-9);
|
||||||
|
}
|
||||||
|
other => panic!("expected categorical metrics, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn measured_report_emits_measured_evidence_record() {
|
||||||
|
let est = scalar_series_est(
|
||||||
|
Measurand::BreathingRateBrpm,
|
||||||
|
DataProvenance::Real,
|
||||||
|
&[(0, 12.0), (1000, 13.0), (2000, 12.5)],
|
||||||
|
);
|
||||||
|
let refr = scalar_series_ref(
|
||||||
|
Measurand::BreathingRateBrpm,
|
||||||
|
&[(0, 12.0), (1000, 13.0), (2000, 12.5)],
|
||||||
|
);
|
||||||
|
let cfg = AlignmentConfig { grid_ms: 1000, max_lag_ms: 0, max_gap_ms: 400 };
|
||||||
|
let report =
|
||||||
|
AgreementReport::build(&est, &refr, scope(), &cfg, 1.0, &measured_policy()).unwrap();
|
||||||
|
assert_eq!(report.grade, EvidenceGrade::Measured);
|
||||||
|
|
||||||
|
let ctx = EvidenceContext::new("space-kitchen", "dev-esp32-A", "adult", "rf-model-v1")
|
||||||
|
.unwrap();
|
||||||
|
let metrics = AccuracyMetrics {
|
||||||
|
moving_recall: 0.9,
|
||||||
|
stationary_recall: 0.95,
|
||||||
|
false_positive_rate: 0.02,
|
||||||
|
drift: 0.05,
|
||||||
|
uncertainty: 0.1,
|
||||||
|
calibration_age_secs: 600,
|
||||||
|
sample_count: report.n_pairs as u64,
|
||||||
|
};
|
||||||
|
let record = report
|
||||||
|
.to_evidence_record(ctx.clone(), metrics, 1_700_000_000_000_000)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(record.class(), ProvenanceClass::Measured);
|
||||||
|
assert_eq!(record.level(), LedgerLevel::L3);
|
||||||
|
assert!(!record.reproducer().is_empty());
|
||||||
|
|
||||||
|
let mut ledger = EvidenceLedger::new();
|
||||||
|
let seq = ledger.append(record).unwrap();
|
||||||
|
assert_eq!(seq, 0);
|
||||||
|
assert_eq!(ledger.query(&ctx).len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn synthetic_report_emits_l0_synthetic_record() {
|
||||||
|
let est = scalar_series_est(
|
||||||
|
Measurand::HeartRateBpm,
|
||||||
|
DataProvenance::Synthetic,
|
||||||
|
&[(0, 60.0), (1000, 61.0), (2000, 62.0)],
|
||||||
|
);
|
||||||
|
let refr = scalar_series_ref(
|
||||||
|
Measurand::HeartRateBpm,
|
||||||
|
&[(0, 60.0), (1000, 61.0), (2000, 62.0)],
|
||||||
|
);
|
||||||
|
let cfg = AlignmentConfig { grid_ms: 1000, max_lag_ms: 0, max_gap_ms: 400 };
|
||||||
|
let report =
|
||||||
|
AgreementReport::build(&est, &refr, scope(), &cfg, 1.0, &measured_policy()).unwrap();
|
||||||
|
// Synthetic input can never be MEASURED, regardless of coverage.
|
||||||
|
assert_eq!(report.grade, EvidenceGrade::Synthetic);
|
||||||
|
assert_eq!(report.evidence_level, EvidenceLevel::L0);
|
||||||
|
|
||||||
|
let ctx = EvidenceContext::new("space-lab", "dev-sim", "", "rf-model-v1").unwrap();
|
||||||
|
let metrics = AccuracyMetrics {
|
||||||
|
moving_recall: 1.0,
|
||||||
|
stationary_recall: 1.0,
|
||||||
|
false_positive_rate: 0.0,
|
||||||
|
drift: 0.0,
|
||||||
|
uncertainty: 0.0,
|
||||||
|
calibration_age_secs: 0,
|
||||||
|
sample_count: 3,
|
||||||
|
};
|
||||||
|
let record = report
|
||||||
|
.to_evidence_record(ctx, metrics, 1_700_000_000_000_000)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(record.class(), ProvenanceClass::Synthetic);
|
||||||
|
assert_eq!(record.level(), LedgerLevel::L0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn real_data_without_reproducer_grades_claimed() {
|
||||||
|
let est = scalar_series_est(
|
||||||
|
Measurand::HeartRateBpm,
|
||||||
|
DataProvenance::Real,
|
||||||
|
&[(0, 70.0), (1000, 71.0), (2000, 72.0)],
|
||||||
|
);
|
||||||
|
let refr = scalar_series_ref(
|
||||||
|
Measurand::HeartRateBpm,
|
||||||
|
&[(0, 70.0), (1000, 71.0), (2000, 72.0)],
|
||||||
|
);
|
||||||
|
let cfg = AlignmentConfig { grid_ms: 1000, max_lag_ms: 0, max_gap_ms: 400 };
|
||||||
|
// No reproducer => cannot be Measured even with a real reference.
|
||||||
|
let policy = GradingPolicy::new(0.5, EvidenceLevel::L2, None).unwrap();
|
||||||
|
let report = AgreementReport::build(&est, &refr, scope(), &cfg, 1.0, &policy).unwrap();
|
||||||
|
assert_eq!(report.grade, EvidenceGrade::Claimed);
|
||||||
|
assert_eq!(report.evidence_level, EvidenceLevel::L2);
|
||||||
|
assert!(report.reproducer.is_none());
|
||||||
|
|
||||||
|
let ctx = EvidenceContext::new("space-kitchen", "dev-esp32-A", "adult", "rf-model-v1")
|
||||||
|
.unwrap();
|
||||||
|
let metrics = AccuracyMetrics {
|
||||||
|
moving_recall: 0.8,
|
||||||
|
stationary_recall: 0.9,
|
||||||
|
false_positive_rate: 0.05,
|
||||||
|
drift: 0.1,
|
||||||
|
uncertainty: 0.2,
|
||||||
|
calibration_age_secs: 100,
|
||||||
|
sample_count: 3,
|
||||||
|
};
|
||||||
|
let record = report.to_evidence_record(ctx, metrics, 1).unwrap();
|
||||||
|
assert_eq!(record.class(), ProvenanceClass::Claimed);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn low_coverage_grades_claimed_not_measured() {
|
||||||
|
// Reference far from the estimate grid: nearest-sample gap exceeds
|
||||||
|
// max_gap for most points, so coverage falls below the threshold.
|
||||||
|
let est = scalar_series_est(
|
||||||
|
Measurand::HeartRateBpm,
|
||||||
|
DataProvenance::Real,
|
||||||
|
&[(0, 60.0), (1000, 61.0), (2000, 62.0), (3000, 63.0)],
|
||||||
|
);
|
||||||
|
// Reference has a single usable sample near t=0 and a distant gap.
|
||||||
|
let refr = scalar_series_ref(
|
||||||
|
Measurand::HeartRateBpm,
|
||||||
|
&[(0, 60.0), (9000, 99.0)],
|
||||||
|
);
|
||||||
|
let cfg = AlignmentConfig { grid_ms: 1000, max_lag_ms: 0, max_gap_ms: 400 };
|
||||||
|
let policy = GradingPolicy::new(0.9, EvidenceLevel::L3, Some("repro".into())).unwrap();
|
||||||
|
let report = AgreementReport::build(&est, &refr, scope(), &cfg, 1.0, &policy).unwrap();
|
||||||
|
assert!(report.coverage < 0.9);
|
||||||
|
assert_eq!(report.grade, EvidenceGrade::Claimed);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_overlap_is_unknown_not_error() {
|
||||||
|
// Estimate and reference ranges do not overlap even after the bounded
|
||||||
|
// lag search — a first-class UNKNOWN report, not an error.
|
||||||
|
let est = scalar_series_est(
|
||||||
|
Measurand::HeartRateBpm,
|
||||||
|
DataProvenance::Real,
|
||||||
|
&[(0, 60.0), (1000, 61.0)],
|
||||||
|
);
|
||||||
|
let refr = scalar_series_ref(
|
||||||
|
Measurand::HeartRateBpm,
|
||||||
|
&[(1_000_000, 60.0), (1_001_000, 61.0)],
|
||||||
|
);
|
||||||
|
let cfg = AlignmentConfig { grid_ms: 1000, max_lag_ms: 2000, max_gap_ms: 400 };
|
||||||
|
let report =
|
||||||
|
AgreementReport::build(&est, &refr, scope(), &cfg, 1.0, &measured_policy()).unwrap();
|
||||||
|
assert_eq!(report.n_pairs, 0);
|
||||||
|
assert_eq!(report.coverage, 0.0);
|
||||||
|
assert!(report.alignment.score.is_none());
|
||||||
|
assert_eq!(report.grade, EvidenceGrade::Claimed);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scope_is_mandatory_and_bounded() {
|
||||||
|
// SessionScope::new rejects an absurd subject count at the boundary.
|
||||||
|
let err = SessionScope::new(
|
||||||
|
MAX_SUBJECTS + 1,
|
||||||
|
MotionState::Moving,
|
||||||
|
LineOfSight::Nlos,
|
||||||
|
DistanceBand::Far,
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(
|
||||||
|
err,
|
||||||
|
GroundTruthError::SubjectCountTooLarge { .. }
|
||||||
|
));
|
||||||
|
// An empty-room session (0 subjects) is valid.
|
||||||
|
assert!(SessionScope::new(0, MotionState::Static, LineOfSight::Los, DistanceBand::Near)
|
||||||
|
.is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ingest_rejects_malformed_series() {
|
||||||
|
// Non-monotonic timestamps.
|
||||||
|
let err = ReferenceSeries::new(
|
||||||
|
src(),
|
||||||
|
Measurand::HeartRateBpm,
|
||||||
|
vec![
|
||||||
|
ReferenceObservation::scalar(1000, 60.0),
|
||||||
|
ReferenceObservation::scalar(1000, 61.0),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(err, GroundTruthError::NonMonotonic { index: 1, .. }));
|
||||||
|
|
||||||
|
// Reading family mismatched to the measurand.
|
||||||
|
let err = ReferenceSeries::new(
|
||||||
|
src(),
|
||||||
|
Measurand::HeartRateBpm,
|
||||||
|
vec![ReferenceObservation::label(0, "present")],
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(err, GroundTruthError::ReadingKindMismatch { index: 0, .. }));
|
||||||
|
|
||||||
|
// Empty series.
|
||||||
|
let err = ReferenceSeries::new(src(), Measurand::HeartRateBpm, vec![]).unwrap_err();
|
||||||
|
assert!(matches!(err, GroundTruthError::EmptySeries));
|
||||||
|
|
||||||
|
// Non-finite scalar.
|
||||||
|
let err = ReferenceSeries::new(
|
||||||
|
src(),
|
||||||
|
Measurand::HeartRateBpm,
|
||||||
|
vec![ReferenceObservation::scalar(0, f64::NAN)],
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(err, GroundTruthError::NonFiniteValue { index: 0 }));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn measurand_mismatch_is_rejected() {
|
||||||
|
let est = scalar_series_est(
|
||||||
|
Measurand::HeartRateBpm,
|
||||||
|
DataProvenance::Real,
|
||||||
|
&[(0, 60.0), (1000, 61.0)],
|
||||||
|
);
|
||||||
|
let refr = scalar_series_ref(
|
||||||
|
Measurand::BreathingRateBrpm,
|
||||||
|
&[(0, 12.0), (1000, 13.0)],
|
||||||
|
);
|
||||||
|
let cfg = AlignmentConfig::default();
|
||||||
|
let err = AgreementReport::build(&est, &refr, scope(), &cfg, 1.0, &measured_policy())
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(err, GroundTruthError::MeasurandMismatch { .. }));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn report_build_is_deterministic() {
|
||||||
|
let est = scalar_series_est(
|
||||||
|
Measurand::HeartRateBpm,
|
||||||
|
DataProvenance::Real,
|
||||||
|
&[(0, 10.0), (1000, 20.0), (2000, 30.0)],
|
||||||
|
);
|
||||||
|
let refr = scalar_series_ref(
|
||||||
|
Measurand::HeartRateBpm,
|
||||||
|
&[(0, 12.0), (1000, 19.0), (2000, 33.0)],
|
||||||
|
);
|
||||||
|
let cfg = AlignmentConfig { grid_ms: 1000, max_lag_ms: 0, max_gap_ms: 400 };
|
||||||
|
let r1 = AgreementReport::build(&est, &refr, scope(), &cfg, 2.5, &measured_policy()).unwrap();
|
||||||
|
let r2 = AgreementReport::build(&est, &refr, scope(), &cfg, 2.5, &measured_policy()).unwrap();
|
||||||
|
assert_eq!(r1, r2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn report_json_round_trips() {
|
||||||
|
let est = scalar_series_est(
|
||||||
|
Measurand::HeartRateBpm,
|
||||||
|
DataProvenance::Real,
|
||||||
|
&[(0, 10.0), (1000, 20.0), (2000, 30.0)],
|
||||||
|
);
|
||||||
|
let refr = scalar_series_ref(
|
||||||
|
Measurand::HeartRateBpm,
|
||||||
|
&[(0, 12.0), (1000, 19.0), (2000, 33.0)],
|
||||||
|
);
|
||||||
|
let cfg = AlignmentConfig { grid_ms: 1000, max_lag_ms: 0, max_gap_ms: 400 };
|
||||||
|
let report =
|
||||||
|
AgreementReport::build(&est, &refr, scope(), &cfg, 2.5, &measured_policy()).unwrap();
|
||||||
|
let json = serde_json::to_string(&report).unwrap();
|
||||||
|
let back: AgreementReport = serde_json::from_str(&json).unwrap();
|
||||||
|
// Structural equality on everything but the alignment score, which can
|
||||||
|
// differ by a ULP through a text round-trip (serde_json float parsing).
|
||||||
|
assert_eq!(back.source, report.source);
|
||||||
|
assert_eq!(back.measurand, report.measurand);
|
||||||
|
assert_eq!(back.scope, report.scope);
|
||||||
|
assert_eq!(back.n_pairs, report.n_pairs);
|
||||||
|
assert_eq!(back.grade, report.grade);
|
||||||
|
assert_eq!(back.evidence_level, report.evidence_level);
|
||||||
|
assert_eq!(back.metrics, report.metrics);
|
||||||
|
assert_eq!(back.alignment.offset_ms, report.alignment.offset_ms);
|
||||||
|
assert!(
|
||||||
|
(back.alignment.score.unwrap() - report.alignment.score.unwrap()).abs() < 1e-9
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn grading_policy_rejects_l0_and_bad_coverage() {
|
||||||
|
assert!(matches!(
|
||||||
|
GradingPolicy::new(0.5, EvidenceLevel::L0, None).unwrap_err(),
|
||||||
|
GroundTruthError::GradeLevelConflict { .. }
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
GradingPolicy::new(1.5, EvidenceLevel::L2, None).unwrap_err(),
|
||||||
|
GroundTruthError::InvalidCoverage { .. }
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
129
v2/crates/ruview-groundtruth/src/model.rs
Normal file
129
v2/crates/ruview-groundtruth/src/model.rs
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
//! Modality-agnostic measurands and readings (ADR-303 §1).
|
||||||
|
//!
|
||||||
|
//! ADR-293 built ground truth for a single measurand family (heart rate,
|
||||||
|
//! breathing rate). This module generalizes the *value* being compared to any
|
||||||
|
//! phenomenon RuView senses — continuous scalars (vitals, count, range) and
|
||||||
|
//! categorical labels (presence, activity, posture) — so the same alignment
|
||||||
|
//! and agreement machinery applies to every modality.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// Whether a measurand is compared as a continuous scalar or a discrete label.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum ReadingKind {
|
||||||
|
/// A continuous numeric value (heart rate, range, count).
|
||||||
|
Scalar,
|
||||||
|
/// A discrete class label (presence, activity, posture).
|
||||||
|
Label,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A phenomenon compared against an independent reference. This is the
|
||||||
|
/// modality-agnostic generalization of ADR-293's per-device measurand: the set
|
||||||
|
/// is deliberately small and closed so the agreement math per family stays
|
||||||
|
/// honest (pose keypoint PCK, which needs the ADR-291 mean-pose baseline and a
|
||||||
|
/// leakage-free split, is intentionally out of scope for this crate).
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum Measurand {
|
||||||
|
/// Someone present in the space (categorical: e.g. `"present"`/`"absent"`).
|
||||||
|
Presence,
|
||||||
|
/// The activity a subject is performing (categorical label).
|
||||||
|
Activity,
|
||||||
|
/// A subject's posture (categorical label).
|
||||||
|
Posture,
|
||||||
|
/// Heart rate, beats per minute (continuous).
|
||||||
|
HeartRateBpm,
|
||||||
|
/// Breathing rate, breaths per minute (continuous).
|
||||||
|
BreathingRateBrpm,
|
||||||
|
/// The number of people present (continuous count).
|
||||||
|
PersonCount,
|
||||||
|
/// Range / localization distance, metres (continuous).
|
||||||
|
RangeMeters,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Measurand {
|
||||||
|
/// The reading family this measurand is compared in.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn kind(self) -> ReadingKind {
|
||||||
|
match self {
|
||||||
|
Measurand::Presence | Measurand::Activity | Measurand::Posture => ReadingKind::Label,
|
||||||
|
Measurand::HeartRateBpm
|
||||||
|
| Measurand::BreathingRateBrpm
|
||||||
|
| Measurand::PersonCount
|
||||||
|
| Measurand::RangeMeters => ReadingKind::Scalar,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this measurand is compared as a continuous scalar.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn is_continuous(self) -> bool {
|
||||||
|
matches!(self.kind(), ReadingKind::Scalar)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A stable, human-readable tag used in error messages.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Measurand::Presence => "presence",
|
||||||
|
Measurand::Activity => "activity",
|
||||||
|
Measurand::Posture => "posture",
|
||||||
|
Measurand::HeartRateBpm => "heart_rate_bpm",
|
||||||
|
Measurand::BreathingRateBrpm => "breathing_rate_brpm",
|
||||||
|
Measurand::PersonCount => "person_count",
|
||||||
|
Measurand::RangeMeters => "range_meters",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A single reading value: either a continuous scalar or a discrete label.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum Reading {
|
||||||
|
/// A continuous numeric value.
|
||||||
|
Scalar(f64),
|
||||||
|
/// A discrete class label.
|
||||||
|
Label(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Reading {
|
||||||
|
/// The family of this reading.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn kind(&self) -> ReadingKind {
|
||||||
|
match self {
|
||||||
|
Reading::Scalar(_) => ReadingKind::Scalar,
|
||||||
|
Reading::Label(_) => ReadingKind::Label,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Borrow the scalar value, if this is a scalar reading.
|
||||||
|
#[must_use]
|
||||||
|
pub fn as_scalar(&self) -> Option<f64> {
|
||||||
|
match self {
|
||||||
|
Reading::Scalar(v) => Some(*v),
|
||||||
|
Reading::Label(_) => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Borrow the label, if this is a label reading.
|
||||||
|
#[must_use]
|
||||||
|
pub fn as_label(&self) -> Option<&str> {
|
||||||
|
match self {
|
||||||
|
Reading::Label(s) => Some(s.as_str()),
|
||||||
|
Reading::Scalar(_) => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the compared data is real inference/measurement or a generated
|
||||||
|
/// (SYNTHETIC/L0) fixture. This is what forces an [`crate::EvidenceGrade`] to
|
||||||
|
/// `Synthetic`; it is never inferred, it is declared by the producer (mirrors
|
||||||
|
/// ADR-304's synthetic-is-L0-by-construction rule).
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum DataProvenance {
|
||||||
|
/// Real inference / real measurement.
|
||||||
|
Real,
|
||||||
|
/// Generated / simulated input — grades as SYNTHETIC (L0).
|
||||||
|
Synthetic,
|
||||||
|
}
|
||||||
93
v2/crates/ruview-groundtruth/src/scope.rs
Normal file
93
v2/crates/ruview-groundtruth/src/scope.rs
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
//! Mandatory session scope (ADR-303 §3, mirroring ADR-293).
|
||||||
|
//!
|
||||||
|
//! An agreement report without scope cannot be constructed: WiFi-sensing
|
||||||
|
//! numbers without stated scope (subject count, motion, line-of-sight,
|
||||||
|
//! distance) are systematically misleading (ADR-293 Context). [`SessionScope`]
|
||||||
|
//! is a required argument to [`crate::AgreementReport::build`], so the type
|
||||||
|
//! system enforces the rule.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::error::GroundTruthError;
|
||||||
|
|
||||||
|
/// The largest subject count accepted, bounding untrusted input.
|
||||||
|
pub const MAX_SUBJECTS: u16 = 4096;
|
||||||
|
|
||||||
|
/// Whether subjects were static or moving during the session.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum MotionState {
|
||||||
|
/// Subject(s) static / at rest.
|
||||||
|
Static,
|
||||||
|
/// Subject(s) moving.
|
||||||
|
Moving,
|
||||||
|
/// A mix of static and moving intervals.
|
||||||
|
Mixed,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The propagation condition between sensor and subject.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum LineOfSight {
|
||||||
|
/// Line-of-sight.
|
||||||
|
Los,
|
||||||
|
/// Non-line-of-sight (obstructed, same room).
|
||||||
|
Nlos,
|
||||||
|
/// Through-wall.
|
||||||
|
ThroughWall,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A coarse distance band between sensor and subject.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum DistanceBand {
|
||||||
|
/// Near (roughly < 2 m).
|
||||||
|
Near,
|
||||||
|
/// Mid (roughly 2–5 m).
|
||||||
|
Mid,
|
||||||
|
/// Far (roughly > 5 m).
|
||||||
|
Far,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mandatory metadata attached to every [`crate::AgreementReport`]. A report
|
||||||
|
/// cannot exist without it, so an agreement number always states the conditions
|
||||||
|
/// it was measured under.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
pub struct SessionScope {
|
||||||
|
/// Number of subjects present (0 is valid for an empty-room session).
|
||||||
|
pub subject_count: u16,
|
||||||
|
/// Motion state during the session.
|
||||||
|
pub motion: MotionState,
|
||||||
|
/// Line-of-sight condition.
|
||||||
|
pub line_of_sight: LineOfSight,
|
||||||
|
/// Distance band.
|
||||||
|
pub distance: DistanceBand,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SessionScope {
|
||||||
|
/// Construct a validated session scope. `subject_count` is bounded to
|
||||||
|
/// [`MAX_SUBJECTS`] so untrusted metadata cannot claim an absurd count.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// [`GroundTruthError::SubjectCountTooLarge`] if `subject_count` exceeds
|
||||||
|
/// [`MAX_SUBJECTS`].
|
||||||
|
pub fn new(
|
||||||
|
subject_count: u16,
|
||||||
|
motion: MotionState,
|
||||||
|
line_of_sight: LineOfSight,
|
||||||
|
distance: DistanceBand,
|
||||||
|
) -> Result<Self, GroundTruthError> {
|
||||||
|
if subject_count > MAX_SUBJECTS {
|
||||||
|
return Err(GroundTruthError::SubjectCountTooLarge {
|
||||||
|
count: u32::from(subject_count),
|
||||||
|
max: u32::from(MAX_SUBJECTS),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(Self {
|
||||||
|
subject_count,
|
||||||
|
motion,
|
||||||
|
line_of_sight,
|
||||||
|
distance,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
203
v2/crates/ruview-groundtruth/src/series.rs
Normal file
203
v2/crates/ruview-groundtruth/src/series.rs
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
//! Timestamped reference and estimate series with boundary validation
|
||||||
|
//! (ADR-303 §1, reusing ADR-293's ingest discipline).
|
||||||
|
//!
|
||||||
|
//! Both a reference (independent observer) and an RF estimate are sequences of
|
||||||
|
//! timestamped [`Reading`]s for one [`Measurand`]. Timestamps must be strictly
|
||||||
|
//! increasing (non-monotonic input is rejected, never silently sorted), scalar
|
||||||
|
//! values must be finite, and each reading's family must match the measurand.
|
||||||
|
//! Sample counts are bounded to cap allocation on untrusted input.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::error::{check_bound, check_nonempty, GroundTruthError};
|
||||||
|
use crate::model::{DataProvenance, Measurand, Reading};
|
||||||
|
use crate::source::ReferenceSource;
|
||||||
|
|
||||||
|
/// The largest series length accepted, bounding allocation on untrusted input.
|
||||||
|
pub const MAX_SAMPLES: usize = 1_000_000;
|
||||||
|
|
||||||
|
/// A single timestamped observation on the validation plane: a producer-stamped
|
||||||
|
/// Unix-millisecond time and a [`Reading`]. Time is always injected, never read
|
||||||
|
/// from a clock inside this crate.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct ReferenceObservation {
|
||||||
|
/// Producer-supplied observation time, Unix milliseconds.
|
||||||
|
pub at_unix_ms: i64,
|
||||||
|
/// The observed value or label.
|
||||||
|
pub reading: Reading,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ReferenceObservation {
|
||||||
|
/// A scalar (continuous) observation at `at_unix_ms`.
|
||||||
|
#[must_use]
|
||||||
|
pub fn scalar(at_unix_ms: i64, value: f64) -> Self {
|
||||||
|
Self {
|
||||||
|
at_unix_ms,
|
||||||
|
reading: Reading::Scalar(value),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A label (categorical) observation at `at_unix_ms`.
|
||||||
|
#[must_use]
|
||||||
|
pub fn label(at_unix_ms: i64, label: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
at_unix_ms,
|
||||||
|
reading: Reading::Label(label.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate a sample vector: non-empty, bounded, strictly increasing
|
||||||
|
/// timestamps, finite scalars, and reading family matching `measurand`.
|
||||||
|
fn validate_samples(
|
||||||
|
measurand: Measurand,
|
||||||
|
samples: &[ReferenceObservation],
|
||||||
|
) -> Result<(), GroundTruthError> {
|
||||||
|
if samples.is_empty() {
|
||||||
|
return Err(GroundTruthError::EmptySeries);
|
||||||
|
}
|
||||||
|
if samples.len() > MAX_SAMPLES {
|
||||||
|
return Err(GroundTruthError::TooManySamples {
|
||||||
|
len: samples.len(),
|
||||||
|
max: MAX_SAMPLES,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let want = measurand.kind();
|
||||||
|
let mut prev: Option<i64> = None;
|
||||||
|
for (index, s) in samples.iter().enumerate() {
|
||||||
|
if s.reading.kind() != want {
|
||||||
|
return Err(GroundTruthError::ReadingKindMismatch {
|
||||||
|
index,
|
||||||
|
measurand: measurand.label(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if let Reading::Scalar(v) = &s.reading {
|
||||||
|
if !v.is_finite() {
|
||||||
|
return Err(GroundTruthError::NonFiniteValue { index });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Reading::Label(l) = &s.reading {
|
||||||
|
check_bound("label", l)?;
|
||||||
|
}
|
||||||
|
if let Some(p) = prev {
|
||||||
|
if s.at_unix_ms <= p {
|
||||||
|
return Err(GroundTruthError::NonMonotonic {
|
||||||
|
index,
|
||||||
|
prev_ms: p,
|
||||||
|
this_ms: s.at_unix_ms,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prev = Some(s.at_unix_ms);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A validated series of independent reference observations for one measurand.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct ReferenceSeries {
|
||||||
|
/// The named reference source.
|
||||||
|
pub source: ReferenceSource,
|
||||||
|
/// The measurand observed.
|
||||||
|
pub measurand: Measurand,
|
||||||
|
samples: Vec<ReferenceObservation>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ReferenceSeries {
|
||||||
|
/// Ingest and validate a reference series at the boundary.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// [`GroundTruthError::EmptySeries`], [`GroundTruthError::TooManySamples`],
|
||||||
|
/// [`GroundTruthError::NonMonotonic`], [`GroundTruthError::NonFiniteValue`],
|
||||||
|
/// [`GroundTruthError::ReadingKindMismatch`], or
|
||||||
|
/// [`GroundTruthError::TooLong`].
|
||||||
|
pub fn new(
|
||||||
|
source: ReferenceSource,
|
||||||
|
measurand: Measurand,
|
||||||
|
samples: Vec<ReferenceObservation>,
|
||||||
|
) -> Result<Self, GroundTruthError> {
|
||||||
|
validate_samples(measurand, &samples)?;
|
||||||
|
Ok(Self {
|
||||||
|
source,
|
||||||
|
measurand,
|
||||||
|
samples,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The validated samples, in time order.
|
||||||
|
#[must_use]
|
||||||
|
pub fn samples(&self) -> &[ReferenceObservation] {
|
||||||
|
&self.samples
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The number of samples.
|
||||||
|
#[must_use]
|
||||||
|
pub fn len(&self) -> usize {
|
||||||
|
self.samples.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the series is empty. Always `false` for a constructed series
|
||||||
|
/// (empty input is rejected), provided so clippy's `len`-without-`is_empty`
|
||||||
|
/// lint is satisfied.
|
||||||
|
#[must_use]
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.samples.is_empty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A validated series of RF-estimate observations for one measurand, carrying
|
||||||
|
/// the producing model version and whether the data is real or synthetic.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct EstimateSeries {
|
||||||
|
/// The measurand estimated.
|
||||||
|
pub measurand: Measurand,
|
||||||
|
/// The model version that produced the estimates (ADR-136).
|
||||||
|
pub model_version: String,
|
||||||
|
/// Whether the estimates are real inference or synthetic input.
|
||||||
|
pub provenance: DataProvenance,
|
||||||
|
samples: Vec<ReferenceObservation>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EstimateSeries {
|
||||||
|
/// Ingest and validate an estimate series at the boundary. `model_version`
|
||||||
|
/// must be non-empty and length-bounded.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// As [`ReferenceSeries::new`], plus [`GroundTruthError::EmptyField`] for a
|
||||||
|
/// missing `model_version`.
|
||||||
|
pub fn new(
|
||||||
|
measurand: Measurand,
|
||||||
|
model_version: impl Into<String>,
|
||||||
|
provenance: DataProvenance,
|
||||||
|
samples: Vec<ReferenceObservation>,
|
||||||
|
) -> Result<Self, GroundTruthError> {
|
||||||
|
let model_version = model_version.into();
|
||||||
|
check_bound("model_version", &model_version)?;
|
||||||
|
check_nonempty("model_version", &model_version)?;
|
||||||
|
validate_samples(measurand, &samples)?;
|
||||||
|
Ok(Self {
|
||||||
|
measurand,
|
||||||
|
model_version,
|
||||||
|
provenance,
|
||||||
|
samples,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The validated samples, in time order.
|
||||||
|
#[must_use]
|
||||||
|
pub fn samples(&self) -> &[ReferenceObservation] {
|
||||||
|
&self.samples
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The number of samples.
|
||||||
|
#[must_use]
|
||||||
|
pub fn len(&self) -> usize {
|
||||||
|
self.samples.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the series is empty (always `false` for a constructed series).
|
||||||
|
#[must_use]
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.samples.is_empty()
|
||||||
|
}
|
||||||
|
}
|
||||||
105
v2/crates/ruview-groundtruth/src/source.rs
Normal file
105
v2/crates/ruview-groundtruth/src/source.rs
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
//! Named reference sources on the validation plane (ADR-303 §1).
|
||||||
|
//!
|
||||||
|
//! A reference source is an *independent observer* used only to check RF
|
||||||
|
//! inference — never an inference input (ADR-303 Decision, option 1 rejected).
|
||||||
|
//! It carries the modality, a named source, device metadata, and the recorded
|
||||||
|
//! measurement principle so a MEASURED claim states what it was measured
|
||||||
|
//! against.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::error::{check_bound, check_nonempty, GroundTruthError};
|
||||||
|
|
||||||
|
/// The modality of an independent reference. Camera/mmWave references arrive as
|
||||||
|
/// exported label/keypoint streams, not live model feeds (ADR-303 §1).
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum ReferenceModality {
|
||||||
|
/// Optical camera (exported labels/keypoints).
|
||||||
|
Camera,
|
||||||
|
/// mmWave radar (exported detections/point cloud).
|
||||||
|
MmWave,
|
||||||
|
/// Pressure mat / floor sensor.
|
||||||
|
Pressure,
|
||||||
|
/// Body-worn wearable (e.g. chest strap, IMU).
|
||||||
|
Wearable,
|
||||||
|
/// Pulse oximeter.
|
||||||
|
PulseOximeter,
|
||||||
|
/// Microphone (acoustic reference).
|
||||||
|
Microphone,
|
||||||
|
/// A human-provided manual label.
|
||||||
|
ManualLabel,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ReferenceModality {
|
||||||
|
/// A stable, human-readable tag.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
ReferenceModality::Camera => "camera",
|
||||||
|
ReferenceModality::MmWave => "mmwave",
|
||||||
|
ReferenceModality::Pressure => "pressure",
|
||||||
|
ReferenceModality::Wearable => "wearable",
|
||||||
|
ReferenceModality::PulseOximeter => "pulse_oximeter",
|
||||||
|
ReferenceModality::Microphone => "microphone",
|
||||||
|
ReferenceModality::ManualLabel => "manual_label",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this modality constitutes an *independent* ground-truth
|
||||||
|
/// reference. Every modality here is independent of the RF estimator — that
|
||||||
|
/// independence is exactly what makes a MEASURED grade admissible. Kept as
|
||||||
|
/// a method so the grading rule reads intentionally rather than assuming.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn is_independent_reference(self) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A named reference source: modality plus device/source metadata and the
|
||||||
|
/// measurement principle. Validated at construction so untrusted metadata is
|
||||||
|
/// bounded.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct ReferenceSource {
|
||||||
|
/// The reference modality.
|
||||||
|
pub modality: ReferenceModality,
|
||||||
|
/// A named source (e.g. `"ceiling-cam-1"`, `"chest-strap-A"`).
|
||||||
|
pub name: String,
|
||||||
|
/// Device make/model.
|
||||||
|
pub device: String,
|
||||||
|
/// The recorded measurement principle (e.g. `"ppg"`, `"tof-depth"`); may be
|
||||||
|
/// empty when not applicable, but is length-bounded.
|
||||||
|
pub principle: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ReferenceSource {
|
||||||
|
/// Construct a reference source, validating metadata at the boundary.
|
||||||
|
/// `name` and `device` must be non-empty; all fields are length-bounded.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// [`GroundTruthError::EmptyField`] for a missing `name`/`device`;
|
||||||
|
/// [`GroundTruthError::TooLong`] for any over-length field.
|
||||||
|
pub fn new(
|
||||||
|
modality: ReferenceModality,
|
||||||
|
name: impl Into<String>,
|
||||||
|
device: impl Into<String>,
|
||||||
|
principle: impl Into<String>,
|
||||||
|
) -> Result<Self, GroundTruthError> {
|
||||||
|
let name = name.into();
|
||||||
|
let device = device.into();
|
||||||
|
let principle = principle.into();
|
||||||
|
|
||||||
|
check_bound("name", &name)?;
|
||||||
|
check_bound("device", &device)?;
|
||||||
|
check_bound("principle", &principle)?;
|
||||||
|
check_nonempty("name", &name)?;
|
||||||
|
check_nonempty("device", &device)?;
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
modality,
|
||||||
|
name,
|
||||||
|
device,
|
||||||
|
principle,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
15
v2/crates/ruview-hal/Cargo.toml
Normal file
15
v2/crates/ruview-hal/Cargo.toml
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
[package]
|
||||||
|
name = "ruview-hal"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
authors.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
thiserror.workspace = true
|
||||||
|
serde = { workspace = true, features = ["derive"] }
|
||||||
|
ruview-ontology = { path = "../ruview-ontology" }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
serde_json.workspace = true
|
||||||
234
v2/crates/ruview-hal/src/adapter.rs
Normal file
234
v2/crates/ruview-hal/src/adapter.rs
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
//! The [`SensorHal`] trait (ADR-320 §1) and two deterministic reference
|
||||||
|
//! adapters.
|
||||||
|
//!
|
||||||
|
//! The trait is the extension point: every sensing modality lands as one
|
||||||
|
//! `SensorHal` implementation instead of a bespoke ingest pipeline. It has
|
||||||
|
//! exactly two responsibilities — [`describe`](SensorHal::describe) the device
|
||||||
|
//! in canonical terms, and [`normalize`](SensorHal::normalize) one native raw
|
||||||
|
//! sample into a [`HalObservation`]. `normalize` is the hardware/FFI boundary
|
||||||
|
//! where untrusted input is validated (CLAUDE.md); it is **infallible** by
|
||||||
|
//! design — malformed or out-of-bounds input yields an UNKNOWN-flagged
|
||||||
|
//! observation, never a panic or an error (ADR-300 rule 1).
|
||||||
|
//!
|
||||||
|
//! Two reference adapters ship here, one RF (CSI) and one non-RF (IMU), per the
|
||||||
|
//! ADR-320 validation requirement of at least two modalities. Both are labelled
|
||||||
|
//! SYNTHETIC / L0: they prove the abstraction, not a fielded device, and make
|
||||||
|
//! no MEASURED claim (CLAUDE.md; ADR-320 "Category and honesty discipline").
|
||||||
|
|
||||||
|
use ruview_ontology::{Container, EvidenceLevel, Observation, ObservationId, SemanticProvenance, SensorId};
|
||||||
|
|
||||||
|
use crate::descriptor::{SamplingSpec, SensorDescriptor};
|
||||||
|
use crate::label::CapabilityTag;
|
||||||
|
use crate::modality::Modality;
|
||||||
|
use crate::observation::{HalObservation, Uncertainty};
|
||||||
|
|
||||||
|
/// Injected context a HAL adapter needs to build a canonical observation.
|
||||||
|
///
|
||||||
|
/// Identity, placement, and time are supplied by the caller — the HAL never
|
||||||
|
/// mints ids or reads a wall clock (deterministic; time is injected, mirroring
|
||||||
|
/// the ontology's `at_unix_ms` contract).
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct NormalizeCtx {
|
||||||
|
/// Caller-supplied stable id for the observation to be produced.
|
||||||
|
pub observation_id: ObservationId,
|
||||||
|
/// Where the observation is located (resolved against the ontology graph).
|
||||||
|
pub located_in: Container,
|
||||||
|
/// Injected capture timestamp (Unix ms). Never sampled from a clock here.
|
||||||
|
pub at_unix_ms: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The hardware abstraction: map any sensing modality to one canonical
|
||||||
|
/// observation.
|
||||||
|
///
|
||||||
|
/// Implementations wrap existing producers — CSI (ESP32/Nexmon/FeitCSI via the
|
||||||
|
/// ADR-279 `RfFrameV2` path), 802.11bf (ADR-310), BLE, UWB, mmWave (ADR-063),
|
||||||
|
/// acoustic, camera, lidar, IMU, and `custom` — behind this single trait, so
|
||||||
|
/// the world model and fusion (ADR-311) see only [`HalObservation`]s.
|
||||||
|
pub trait SensorHal {
|
||||||
|
/// The native, modality-specific raw sample type this adapter consumes.
|
||||||
|
/// Kept native (not canonicalized) per the ADR-279 shared-latent lesson.
|
||||||
|
type Raw;
|
||||||
|
|
||||||
|
/// Describe this device in canonical terms.
|
||||||
|
fn describe(&self) -> SensorDescriptor;
|
||||||
|
|
||||||
|
/// Normalize one native raw sample into a canonical [`HalObservation`].
|
||||||
|
///
|
||||||
|
/// Infallible: malformed / out-of-bounds input produces an UNKNOWN-flagged,
|
||||||
|
/// `degraded` observation rather than panicking or erroring.
|
||||||
|
fn normalize(&self, raw: Self::Raw, ctx: &NormalizeCtx) -> HalObservation;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the canonical ontology observation shared by every reference adapter.
|
||||||
|
///
|
||||||
|
/// Reference adapters are synthetic, so the evidence level is pinned to
|
||||||
|
/// [`EvidenceLevel::L0`] and the provenance carries the synthetic calibration
|
||||||
|
/// handle — the fact can never alias to a measured/calibrated observation.
|
||||||
|
fn synthetic_observation(sensor: SensorId, ctx: &NormalizeCtx, model_version: &str) -> Observation {
|
||||||
|
Observation {
|
||||||
|
id: ctx.observation_id.clone(),
|
||||||
|
sensor,
|
||||||
|
located_in: ctx.located_in.clone(),
|
||||||
|
at_unix_ms: ctx.at_unix_ms,
|
||||||
|
evidence_level: EvidenceLevel::L0,
|
||||||
|
provenance: synthetic_provenance(model_version),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A provenance record stamped SYNTHETIC via its calibration handle, so
|
||||||
|
/// [`HalObservation::is_synthetic`] is true and the fact cannot look calibrated.
|
||||||
|
#[must_use]
|
||||||
|
pub fn synthetic_provenance(model_version: impl Into<String>) -> SemanticProvenance {
|
||||||
|
SemanticProvenance {
|
||||||
|
evidence: Vec::new(),
|
||||||
|
model_version: model_version.into(),
|
||||||
|
calibration_version: crate::SYNTHETIC_CALIBRATION.to_string(),
|
||||||
|
privacy_decision: "synthetic".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Maximum CSI taps a reference adapter will read, bounding allocation/compute
|
||||||
|
/// on untrusted input.
|
||||||
|
pub const MAX_CSI_TAPS: usize = 4096;
|
||||||
|
|
||||||
|
/// A native CSI raw sample: per-subcarrier amplitude and phase.
|
||||||
|
///
|
||||||
|
/// This is the *native* frame the adapter keeps — the pipeline never sees it,
|
||||||
|
/// only the [`HalObservation`] it is lifted into.
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub struct CsiSample {
|
||||||
|
/// Per-subcarrier amplitudes (linear).
|
||||||
|
pub amplitudes: Vec<f32>,
|
||||||
|
/// Per-subcarrier phases (radians).
|
||||||
|
pub phases: Vec<f32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A deterministic, synthetic CSI reference adapter (SYNTHETIC / L0).
|
||||||
|
///
|
||||||
|
/// Mirrors the ADR-279 per-device latent adapters in shape without claiming any
|
||||||
|
/// real device: it demonstrates that a CSI producer lifts into the canonical
|
||||||
|
/// observation. It makes no MEASURED claim.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct SyntheticCsiAdapter {
|
||||||
|
/// The ontology sensor identity this adapter is authenticated as.
|
||||||
|
pub sensor_id: SensorId,
|
||||||
|
/// Declared native subcarrier count.
|
||||||
|
pub subcarriers: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SensorHal for SyntheticCsiAdapter {
|
||||||
|
type Raw = CsiSample;
|
||||||
|
|
||||||
|
fn describe(&self) -> SensorDescriptor {
|
||||||
|
SensorDescriptor {
|
||||||
|
sensor_id: self.sensor_id.clone(),
|
||||||
|
modality: Modality::Csi,
|
||||||
|
capabilities: vec![
|
||||||
|
CapabilityTag::new("amplitude").expect("static tag is valid"),
|
||||||
|
CapabilityTag::new("phase").expect("static tag is valid"),
|
||||||
|
],
|
||||||
|
sampling: SamplingSpec {
|
||||||
|
sample_rate_hz: Some(100.0),
|
||||||
|
unit: "csi-complex".to_string(),
|
||||||
|
dimensions: self.subcarriers,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize(&self, raw: Self::Raw, ctx: &NormalizeCtx) -> HalObservation {
|
||||||
|
let observation = synthetic_observation(self.sensor_id.clone(), ctx, "synthetic-csi-adapter@0");
|
||||||
|
|
||||||
|
// Boundary validation: empty, mismatched, over-bounded, or non-finite
|
||||||
|
// input degrades to UNKNOWN rather than panicking or fabricating a
|
||||||
|
// confident value.
|
||||||
|
let malformed = raw.amplitudes.is_empty()
|
||||||
|
|| raw.amplitudes.len() != raw.phases.len()
|
||||||
|
|| raw.amplitudes.len() > MAX_CSI_TAPS
|
||||||
|
|| raw.amplitudes.iter().any(|v| !v.is_finite())
|
||||||
|
|| raw.phases.iter().any(|v| !v.is_finite());
|
||||||
|
|
||||||
|
let uncertainty = if malformed {
|
||||||
|
Uncertainty::degraded()
|
||||||
|
} else {
|
||||||
|
// Deterministic confidence from the mean amplitude, bounded to
|
||||||
|
// [0, 1) by a saturating map. No randomness, no clock.
|
||||||
|
let sum: f64 = raw.amplitudes.iter().map(|&v| f64::from(v).abs()).sum();
|
||||||
|
let mean = sum / raw.amplitudes.len() as f64;
|
||||||
|
Uncertainty::known(mean / (mean + 1.0))
|
||||||
|
};
|
||||||
|
|
||||||
|
HalObservation {
|
||||||
|
modality: Modality::Csi,
|
||||||
|
uncertainty,
|
||||||
|
observation,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A native IMU raw sample: 3-axis acceleration and angular rate.
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub struct ImuSample {
|
||||||
|
/// Acceleration `[x, y, z]` in m/s².
|
||||||
|
pub accel: [f32; 3],
|
||||||
|
/// Angular rate `[x, y, z]` in rad/s.
|
||||||
|
pub gyro: [f32; 3],
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A deterministic, synthetic IMU reference adapter (SYNTHETIC / L0).
|
||||||
|
///
|
||||||
|
/// The required non-RF second modality (ADR-320 validation). Demonstrates that
|
||||||
|
/// a wholly different phenomenon class lifts into the *same* canonical
|
||||||
|
/// observation with its own honest evidence level — it is never lifted to
|
||||||
|
/// camera- or RF-grade.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct SyntheticImuAdapter {
|
||||||
|
/// The ontology sensor identity this adapter is authenticated as.
|
||||||
|
pub sensor_id: SensorId,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SensorHal for SyntheticImuAdapter {
|
||||||
|
type Raw = ImuSample;
|
||||||
|
|
||||||
|
fn describe(&self) -> SensorDescriptor {
|
||||||
|
SensorDescriptor {
|
||||||
|
sensor_id: self.sensor_id.clone(),
|
||||||
|
modality: Modality::Imu,
|
||||||
|
capabilities: vec![
|
||||||
|
CapabilityTag::new("accel").expect("static tag is valid"),
|
||||||
|
CapabilityTag::new("gyro").expect("static tag is valid"),
|
||||||
|
],
|
||||||
|
sampling: SamplingSpec {
|
||||||
|
sample_rate_hz: Some(200.0),
|
||||||
|
unit: "m/s^2|rad/s".to_string(),
|
||||||
|
dimensions: 6,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize(&self, raw: Self::Raw, ctx: &NormalizeCtx) -> HalObservation {
|
||||||
|
let observation = synthetic_observation(self.sensor_id.clone(), ctx, "synthetic-imu-adapter@0");
|
||||||
|
|
||||||
|
let finite = raw.accel.iter().chain(raw.gyro.iter()).all(|v| v.is_finite());
|
||||||
|
|
||||||
|
let uncertainty = if !finite {
|
||||||
|
Uncertainty::degraded()
|
||||||
|
} else {
|
||||||
|
// Deterministic confidence: how close the acceleration magnitude is
|
||||||
|
// to 1 g (a stationary device). Bounded to [0, 1].
|
||||||
|
let g: f64 = raw
|
||||||
|
.accel
|
||||||
|
.iter()
|
||||||
|
.map(|&v| f64::from(v) * f64::from(v))
|
||||||
|
.sum::<f64>()
|
||||||
|
.sqrt();
|
||||||
|
let closeness = 1.0 - ((g - 9.81).abs() / 9.81);
|
||||||
|
Uncertainty::known(closeness)
|
||||||
|
};
|
||||||
|
|
||||||
|
HalObservation {
|
||||||
|
modality: Modality::Imu,
|
||||||
|
uncertainty,
|
||||||
|
observation,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
66
v2/crates/ruview-hal/src/descriptor.rs
Normal file
66
v2/crates/ruview-hal/src/descriptor.rs
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
//! The sensor descriptor (ADR-320 §1): what a device is, in canonical terms.
|
||||||
|
//!
|
||||||
|
//! A [`SensorDescriptor`] binds a HAL implementation to its ontology
|
||||||
|
//! [`Sensor`](ruview_ontology::Sensor) identity, its [`Modality`], the
|
||||||
|
//! capability tags it advertises, and the native sampling/units metadata of its
|
||||||
|
//! raw frame. The native frame is described, not canonicalized: per the ADR-279
|
||||||
|
//! shared-latent lesson, premature canonicalization discards information
|
||||||
|
//! (bandwidth, antenna structure, phase), so the descriptor records the native
|
||||||
|
//! shape and the adapter lifts it into an [`Observation`](ruview_ontology::Observation)
|
||||||
|
//! only at [`normalize`](crate::SensorHal::normalize) time.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use ruview_ontology::SensorId;
|
||||||
|
|
||||||
|
use crate::label::CapabilityTag;
|
||||||
|
use crate::modality::Modality;
|
||||||
|
|
||||||
|
/// Native sampling and unit metadata for a sensor's raw frame.
|
||||||
|
///
|
||||||
|
/// This is descriptive, not prescriptive: it records how the device natively
|
||||||
|
/// produces samples so downstream stages can interpret provenance, without the
|
||||||
|
/// pipeline ever having to understand the raw frame itself.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct SamplingSpec {
|
||||||
|
/// Native sampling rate in Hz when fixed/known. `None` is a first-class
|
||||||
|
/// UNKNOWN — an event-driven or unspecified source is not an error
|
||||||
|
/// (ADR-300 rule 1).
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub sample_rate_hz: Option<f64>,
|
||||||
|
/// Native unit label for one raw sample (e.g. `"csi-complex"`, `"m/s^2"`,
|
||||||
|
/// `"dBm"`). Descriptive free-form metadata, not a parsed quantity.
|
||||||
|
pub unit: String,
|
||||||
|
/// Native dimensionality of one raw frame (e.g. subcarriers × antennas, or
|
||||||
|
/// IMU axes). `0` means unknown.
|
||||||
|
pub dimensions: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A canonical description of one sensing device.
|
||||||
|
///
|
||||||
|
/// Round-trips losslessly through serde so a fleet controller (ADR-316) can
|
||||||
|
/// enumerate heterogeneous hardware uniformly. The `sensor_id` is the ontology
|
||||||
|
/// identity the device is authenticated as (ADR-305) before its observations
|
||||||
|
/// are trusted.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct SensorDescriptor {
|
||||||
|
/// The ontology sensor identity this device is authenticated as (ADR-305).
|
||||||
|
pub sensor_id: SensorId,
|
||||||
|
/// What phenomenon class the device senses.
|
||||||
|
pub modality: Modality,
|
||||||
|
/// Capability tags — the phenomena the device advertises it can observe.
|
||||||
|
#[serde(default)]
|
||||||
|
pub capabilities: Vec<CapabilityTag>,
|
||||||
|
/// Native sampling / units metadata for the raw frame.
|
||||||
|
pub sampling: SamplingSpec,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SensorDescriptor {
|
||||||
|
/// Re-validate a descriptor received from an untrusted source. Checks the
|
||||||
|
/// modality label bounds; ids and capability tags are validated when
|
||||||
|
/// constructed. Returns UNKNOWN-friendly `Ok(())` for any well-formed
|
||||||
|
/// descriptor.
|
||||||
|
pub fn validate(&self) -> Result<(), crate::label::LabelError> {
|
||||||
|
self.modality.validate()
|
||||||
|
}
|
||||||
|
}
|
||||||
82
v2/crates/ruview-hal/src/label.rs
Normal file
82
v2/crates/ruview-hal/src/label.rs
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
//! Bounded-string validation shared by the HAL's boundary types.
|
||||||
|
//!
|
||||||
|
//! Capability tags and `Modality::Custom` payloads arrive from potentially
|
||||||
|
//! untrusted hardware descriptors. They are validated at construction with the
|
||||||
|
//! same discipline the ontology applies to ids: non-empty, length-bounded, and
|
||||||
|
//! free of ASCII control characters (CLAUDE.md: validate untrusted input at
|
||||||
|
//! every boundary; bound allocation).
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
/// Maximum accepted label length, in bytes. Bounds allocation on untrusted
|
||||||
|
/// input.
|
||||||
|
pub const MAX_LABEL_LEN: usize = 128;
|
||||||
|
|
||||||
|
/// Reasons a raw label string is rejected at the boundary.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Error)]
|
||||||
|
pub enum LabelError {
|
||||||
|
/// The label was empty.
|
||||||
|
#[error("label must not be empty")]
|
||||||
|
Empty,
|
||||||
|
/// The label exceeded [`MAX_LABEL_LEN`] bytes.
|
||||||
|
#[error("label length {len} exceeds maximum {max}")]
|
||||||
|
TooLong {
|
||||||
|
/// Actual length in bytes.
|
||||||
|
len: usize,
|
||||||
|
/// The enforced maximum.
|
||||||
|
max: usize,
|
||||||
|
},
|
||||||
|
/// The label contained an ASCII control character.
|
||||||
|
#[error("label contains a control character at byte {pos}")]
|
||||||
|
ControlChar {
|
||||||
|
/// Byte offset of the offending control character.
|
||||||
|
pos: usize,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate a raw label: non-empty, bounded length, no control characters.
|
||||||
|
pub(crate) fn validate_label(raw: &str) -> Result<(), LabelError> {
|
||||||
|
if raw.is_empty() {
|
||||||
|
return Err(LabelError::Empty);
|
||||||
|
}
|
||||||
|
if raw.len() > MAX_LABEL_LEN {
|
||||||
|
return Err(LabelError::TooLong {
|
||||||
|
len: raw.len(),
|
||||||
|
max: MAX_LABEL_LEN,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if let Some(pos) = raw.bytes().position(|b| b.is_ascii_control()) {
|
||||||
|
return Err(LabelError::ControlChar { pos });
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A validated, bounded capability tag describing one phenomenon a sensor can
|
||||||
|
/// observe (e.g. `"amplitude"`, `"range"`, `"accel"`). Reuses the ontology's
|
||||||
|
/// id-style validation discipline rather than accepting a raw `String`.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
|
||||||
|
#[serde(transparent)]
|
||||||
|
pub struct CapabilityTag(String);
|
||||||
|
|
||||||
|
impl CapabilityTag {
|
||||||
|
/// Construct a validated tag, rejecting empty, over-long, or
|
||||||
|
/// control-character input at the boundary.
|
||||||
|
pub fn new(raw: impl Into<String>) -> Result<Self, LabelError> {
|
||||||
|
let s = raw.into();
|
||||||
|
validate_label(&s)?;
|
||||||
|
Ok(Self(s))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Borrow the underlying tag string.
|
||||||
|
#[must_use]
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl core::fmt::Display for CapabilityTag {
|
||||||
|
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
|
||||||
|
f.write_str(&self.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
343
v2/crates/ruview-hal/src/lib.rs
Normal file
343
v2/crates/ruview-hal/src/lib.rs
Normal file
@@ -0,0 +1,343 @@
|
|||||||
|
//! # `ruview-hal` — the RuView sensor HAL (ADR-320, ADR-300 primitive 20)
|
||||||
|
//!
|
||||||
|
//! One hardware abstraction that maps **any** sensing modality — {CSI, 802.11bf,
|
||||||
|
//! BLE, UWB, mmWave, acoustic, camera, lidar, IMU, custom} — onto one canonical
|
||||||
|
//! [`Observation`](ruview_ontology::Observation) feeding one world model. This
|
||||||
|
//! is the boundary that turns RuView from a WiFi-CSI pipeline into an open
|
||||||
|
//! spatial-intelligence ingest layer: the world model never sees a
|
||||||
|
//! modality-specific frame, only a provenance-bearing, evidence-labelled
|
||||||
|
//! observation.
|
||||||
|
//!
|
||||||
|
//! This crate consumes the canonical ontology (ADR-306) — its output is an
|
||||||
|
//! ontology `Observation` bound to a `Sensor` — and its observations feed real
|
||||||
|
//! sensor fusion (ADR-311). It is a **pure abstraction**: no I/O, no async, no
|
||||||
|
//! inference, no accuracy claim. A passing trait test proves the abstraction,
|
||||||
|
//! not a fielded device; hardware support for any modality stays CLAIMED until
|
||||||
|
//! demonstrated on real silicon with captured evidence (CLAUDE.md).
|
||||||
|
//!
|
||||||
|
//! ## The four ADR-300 non-negotiable rules, as they bind this crate
|
||||||
|
//!
|
||||||
|
//! 1. **UNKNOWN is first-class, never an error.** [`SensorHal::normalize`] is
|
||||||
|
//! infallible: malformed / out-of-bounds raw input yields an UNKNOWN-flagged
|
||||||
|
//! ([`Uncertainty::degraded`]) observation, never a panic or `Err`.
|
||||||
|
//! 2. **Certificates bind cryptographically.** Out of scope for the HAL, but a
|
||||||
|
//! device is authenticated as an ADR-305 `Sensor` (the descriptor's
|
||||||
|
//! `sensor_id`) before its observations are trusted.
|
||||||
|
//! 3. **One canonical semantics downstream.** The HAL reuses the ontology's
|
||||||
|
//! `Observation`, `Sensor`, `EvidenceLevel`, and `SemanticProvenance` rather
|
||||||
|
//! than reinventing per-crate shapes; [`HalObservation`] *wraps* the
|
||||||
|
//! canonical observation and delegates its evidence/provenance accessors.
|
||||||
|
//! 4. **Honest evidence.** A camera-derived and a CSI-derived observation are
|
||||||
|
//! the same type with different provenance; neither is lifted to the other's
|
||||||
|
//! grade. The reference adapters are SYNTHETIC / [`EvidenceLevel::L0`] and
|
||||||
|
//! cannot alias to a measured level.
|
||||||
|
//!
|
||||||
|
//! ## Core shapes
|
||||||
|
//!
|
||||||
|
//! - [`Modality`] — the phenomenon class (closed variants + `Custom`).
|
||||||
|
//! - [`SensorDescriptor`] / [`SamplingSpec`] — canonical device description with
|
||||||
|
//! ontology `SensorId`, capability tags, and native sampling/units metadata.
|
||||||
|
//! - [`HalObservation`] — wraps [`Observation`](ruview_ontology::Observation)
|
||||||
|
//! with a [`Modality`] and per-observation [`Uncertainty`]; delegates
|
||||||
|
//! `EvidenceLevel` / `SemanticProvenance`.
|
||||||
|
//! - [`SensorHal`] — the extension-point trait: `describe` + `normalize`.
|
||||||
|
//! - [`SyntheticCsiAdapter`], [`SyntheticImuAdapter`] — deterministic reference
|
||||||
|
//! adapters (one RF, one non-RF), labelled SYNTHETIC / L0.
|
||||||
|
//!
|
||||||
|
//! ## Mapping existing adapters onto the trait (docs only)
|
||||||
|
//!
|
||||||
|
//! This crate does not rewrite the existing producers; it is the trait they are
|
||||||
|
//! re-expressed as. RF modalities reuse the ADR-279 per-device latent adapters
|
||||||
|
//! wholesale — the HAL adds the non-RF and ranging modalities under the same
|
||||||
|
//! trait. Each row is the `SensorHal` an existing producer implements when it
|
||||||
|
//! is brought under the abstraction:
|
||||||
|
//!
|
||||||
|
//! | Existing producer | Source ADR | `Modality` | `SensorHal::Raw` (native frame) | Notes |
|
||||||
|
//! |---|---|---|---|---|
|
||||||
|
//! | ESP32-S3/C6 CSI node | ADR-279 / firmware | [`Modality::Csi`] | `RfFrameV2` (subcarrier complex) | Reuses the ADR-279 native-frame → shared-latent adapter; the HAL only lifts the latent into an `Observation`. |
|
||||||
|
//! | Nexmon CSI | ADR-279 | [`Modality::Csi`] | `RfFrameV2` | Per-device adapter into the shared latent; same trait, different native layout. |
|
||||||
|
//! | FeitCSI / Intel / Atheros / Realtek | ADR-279 | [`Modality::Csi`] | `RfFrameV2` | Same shared-latent path; bandwidth/antenna structure kept native, not canonicalized. |
|
||||||
|
//! | 802.11bf sensing | ADR-310 (phase 2) | [`Modality::Ieee80211bf`] | native 11bf measurement frame | Enters under the same trait as it lands. |
|
||||||
|
//! | mmWave radar | ADR-063 | [`Modality::Mmwave`] | range-doppler / point frame | The ADR-063 fusion producer becomes a `SensorHal` implementation. |
|
||||||
|
//! | Multistatic WiFi | ADR-029 | [`Modality::Csi`] | multi-link `RfFrameV2` set | Multiple links, one authenticated `Sensor`, one `Observation`. |
|
||||||
|
//!
|
||||||
|
//! Non-RF modalities (camera, lidar, acoustic) enter the same governed plane
|
||||||
|
//! with the same provenance and privacy discipline — a camera is not a
|
||||||
|
//! privacy-free shortcut; it inherits ADR-277 governance and carries its own
|
||||||
|
//! honest evidence level. The two synthetic reference adapters in this crate
|
||||||
|
//! ([`SyntheticCsiAdapter`], [`SyntheticImuAdapter`]) are the executable
|
||||||
|
//! template such implementations follow.
|
||||||
|
|
||||||
|
#![forbid(unsafe_code)]
|
||||||
|
#![warn(missing_docs)]
|
||||||
|
|
||||||
|
mod adapter;
|
||||||
|
mod descriptor;
|
||||||
|
mod label;
|
||||||
|
mod modality;
|
||||||
|
mod observation;
|
||||||
|
|
||||||
|
/// The provenance calibration handle that marks an observation SYNTHETIC. A
|
||||||
|
/// synthetic observation stamped with this handle can never present as
|
||||||
|
/// measured/calibrated (ADR-279 invariant 6; CLAUDE.md honesty discipline).
|
||||||
|
pub const SYNTHETIC_CALIBRATION: &str = "synthetic";
|
||||||
|
|
||||||
|
pub use adapter::{
|
||||||
|
synthetic_provenance, CsiSample, ImuSample, NormalizeCtx, SensorHal, SyntheticCsiAdapter,
|
||||||
|
SyntheticImuAdapter, MAX_CSI_TAPS,
|
||||||
|
};
|
||||||
|
pub use descriptor::{SamplingSpec, SensorDescriptor};
|
||||||
|
pub use label::{CapabilityTag, LabelError, MAX_LABEL_LEN};
|
||||||
|
pub use modality::Modality;
|
||||||
|
pub use observation::{Confidence, HalObservation, Uncertainty};
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use ruview_ontology::{Container, EvidenceLevel, ObservationId, SensorId, SpaceId};
|
||||||
|
|
||||||
|
fn ctx() -> NormalizeCtx {
|
||||||
|
NormalizeCtx {
|
||||||
|
observation_id: ObservationId::new("obs-1").unwrap(),
|
||||||
|
located_in: Container::Space {
|
||||||
|
id: SpaceId::new("kitchen").unwrap(),
|
||||||
|
},
|
||||||
|
at_unix_ms: 1_700_000_000_000,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn csi_adapter() -> SyntheticCsiAdapter {
|
||||||
|
SyntheticCsiAdapter {
|
||||||
|
sensor_id: SensorId::new("csi-1").unwrap(),
|
||||||
|
subcarriers: 52,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn imu_adapter() -> SyntheticImuAdapter {
|
||||||
|
SyntheticImuAdapter {
|
||||||
|
sensor_id: SensorId::new("imu-1").unwrap(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn good_csi() -> CsiSample {
|
||||||
|
CsiSample {
|
||||||
|
amplitudes: vec![1.0, 2.0, 3.0, 4.0],
|
||||||
|
phases: vec![0.1, 0.2, 0.3, 0.4],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ADR-320 validation: descriptor round-trips losslessly through serde.
|
||||||
|
#[test]
|
||||||
|
fn descriptor_round_trip() {
|
||||||
|
for descriptor in [csi_adapter().describe(), imu_adapter().describe()] {
|
||||||
|
let json = serde_json::to_string(&descriptor).unwrap();
|
||||||
|
let back: SensorDescriptor = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(descriptor, back);
|
||||||
|
assert!(descriptor.validate().is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
// A custom modality descriptor also round-trips and re-validates.
|
||||||
|
let custom = SensorDescriptor {
|
||||||
|
sensor_id: SensorId::new("x-1").unwrap(),
|
||||||
|
modality: Modality::custom("thermal-array").unwrap(),
|
||||||
|
capabilities: vec![CapabilityTag::new("temperature").unwrap()],
|
||||||
|
sampling: SamplingSpec {
|
||||||
|
sample_rate_hz: None,
|
||||||
|
unit: "celsius".into(),
|
||||||
|
dimensions: 64,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let back: SensorDescriptor =
|
||||||
|
serde_json::from_str(&serde_json::to_string(&custom).unwrap()).unwrap();
|
||||||
|
assert_eq!(custom, back);
|
||||||
|
assert!(back.validate().is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ADR-320 validation: a reference adapter normalizes a synthetic sample to a
|
||||||
|
// uniform HalObservation carrying sensor id, container, time, exactly one
|
||||||
|
// evidence level, and provenance.
|
||||||
|
#[test]
|
||||||
|
fn reference_adapter_normalizes_synthetic_sample() {
|
||||||
|
let a = csi_adapter();
|
||||||
|
let obs = a.normalize(good_csi(), &ctx());
|
||||||
|
|
||||||
|
assert_eq!(obs.modality, Modality::Csi);
|
||||||
|
assert_eq!(obs.sensor().as_str(), "csi-1");
|
||||||
|
assert_eq!(obs.observation.located_in, ctx().located_in);
|
||||||
|
assert_eq!(obs.observation.at_unix_ms, 1_700_000_000_000);
|
||||||
|
assert_eq!(obs.evidence_level(), EvidenceLevel::L0);
|
||||||
|
assert!(obs.is_synthetic());
|
||||||
|
assert!(!obs.is_unknown());
|
||||||
|
assert!(!obs.uncertainty.degraded);
|
||||||
|
|
||||||
|
// The non-RF adapter produces the *same* type with its own provenance.
|
||||||
|
let imu = imu_adapter().normalize(
|
||||||
|
ImuSample {
|
||||||
|
accel: [0.0, 0.0, 9.81],
|
||||||
|
gyro: [0.0, 0.0, 0.0],
|
||||||
|
},
|
||||||
|
&ctx(),
|
||||||
|
);
|
||||||
|
assert_eq!(imu.modality, Modality::Imu);
|
||||||
|
assert_eq!(imu.evidence_level(), EvidenceLevel::L0);
|
||||||
|
assert!(imu.is_synthetic());
|
||||||
|
assert!(!imu.is_unknown());
|
||||||
|
// Honest evidence: synthetic never reaches a measured/corroborated level.
|
||||||
|
assert!(imu.evidence_level() < EvidenceLevel::L2);
|
||||||
|
assert_eq!(imu.provenance().model_version, "synthetic-imu-adapter@0");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ADR-320 validation: unknown / degraded input yields an UNKNOWN-flagged
|
||||||
|
// observation, never a panic.
|
||||||
|
#[test]
|
||||||
|
fn degraded_input_yields_unknown_not_panic() {
|
||||||
|
let a = csi_adapter();
|
||||||
|
|
||||||
|
// Empty frame.
|
||||||
|
let empty = a.normalize(
|
||||||
|
CsiSample {
|
||||||
|
amplitudes: vec![],
|
||||||
|
phases: vec![],
|
||||||
|
},
|
||||||
|
&ctx(),
|
||||||
|
);
|
||||||
|
assert!(empty.is_unknown());
|
||||||
|
assert!(empty.uncertainty.degraded);
|
||||||
|
assert_eq!(empty.uncertainty.confidence, Confidence::Unknown);
|
||||||
|
// Still a well-formed canonical observation.
|
||||||
|
assert_eq!(empty.sensor().as_str(), "csi-1");
|
||||||
|
assert_eq!(empty.evidence_level(), EvidenceLevel::L0);
|
||||||
|
// Cannot alias to measured.
|
||||||
|
assert!(empty.evidence_level() < EvidenceLevel::L2);
|
||||||
|
|
||||||
|
// Length mismatch.
|
||||||
|
let mismatch = a.normalize(
|
||||||
|
CsiSample {
|
||||||
|
amplitudes: vec![1.0, 2.0],
|
||||||
|
phases: vec![0.1],
|
||||||
|
},
|
||||||
|
&ctx(),
|
||||||
|
);
|
||||||
|
assert!(mismatch.is_unknown());
|
||||||
|
|
||||||
|
// Non-finite (NaN) input.
|
||||||
|
let nan = a.normalize(
|
||||||
|
CsiSample {
|
||||||
|
amplitudes: vec![f32::NAN, 1.0, 2.0, 3.0],
|
||||||
|
phases: vec![0.0, 0.0, 0.0, 0.0],
|
||||||
|
},
|
||||||
|
&ctx(),
|
||||||
|
);
|
||||||
|
assert!(nan.is_unknown());
|
||||||
|
|
||||||
|
// Over-bounded input is rejected as degraded, bounding compute.
|
||||||
|
let huge = a.normalize(
|
||||||
|
CsiSample {
|
||||||
|
amplitudes: vec![1.0; MAX_CSI_TAPS + 1],
|
||||||
|
phases: vec![0.0; MAX_CSI_TAPS + 1],
|
||||||
|
},
|
||||||
|
&ctx(),
|
||||||
|
);
|
||||||
|
assert!(huge.is_unknown());
|
||||||
|
|
||||||
|
// IMU with an infinite gyro component.
|
||||||
|
let imu = imu_adapter().normalize(
|
||||||
|
ImuSample {
|
||||||
|
accel: [0.0, 0.0, 9.81],
|
||||||
|
gyro: [f32::INFINITY, 0.0, 0.0],
|
||||||
|
},
|
||||||
|
&ctx(),
|
||||||
|
);
|
||||||
|
assert!(imu.is_unknown());
|
||||||
|
assert!(imu.uncertainty.degraded);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Malformed labels are rejected at the boundary, not panicked on.
|
||||||
|
#[test]
|
||||||
|
fn label_validation_at_boundary() {
|
||||||
|
assert_eq!(CapabilityTag::new(""), Err(LabelError::Empty));
|
||||||
|
assert!(matches!(
|
||||||
|
CapabilityTag::new("a\nb"),
|
||||||
|
Err(LabelError::ControlChar { pos: 1 })
|
||||||
|
));
|
||||||
|
let long = "x".repeat(MAX_LABEL_LEN + 1);
|
||||||
|
assert!(matches!(
|
||||||
|
Modality::custom(long),
|
||||||
|
Err(LabelError::TooLong { .. })
|
||||||
|
));
|
||||||
|
// Closed variants always validate; a well-formed custom validates.
|
||||||
|
assert!(Modality::Camera.validate().is_ok());
|
||||||
|
assert!(Modality::custom("thermal").unwrap().validate().is_ok());
|
||||||
|
assert!(Modality::Csi.is_rf());
|
||||||
|
assert!(!Modality::Imu.is_rf());
|
||||||
|
assert_eq!(Modality::Ieee80211bf.label(), "ieee80211bf");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serde round-trips a HalObservation (both known and unknown) losslessly.
|
||||||
|
#[test]
|
||||||
|
fn hal_observation_serde_round_trip() {
|
||||||
|
let known = csi_adapter().normalize(good_csi(), &ctx());
|
||||||
|
let back: HalObservation =
|
||||||
|
serde_json::from_str(&serde_json::to_string(&known).unwrap()).unwrap();
|
||||||
|
assert_eq!(known, back);
|
||||||
|
|
||||||
|
let unknown = csi_adapter().normalize(
|
||||||
|
CsiSample {
|
||||||
|
amplitudes: vec![],
|
||||||
|
phases: vec![],
|
||||||
|
},
|
||||||
|
&ctx(),
|
||||||
|
);
|
||||||
|
let back: HalObservation =
|
||||||
|
serde_json::from_str(&serde_json::to_string(&unknown).unwrap()).unwrap();
|
||||||
|
assert_eq!(unknown, back);
|
||||||
|
|
||||||
|
// Modality serializes to its canonical tag; UNKNOWN confidence to a
|
||||||
|
// stable string.
|
||||||
|
let json = serde_json::to_string(&unknown).unwrap();
|
||||||
|
assert!(json.contains("\"csi\""));
|
||||||
|
assert!(json.contains("\"unknown\""));
|
||||||
|
assert!(json.contains("\"L0\""));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalization is deterministic: identical input + ctx → identical output.
|
||||||
|
#[test]
|
||||||
|
fn normalization_is_deterministic() {
|
||||||
|
let a = csi_adapter();
|
||||||
|
let c = ctx();
|
||||||
|
assert_eq!(a.normalize(good_csi(), &c), a.normalize(good_csi(), &c));
|
||||||
|
|
||||||
|
let imu = imu_adapter();
|
||||||
|
let s = ImuSample {
|
||||||
|
accel: [1.0, 2.0, 9.0],
|
||||||
|
gyro: [0.01, 0.02, 0.03],
|
||||||
|
};
|
||||||
|
assert_eq!(imu.normalize(s.clone(), &c), imu.normalize(s, &c));
|
||||||
|
}
|
||||||
|
|
||||||
|
// A synthetic observation cannot be constructed as measured/calibrated: the
|
||||||
|
// synthetic calibration handle and L0 evidence pin it below corroboration.
|
||||||
|
#[test]
|
||||||
|
fn synthetic_cannot_alias_to_measured() {
|
||||||
|
let obs = csi_adapter().normalize(good_csi(), &ctx());
|
||||||
|
assert!(obs.is_synthetic());
|
||||||
|
assert_eq!(
|
||||||
|
obs.provenance().calibration_version,
|
||||||
|
SYNTHETIC_CALIBRATION
|
||||||
|
);
|
||||||
|
assert!(obs.evidence_level() < EvidenceLevel::L2);
|
||||||
|
assert_ne!(obs.evidence_level(), EvidenceLevel::L4);
|
||||||
|
assert_ne!(obs.evidence_level(), EvidenceLevel::L5);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confidence clamps and collapses non-finite values rather than poisoning.
|
||||||
|
#[test]
|
||||||
|
fn confidence_is_bounded() {
|
||||||
|
assert_eq!(Confidence::known(2.0), Confidence::Known(1.0));
|
||||||
|
assert_eq!(Confidence::known(-1.0), Confidence::Known(0.0));
|
||||||
|
assert_eq!(Confidence::known(f64::NAN), Confidence::Unknown);
|
||||||
|
assert!(Uncertainty::unknown().is_unknown());
|
||||||
|
assert!(!Uncertainty::unknown().degraded);
|
||||||
|
assert!(Uncertainty::degraded().degraded);
|
||||||
|
}
|
||||||
|
}
|
||||||
91
v2/crates/ruview-hal/src/modality.rs
Normal file
91
v2/crates/ruview-hal/src/modality.rs
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
//! The sensing modality tag (ADR-320 §1).
|
||||||
|
//!
|
||||||
|
//! [`Modality`] enumerates the phenomenon class a sensor measures. It is the
|
||||||
|
//! only place the pipeline distinguishes "how the world was sensed"; every
|
||||||
|
//! modality flows through the same [`SensorHal`](crate::SensorHal) trait into
|
||||||
|
//! the same canonical [`Observation`](ruview_ontology::Observation), so the
|
||||||
|
//! world model never branches on a modality-specific frame shape (ADR-300 rule
|
||||||
|
//! 3: one canonical semantics downstream).
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::label::{validate_label, LabelError};
|
||||||
|
|
||||||
|
/// The class of physical phenomenon a sensor observes.
|
||||||
|
///
|
||||||
|
/// The closed variants cover the modalities named in ADR-320; [`Modality::Custom`]
|
||||||
|
/// is the open extension point for a modality not yet enumerated, carrying a
|
||||||
|
/// validated free-form label. `Custom` is validated with [`Modality::custom`]
|
||||||
|
/// (or [`Modality::validate`]) at the boundary.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum Modality {
|
||||||
|
/// WiFi channel-state information (ESP32/Nexmon/FeitCSI via ADR-279).
|
||||||
|
Csi,
|
||||||
|
/// IEEE 802.11bf native sensing (ADR-310, phase 2).
|
||||||
|
Ieee80211bf,
|
||||||
|
/// Bluetooth Low Energy ranging / RSSI.
|
||||||
|
Ble,
|
||||||
|
/// Ultra-wideband ranging.
|
||||||
|
Uwb,
|
||||||
|
/// Millimetre-wave radar (ADR-063).
|
||||||
|
Mmwave,
|
||||||
|
/// Acoustic / ultrasonic sensing.
|
||||||
|
Acoustic,
|
||||||
|
/// Optical camera.
|
||||||
|
Camera,
|
||||||
|
/// Lidar point cloud.
|
||||||
|
Lidar,
|
||||||
|
/// Inertial measurement unit (accelerometer + gyroscope).
|
||||||
|
Imu,
|
||||||
|
/// An open-ended modality carrying a validated label.
|
||||||
|
Custom(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Modality {
|
||||||
|
/// Construct a validated [`Modality::Custom`], rejecting empty, over-long,
|
||||||
|
/// or control-character labels at the boundary.
|
||||||
|
pub fn custom(raw: impl Into<String>) -> Result<Self, LabelError> {
|
||||||
|
let s = raw.into();
|
||||||
|
validate_label(&s)?;
|
||||||
|
Ok(Self::Custom(s))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-validate a modality received from an untrusted source (e.g. after
|
||||||
|
/// deserialization). Closed variants are always valid; a `Custom` payload
|
||||||
|
/// must satisfy the label bounds.
|
||||||
|
pub fn validate(&self) -> Result<(), LabelError> {
|
||||||
|
match self {
|
||||||
|
Self::Custom(s) => validate_label(s),
|
||||||
|
_ => Ok(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A stable lowercase label for this modality, matching its serialized tag.
|
||||||
|
/// For [`Modality::Custom`] this is the inner label.
|
||||||
|
#[must_use]
|
||||||
|
pub fn label(&self) -> &str {
|
||||||
|
match self {
|
||||||
|
Self::Csi => "csi",
|
||||||
|
Self::Ieee80211bf => "ieee80211bf",
|
||||||
|
Self::Ble => "ble",
|
||||||
|
Self::Uwb => "uwb",
|
||||||
|
Self::Mmwave => "mmwave",
|
||||||
|
Self::Acoustic => "acoustic",
|
||||||
|
Self::Camera => "camera",
|
||||||
|
Self::Lidar => "lidar",
|
||||||
|
Self::Imu => "imu",
|
||||||
|
Self::Custom(s) => s,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True for radio-frequency modalities, which reuse the ADR-279 native RF
|
||||||
|
/// frame / shared-latent adapters wholesale.
|
||||||
|
#[must_use]
|
||||||
|
pub fn is_rf(&self) -> bool {
|
||||||
|
matches!(
|
||||||
|
self,
|
||||||
|
Self::Csi | Self::Ieee80211bf | Self::Ble | Self::Uwb | Self::Mmwave
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
156
v2/crates/ruview-hal/src/observation.rs
Normal file
156
v2/crates/ruview-hal/src/observation.rs
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
//! The HAL observation (ADR-320 §2): a canonical observation plus HAL context.
|
||||||
|
//!
|
||||||
|
//! [`HalObservation`] wraps the canonical ontology
|
||||||
|
//! [`Observation`](ruview_ontology::Observation) — reusing it rather than
|
||||||
|
//! reinventing a per-crate shape (ADR-300 rule 3) — and adds the two pieces the
|
||||||
|
//! HAL boundary contributes: the [`Modality`] the measurement came through and
|
||||||
|
//! a per-observation [`Uncertainty`]. The ontology `Observation` already
|
||||||
|
//! carries the mandatory `EvidenceLevel` and `SemanticProvenance`, so those
|
||||||
|
//! travel with the fact and are surfaced here by delegating accessors — never
|
||||||
|
//! duplicated or allowed to diverge.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use ruview_ontology::{EvidenceLevel, Observation, SemanticProvenance, SensorId};
|
||||||
|
|
||||||
|
use crate::modality::Modality;
|
||||||
|
|
||||||
|
/// A confidence value that is either a bounded scalar or first-class UNKNOWN.
|
||||||
|
///
|
||||||
|
/// UNKNOWN is a value, never an error (ADR-300 rule 1): a source that cannot
|
||||||
|
/// quantify its confidence says so and stays legible rather than defaulting to
|
||||||
|
/// a confident number.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum Confidence {
|
||||||
|
/// No confidence can be assigned.
|
||||||
|
Unknown,
|
||||||
|
/// A confidence in the closed unit interval `[0.0, 1.0]`.
|
||||||
|
Known(f64),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Confidence {
|
||||||
|
/// Construct a `Known` confidence, clamping into `[0.0, 1.0]`. A non-finite
|
||||||
|
/// input (NaN/inf) collapses to [`Confidence::Unknown`] rather than
|
||||||
|
/// propagating a poisoned value.
|
||||||
|
#[must_use]
|
||||||
|
pub fn known(value: f64) -> Self {
|
||||||
|
if value.is_finite() {
|
||||||
|
Self::Known(value.clamp(0.0, 1.0))
|
||||||
|
} else {
|
||||||
|
Self::Unknown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True when this is [`Confidence::Unknown`].
|
||||||
|
#[must_use]
|
||||||
|
pub fn is_unknown(&self) -> bool {
|
||||||
|
matches!(self, Self::Unknown)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-observation uncertainty carried alongside the canonical observation.
|
||||||
|
///
|
||||||
|
/// `degraded` distinguishes a *legitimately* unquantifiable source (`degraded
|
||||||
|
/// = false`) from one whose raw input was malformed and yielded a best-effort
|
||||||
|
/// UNKNOWN placeholder (`degraded = true`). Neither is an error.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct Uncertainty {
|
||||||
|
/// The confidence, or UNKNOWN.
|
||||||
|
pub confidence: Confidence,
|
||||||
|
/// True when the observation is an UNKNOWN placeholder produced from
|
||||||
|
/// malformed / out-of-bounds raw input rather than a real measurement.
|
||||||
|
pub degraded: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Uncertainty {
|
||||||
|
/// A first-class UNKNOWN with a bounded, non-degraded source (e.g. an
|
||||||
|
/// event-driven sensor that simply does not quantify confidence).
|
||||||
|
#[must_use]
|
||||||
|
pub fn unknown() -> Self {
|
||||||
|
Self {
|
||||||
|
confidence: Confidence::Unknown,
|
||||||
|
degraded: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An UNKNOWN produced because the raw input was malformed or exceeded the
|
||||||
|
/// adapter's bounds. Flagged `degraded` so downstream fusion can weight or
|
||||||
|
/// drop it, but still a well-formed observation, not a panic or error.
|
||||||
|
#[must_use]
|
||||||
|
pub fn degraded() -> Self {
|
||||||
|
Self {
|
||||||
|
confidence: Confidence::Unknown,
|
||||||
|
degraded: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A quantified uncertainty from a valid sample.
|
||||||
|
#[must_use]
|
||||||
|
pub fn known(confidence: f64) -> Self {
|
||||||
|
Self {
|
||||||
|
confidence: Confidence::known(confidence),
|
||||||
|
degraded: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True when the confidence is UNKNOWN (for any reason).
|
||||||
|
#[must_use]
|
||||||
|
pub fn is_unknown(&self) -> bool {
|
||||||
|
self.confidence.is_unknown()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A canonical observation as it crosses the HAL boundary.
|
||||||
|
///
|
||||||
|
/// The inner [`Observation`] is the single downstream representation; `modality`
|
||||||
|
/// and `uncertainty` are the HAL's added context. A camera-derived and a
|
||||||
|
/// CSI-derived `HalObservation` are the same type with different provenance and
|
||||||
|
/// evidence — neither is lifted to the other's grade (CLAUDE.md: never present
|
||||||
|
/// WiFi sensing as camera-grade).
|
||||||
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct HalObservation {
|
||||||
|
/// The modality this measurement was sensed through.
|
||||||
|
pub modality: Modality,
|
||||||
|
/// Per-observation uncertainty (possibly UNKNOWN).
|
||||||
|
pub uncertainty: Uncertainty,
|
||||||
|
/// The canonical ontology observation this HAL sample maps onto.
|
||||||
|
pub observation: Observation,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HalObservation {
|
||||||
|
/// The evidence level of the underlying observation (ADR-282).
|
||||||
|
#[must_use]
|
||||||
|
pub fn evidence_level(&self) -> EvidenceLevel {
|
||||||
|
self.observation.evidence_level
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The provenance of the underlying observation.
|
||||||
|
#[must_use]
|
||||||
|
pub fn provenance(&self) -> &SemanticProvenance {
|
||||||
|
&self.observation.provenance
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The authenticated sensor identity that produced this observation.
|
||||||
|
#[must_use]
|
||||||
|
pub fn sensor(&self) -> &SensorId {
|
||||||
|
&self.observation.sensor
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True when this observation carries UNKNOWN uncertainty.
|
||||||
|
#[must_use]
|
||||||
|
pub fn is_unknown(&self) -> bool {
|
||||||
|
self.uncertainty.is_unknown()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True when this observation was produced by a synthetic source, marked by
|
||||||
|
/// its provenance calibration handle. A synthetic observation can never
|
||||||
|
/// alias to a measured/calibrated one (ADR-279 invariant 6): the reference
|
||||||
|
/// adapters always emit [`EvidenceLevel::L0`] with a synthetic calibration
|
||||||
|
/// handle, which cannot reach the corroborated/calibrated levels
|
||||||
|
/// (`>= L2`).
|
||||||
|
#[must_use]
|
||||||
|
pub fn is_synthetic(&self) -> bool {
|
||||||
|
self.observation.provenance.calibration_version == crate::SYNTHETIC_CALIBRATION
|
||||||
|
}
|
||||||
|
}
|
||||||
16
v2/crates/ruview-infogain/Cargo.toml
Normal file
16
v2/crates/ruview-infogain/Cargo.toml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
[package]
|
||||||
|
name = "ruview-infogain"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
authors.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
thiserror.workspace = true
|
||||||
|
serde = { workspace = true, features = ["derive"] }
|
||||||
|
ruview-ontology = { path = "../ruview-ontology" }
|
||||||
|
ruview-hal = { path = "../ruview-hal" }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
serde_json.workspace = true
|
||||||
106
v2/crates/ruview-infogain/src/candidate.rs
Normal file
106
v2/crates/ruview-infogain/src/candidate.rs
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
//! Candidate sensor actions the scheduler ranks (ADR-314 §1).
|
||||||
|
//!
|
||||||
|
//! **SYNTHETIC / L0 scaffold (ADR-282).** An [`ExpectedReduction`] is a *model
|
||||||
|
//! prediction* of how much a not-yet-taken measurement would shrink the fused
|
||||||
|
//! covariance — in a fielded system it comes from the ADR-315 RF-twin forward
|
||||||
|
//! model evaluated against the ADR-311 covariance. It is never a measured
|
||||||
|
//! quantity: a value-of-information estimate made *before* paying for the
|
||||||
|
//! measurement. No accuracy claim is made.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use ruview_hal::Modality;
|
||||||
|
use ruview_ontology::SensorId;
|
||||||
|
|
||||||
|
use crate::cost::Cost;
|
||||||
|
|
||||||
|
/// The predicted uncertainty reduction of taking one candidate measurement,
|
||||||
|
/// with UNKNOWN as a first-class value (ADR-300 rule 1).
|
||||||
|
///
|
||||||
|
/// A candidate whose informativeness the forward model cannot predict is
|
||||||
|
/// [`ExpectedReduction::Unknown`] — it is **not** silently treated as zero. The
|
||||||
|
/// scheduler's [`UnknownPolicy`](crate::UnknownPolicy) decides whether such a
|
||||||
|
/// candidate is probed (to *learn* its informativeness) or deferred; either way
|
||||||
|
/// the choice is explicit.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum ExpectedReduction {
|
||||||
|
/// A predicted, non-negative uncertainty reduction on the ADR-302 objective.
|
||||||
|
Known(f64),
|
||||||
|
/// The forward model cannot predict this candidate's informativeness.
|
||||||
|
Unknown,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ExpectedReduction {
|
||||||
|
/// Construct a [`Known`](ExpectedReduction::Known) reduction from a raw
|
||||||
|
/// prediction, sanitizing at the boundary: a non-finite prediction becomes
|
||||||
|
/// [`Unknown`](ExpectedReduction::Unknown) (honest, per rule 1), and a
|
||||||
|
/// negative prediction — uncertainty cannot be *increased* by sampling — is
|
||||||
|
/// clamped to `0.0`.
|
||||||
|
#[must_use]
|
||||||
|
pub fn known(raw: f64) -> Self {
|
||||||
|
if !raw.is_finite() {
|
||||||
|
Self::Unknown
|
||||||
|
} else {
|
||||||
|
Self::Known(raw.max(0.0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The predicted reduction if known, else `None`.
|
||||||
|
#[must_use]
|
||||||
|
pub fn value(&self) -> Option<f64> {
|
||||||
|
match self {
|
||||||
|
Self::Known(v) => Some(*v),
|
||||||
|
Self::Unknown => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True when the informativeness is unknown.
|
||||||
|
#[must_use]
|
||||||
|
pub fn is_unknown(&self) -> bool {
|
||||||
|
matches!(self, Self::Unknown)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One candidate sensor action the scheduler may spend budget on.
|
||||||
|
///
|
||||||
|
/// It names the radio/[`Modality`] to sample, the modelled
|
||||||
|
/// [`ExpectedReduction`] of doing so, and the [`Cost`] triple it would consume.
|
||||||
|
/// [`cycles_since_sampled`](SensorAction::cycles_since_sampled) is caller-
|
||||||
|
/// supplied staleness that feeds the sampling floor — the scheduler is a pure
|
||||||
|
/// function of its inputs and holds no cross-cycle state of its own.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct SensorAction {
|
||||||
|
/// The authenticated sensor identity (ADR-305) this action would sample.
|
||||||
|
pub sensor: SensorId,
|
||||||
|
/// The sensing modality of that sensor (ADR-320).
|
||||||
|
pub modality: Modality,
|
||||||
|
/// Modelled expected uncertainty reduction of taking the measurement.
|
||||||
|
pub expected_reduction: ExpectedReduction,
|
||||||
|
/// Modelled cost triple the action would consume.
|
||||||
|
pub cost: Cost,
|
||||||
|
/// Cycles since this sensor was last sampled, supplied by the caller. Feeds
|
||||||
|
/// the sampling floor so a currently-low-value sensor is not starved into
|
||||||
|
/// permanent blindness (ADR-314 §2). `0` means "sampled last cycle".
|
||||||
|
#[serde(default)]
|
||||||
|
pub cycles_since_sampled: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SensorAction {
|
||||||
|
/// Convenience constructor with `cycles_since_sampled = 0`.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(
|
||||||
|
sensor: SensorId,
|
||||||
|
modality: Modality,
|
||||||
|
expected_reduction: ExpectedReduction,
|
||||||
|
cost: Cost,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
sensor,
|
||||||
|
modality,
|
||||||
|
expected_reduction,
|
||||||
|
cost,
|
||||||
|
cycles_since_sampled: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
131
v2/crates/ruview-infogain/src/cost.rs
Normal file
131
v2/crates/ruview-infogain/src/cost.rs
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
//! Cost descriptors and the deployment cost policy (ADR-314 §1).
|
||||||
|
//!
|
||||||
|
//! **SYNTHETIC / L0 scaffold (ADR-282).** Every quantity here is a *modelled*
|
||||||
|
//! resource figure supplied by the caller (in a fielded system, read from the
|
||||||
|
//! ADR-320 HAL descriptors); nothing in this module measures a device. No
|
||||||
|
//! `MEASURED` energy/latency/throughput claim is made or implied — a scheduler
|
||||||
|
//! predicts where budget is best spent, it does not observe hardware.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// Numerical floor for the weighted-cost denominator so a zero-cost (or nearly
|
||||||
|
/// free) action never produces a non-finite value density. It does not model a
|
||||||
|
/// physical minimum; it only keeps the division bounded and deterministic.
|
||||||
|
pub(crate) const MIN_WEIGHTED_COST: f64 = 1e-9;
|
||||||
|
|
||||||
|
/// The three scarce edge resources one sensor action is modelled to consume.
|
||||||
|
///
|
||||||
|
/// These are the ADR-314 denominator terms — compute, energy, and bandwidth —
|
||||||
|
/// the three resources ADR-314 names as scarce on the ESP32-class nodes and
|
||||||
|
/// small gateways RuView targets. Values are unitless modelled magnitudes; the
|
||||||
|
/// caller supplies them (from ADR-320 HAL descriptors in a fielded system).
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct Cost {
|
||||||
|
/// Modelled compute cost of running the action (unitless magnitude).
|
||||||
|
pub compute: f64,
|
||||||
|
/// Modelled energy cost of running the action (unitless magnitude).
|
||||||
|
pub energy: f64,
|
||||||
|
/// Modelled bandwidth cost of shipping the result (unitless magnitude).
|
||||||
|
pub bandwidth: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Cost {
|
||||||
|
/// The zero-cost triple (identity for accumulation).
|
||||||
|
pub const ZERO: Cost = Cost {
|
||||||
|
compute: 0.0,
|
||||||
|
energy: 0.0,
|
||||||
|
bandwidth: 0.0,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Construct a cost triple.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn new(compute: f64, energy: f64, bandwidth: f64) -> Self {
|
||||||
|
Self {
|
||||||
|
compute,
|
||||||
|
energy,
|
||||||
|
bandwidth,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True when every component is finite and non-negative. A malformed cost
|
||||||
|
/// (NaN/∞/negative) is not silently coerced to a number; the scheduler
|
||||||
|
/// defers such a candidate as UNKNOWN-cost rather than guessing (ADR-300
|
||||||
|
/// rule 1).
|
||||||
|
#[must_use]
|
||||||
|
pub fn is_well_formed(&self) -> bool {
|
||||||
|
[self.compute, self.energy, self.bandwidth]
|
||||||
|
.iter()
|
||||||
|
.all(|c| c.is_finite() && *c >= 0.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Component-wise sum, used to accumulate the spent budget.
|
||||||
|
#[must_use]
|
||||||
|
pub(crate) fn plus(&self, other: &Cost) -> Cost {
|
||||||
|
Cost {
|
||||||
|
compute: self.compute + other.compute,
|
||||||
|
energy: self.energy + other.energy,
|
||||||
|
bandwidth: self.bandwidth + other.bandwidth,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The deployment cost policy: how the three cost terms are weighted into one
|
||||||
|
/// scalar denominator (ADR-314 §1).
|
||||||
|
///
|
||||||
|
/// The weighting is a *deployment* choice, not a hardcoded constant: a battery
|
||||||
|
/// node weights energy heavily, a wired gateway weights bandwidth. The policy
|
||||||
|
/// is configured, never assumed.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct CostPolicy {
|
||||||
|
/// Weight applied to the compute term.
|
||||||
|
pub w_compute: f64,
|
||||||
|
/// Weight applied to the energy term.
|
||||||
|
pub w_energy: f64,
|
||||||
|
/// Weight applied to the bandwidth term.
|
||||||
|
pub w_bandwidth: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CostPolicy {
|
||||||
|
/// Equal weight on all three resources.
|
||||||
|
pub const UNIFORM: CostPolicy = CostPolicy {
|
||||||
|
w_compute: 1.0,
|
||||||
|
w_energy: 1.0,
|
||||||
|
w_bandwidth: 1.0,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Construct a policy, clamping any non-finite or negative weight to `0.0`
|
||||||
|
/// at the boundary so a malformed weight can never poison the ranking.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(w_compute: f64, w_energy: f64, w_bandwidth: f64) -> Self {
|
||||||
|
Self {
|
||||||
|
w_compute: sanitize_weight(w_compute),
|
||||||
|
w_energy: sanitize_weight(w_energy),
|
||||||
|
w_bandwidth: sanitize_weight(w_bandwidth),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Collapse a well-formed cost triple into the single scalar denominator
|
||||||
|
/// used by the value function, floored at [`MIN_WEIGHTED_COST`] so the
|
||||||
|
/// division is always finite. Callers must only pass a
|
||||||
|
/// [`Cost::is_well_formed`] triple.
|
||||||
|
#[must_use]
|
||||||
|
pub fn scalar_cost(&self, cost: &Cost) -> f64 {
|
||||||
|
let weighted =
|
||||||
|
self.w_compute * cost.compute + self.w_energy * cost.energy + self.w_bandwidth * cost.bandwidth;
|
||||||
|
weighted.max(MIN_WEIGHTED_COST)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for CostPolicy {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::UNIFORM
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sanitize_weight(w: f64) -> f64 {
|
||||||
|
if w.is_finite() && w >= 0.0 {
|
||||||
|
w
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
}
|
||||||
|
}
|
||||||
393
v2/crates/ruview-infogain/src/lib.rs
Normal file
393
v2/crates/ruview-infogain/src/lib.rs
Normal file
@@ -0,0 +1,393 @@
|
|||||||
|
//! # `ruview-infogain` — information-gain scheduler (ADR-314, ADR-300 primitive 14)
|
||||||
|
//!
|
||||||
|
//! **SYNTHETIC / L0 research-forward scaffold (ADR-282, ADR-300 phase 3).**
|
||||||
|
//! This crate models *which radios/modalities to spend the next sampling budget
|
||||||
|
//! on* by value of information. It is a **simulation/model scaffold**: every
|
||||||
|
//! informativeness estimate is a model prediction and every cost is a modelled
|
||||||
|
//! magnitude. Nothing here measures hardware, and **no** `MEASURED`, accuracy,
|
||||||
|
//! or energy/latency/throughput claim is made or implied — a twin predicts, it
|
||||||
|
//! does not measure (CLAUDE.md honesty discipline; ADR-314 asserts no
|
||||||
|
//! efficiency number). Any figure produced by this crate is `SYNTHETIC`.
|
||||||
|
//!
|
||||||
|
//! ## What it does
|
||||||
|
//!
|
||||||
|
//! With many sensors, processing every stream at full rate wastes the three
|
||||||
|
//! scarce edge resources — compute, energy, bandwidth. This scheduler assigns
|
||||||
|
//! each candidate action a value
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! Value(sensor) ≈ expected_uncertainty_reduction / (compute + energy + bandwidth)
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! and spends the budget on the highest-value actions, so the edge samples the
|
||||||
|
//! most informative radios first. In a fielded system the numerator comes from
|
||||||
|
//! the ADR-315 RF-twin forward model against the ADR-311 fused covariance and
|
||||||
|
//! the denominator from ADR-320 HAL cost descriptors; this crate takes both as
|
||||||
|
//! caller-supplied inputs and stays a pure allocator.
|
||||||
|
//!
|
||||||
|
//! ## The four ADR-300 non-negotiable rules, as they bind this crate
|
||||||
|
//!
|
||||||
|
//! 1. **UNKNOWN is first-class, never an error.** A candidate whose
|
||||||
|
//! informativeness the model cannot predict is
|
||||||
|
//! [`ExpectedReduction::Unknown`] — handled by an explicit
|
||||||
|
//! [`UnknownPolicy`] (probe or defer), **never** silently treated as zero. A
|
||||||
|
//! malformed cost is UNKNOWN cost and defers the candidate rather than
|
||||||
|
//! panicking or guessing. [`Scheduler::plan`] is total: no input panics.
|
||||||
|
//! 2. **Certificates bind cryptographically.** Out of scope here; a candidate
|
||||||
|
//! names an already-authenticated ADR-305 [`SensorId`](ruview_ontology::SensorId).
|
||||||
|
//! 3. **One canonical semantics downstream.** Candidates reuse the canonical
|
||||||
|
//! [`SensorId`](ruview_ontology::SensorId) and HAL [`Modality`](ruview_hal::Modality)
|
||||||
|
//! rather than reinventing per-crate identity/modality shapes.
|
||||||
|
//! 4. **Honest evidence.** A scheduling decision is a resource choice, not a
|
||||||
|
//! sensing claim; the plan records which sensors were skipped so ADR-302 can
|
||||||
|
//! raise `UNKNOWN` for an under-sampled zone rather than reporting a stale
|
||||||
|
//! estimate as current.
|
||||||
|
//!
|
||||||
|
//! ## Purity
|
||||||
|
//!
|
||||||
|
//! [`Scheduler::plan`] has **no** scheduling side effects: it starts no
|
||||||
|
//! sampling and touches no hardware — it returns a [`SchedulePlan`]. ADR-309
|
||||||
|
//! active sensing chooses the probe on each selected sensor; ADR-311 fusion
|
||||||
|
//! incorporates the result. It is deterministic (no wall clock, no randomness;
|
||||||
|
//! synthetic scenes vary only by explicit caller-supplied parameters) and
|
||||||
|
//! bounded in allocation.
|
||||||
|
//!
|
||||||
|
//! ## Example
|
||||||
|
//!
|
||||||
|
//! ```
|
||||||
|
//! use ruview_infogain::*;
|
||||||
|
//! use ruview_hal::Modality;
|
||||||
|
//! use ruview_ontology::SensorId;
|
||||||
|
//!
|
||||||
|
//! let candidates = vec![
|
||||||
|
//! SensorAction::new(
|
||||||
|
//! SensorId::new("wifi-1").unwrap(),
|
||||||
|
//! Modality::Csi,
|
||||||
|
//! ExpectedReduction::known(0.9), // high modelled information...
|
||||||
|
//! Cost::new(1.0, 1.0, 1.0), // ...at low cost → high value
|
||||||
|
//! ),
|
||||||
|
//! SensorAction::new(
|
||||||
|
//! SensorId::new("mmwave-occluded").unwrap(),
|
||||||
|
//! Modality::Mmwave,
|
||||||
|
//! ExpectedReduction::known(0.1), // low information...
|
||||||
|
//! Cost::new(5.0, 5.0, 5.0), // ...at high cost → low value
|
||||||
|
//! ),
|
||||||
|
//! ];
|
||||||
|
//!
|
||||||
|
//! let sched = Scheduler::new(SchedulerConfig::default());
|
||||||
|
//! let plan = sched.plan(&candidates, &Budget::new(2.0, 2.0, 2.0));
|
||||||
|
//!
|
||||||
|
//! // Only the informative-per-cost WiFi link fits the budget.
|
||||||
|
//! assert_eq!(plan.sampled_sensors(), vec![&SensorId::new("wifi-1").unwrap()]);
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
#![forbid(unsafe_code)]
|
||||||
|
#![warn(missing_docs)]
|
||||||
|
|
||||||
|
mod candidate;
|
||||||
|
mod cost;
|
||||||
|
mod scheduler;
|
||||||
|
|
||||||
|
pub use candidate::{ExpectedReduction, SensorAction};
|
||||||
|
pub use cost::{Cost, CostPolicy};
|
||||||
|
pub use scheduler::{
|
||||||
|
Budget, DeferReason, DeferredAction, ScheduledAction, SchedulePlan, Scheduler, SchedulerConfig,
|
||||||
|
SelectReason, UnknownPolicy,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use ruview_hal::Modality;
|
||||||
|
use ruview_ontology::SensorId;
|
||||||
|
|
||||||
|
fn sid(s: &str) -> SensorId {
|
||||||
|
SensorId::new(s).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn action(name: &str, reduction: ExpectedReduction, cost: Cost) -> SensorAction {
|
||||||
|
SensorAction::new(sid(name), Modality::Csi, reduction, cost)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn known(name: &str, r: f64, c: f64) -> SensorAction {
|
||||||
|
action(name, ExpectedReduction::known(r), Cost::new(c, c, c))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn plan(candidates: &[SensorAction], budget: Budget) -> SchedulePlan {
|
||||||
|
Scheduler::new(SchedulerConfig::default()).plan(candidates, &budget)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ADR-314 §2: the highest value/cost candidate is ranked and selected first.
|
||||||
|
#[test]
|
||||||
|
fn highest_value_per_cost_selected_first() {
|
||||||
|
let candidates = vec![
|
||||||
|
known("low", 0.2, 1.0), // density 0.2 / 3.0
|
||||||
|
known("high", 0.9, 1.0), // density 0.9 / 3.0
|
||||||
|
known("mid", 0.5, 1.0), // density 0.5 / 3.0
|
||||||
|
];
|
||||||
|
// Ample budget: all fit, but order must be by descending value density.
|
||||||
|
let p = plan(&candidates, Budget::new(99.0, 99.0, 99.0));
|
||||||
|
let order: Vec<&str> = p.selected.iter().map(|a| a.sensor.as_str()).collect();
|
||||||
|
assert_eq!(order, vec!["high", "mid", "low"]);
|
||||||
|
assert!(p.selected.iter().all(|a| a.reason == SelectReason::Value));
|
||||||
|
assert!(p.deferred.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ADR-314 §1: a high-cost low-gain sensor is deferred when the budget cannot
|
||||||
|
// hold both it and the more valuable action.
|
||||||
|
#[test]
|
||||||
|
fn high_cost_low_gain_deferred_under_budget() {
|
||||||
|
let candidates = vec![
|
||||||
|
known("cheap-informative", 0.9, 1.0), // density 0.30
|
||||||
|
known("costly-uninformative", 0.1, 5.0), // density ~0.0067
|
||||||
|
];
|
||||||
|
// Budget fits the cheap action but not both.
|
||||||
|
let p = plan(&candidates, Budget::new(3.0, 3.0, 3.0));
|
||||||
|
assert_eq!(p.sampled_sensors(), vec![&sid("cheap-informative")]);
|
||||||
|
assert_eq!(p.deferred.len(), 1);
|
||||||
|
assert_eq!(p.deferred[0].sensor.as_str(), "costly-uninformative");
|
||||||
|
assert_eq!(p.deferred[0].reason, DeferReason::Budget);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The cumulative selected cost never exceeds the budget in any dimension.
|
||||||
|
#[test]
|
||||||
|
fn budget_is_respected_in_every_dimension() {
|
||||||
|
let candidates = vec![
|
||||||
|
known("a", 0.9, 2.0),
|
||||||
|
known("b", 0.8, 2.0),
|
||||||
|
known("c", 0.7, 2.0),
|
||||||
|
known("d", 0.6, 2.0),
|
||||||
|
];
|
||||||
|
let budget = Budget::new(5.0, 5.0, 5.0);
|
||||||
|
let p = plan(&candidates, budget);
|
||||||
|
assert!(p.spent.compute <= budget.compute + 1e-9);
|
||||||
|
assert!(p.spent.energy <= budget.energy + 1e-9);
|
||||||
|
assert!(p.spent.bandwidth <= budget.bandwidth + 1e-9);
|
||||||
|
// Two of the cost-2 actions fit under a budget of 5; the third does not.
|
||||||
|
assert_eq!(p.selected.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A candidate that exactly fills the remaining budget is admitted.
|
||||||
|
#[test]
|
||||||
|
fn exact_fit_is_admitted() {
|
||||||
|
let candidates = vec![known("exact", 0.5, 2.0)];
|
||||||
|
let p = plan(&candidates, Budget::new(2.0, 2.0, 2.0));
|
||||||
|
assert_eq!(p.selected.len(), 1);
|
||||||
|
assert_eq!(p.deferred.len(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deterministic tie-break: equal value density resolves by cheapest weighted
|
||||||
|
// cost, then by sensor id — same inputs always give the same plan.
|
||||||
|
#[test]
|
||||||
|
fn tie_break_is_deterministic() {
|
||||||
|
// Equal density (0.5 / 2.0), so tie-break falls to sensor id.
|
||||||
|
let candidates = vec![
|
||||||
|
known("zebra", 0.5, 2.0),
|
||||||
|
known("alpha", 0.5, 2.0),
|
||||||
|
known("mike", 0.5, 2.0),
|
||||||
|
];
|
||||||
|
let p1 = plan(&candidates, Budget::new(99.0, 99.0, 99.0));
|
||||||
|
let p2 = plan(&candidates, Budget::new(99.0, 99.0, 99.0));
|
||||||
|
assert_eq!(p1, p2);
|
||||||
|
let order: Vec<&str> = p1.selected.iter().map(|a| a.sensor.as_str()).collect();
|
||||||
|
assert_eq!(order, vec!["alpha", "mike", "zebra"]);
|
||||||
|
|
||||||
|
// Cost tie-break wins over id: cheaper same-density action ranks first.
|
||||||
|
let mixed = vec![
|
||||||
|
action("expensive", ExpectedReduction::known(1.0), Cost::new(2.0, 2.0, 2.0)), // 1/6
|
||||||
|
action("cheap", ExpectedReduction::known(0.5), Cost::new(1.0, 1.0, 1.0)), // 0.5/3 = 1/6
|
||||||
|
];
|
||||||
|
let pm = plan(&mixed, Budget::new(99.0, 99.0, 99.0));
|
||||||
|
let order: Vec<&str> = pm.selected.iter().map(|a| a.sensor.as_str()).collect();
|
||||||
|
assert_eq!(order, vec!["cheap", "expensive"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ADR-300 rule 1: an unknown-value candidate is NOT treated as zero. Under
|
||||||
|
// the default Defer policy it is deferred with an explicit reason.
|
||||||
|
#[test]
|
||||||
|
fn unknown_value_defer_policy_defers_explicitly() {
|
||||||
|
let candidates = vec![
|
||||||
|
action("unknown", ExpectedReduction::Unknown, Cost::new(1.0, 1.0, 1.0)),
|
||||||
|
known("known", 0.5, 1.0),
|
||||||
|
];
|
||||||
|
let p = plan(&candidates, Budget::new(99.0, 99.0, 99.0));
|
||||||
|
assert_eq!(p.sampled_sensors(), vec![&sid("known")]);
|
||||||
|
assert_eq!(p.deferred.len(), 1);
|
||||||
|
assert_eq!(p.deferred[0].sensor.as_str(), "unknown");
|
||||||
|
assert_eq!(p.deferred[0].reason, DeferReason::UnknownDeferred);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ADR-314: the Probe policy spends budget to LEARN an unknown candidate's
|
||||||
|
// informativeness, with an explicit synthetic probe value (not zero).
|
||||||
|
#[test]
|
||||||
|
fn unknown_value_probe_policy_selects_to_learn() {
|
||||||
|
let config = SchedulerConfig {
|
||||||
|
policy: CostPolicy::UNIFORM,
|
||||||
|
unknown: UnknownPolicy::Probe { probe_value: 1.0 },
|
||||||
|
sampling_floor: None,
|
||||||
|
};
|
||||||
|
let candidates = vec![
|
||||||
|
action("unknown", ExpectedReduction::Unknown, Cost::new(1.0, 1.0, 1.0)),
|
||||||
|
known("weak", 0.1, 1.0),
|
||||||
|
];
|
||||||
|
let p = Scheduler::new(config).plan(&candidates, &Budget::new(1.0, 1.0, 1.0));
|
||||||
|
// Probe value (1.0) beats the weak known (0.1), so the unknown is probed.
|
||||||
|
assert_eq!(p.selected.len(), 1);
|
||||||
|
assert_eq!(p.selected[0].sensor.as_str(), "unknown");
|
||||||
|
assert_eq!(p.selected[0].reason, SelectReason::Probe);
|
||||||
|
// No honest value figure is reported for an unknown reduction.
|
||||||
|
assert_eq!(p.selected[0].value_density, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ADR-314 §2: the sampling floor force-includes a starved low-value sensor
|
||||||
|
// so it is re-evaluated rather than permanently blinded.
|
||||||
|
#[test]
|
||||||
|
fn sampling_floor_forces_starved_low_value_sensor() {
|
||||||
|
let config = SchedulerConfig {
|
||||||
|
policy: CostPolicy::UNIFORM,
|
||||||
|
unknown: UnknownPolicy::Defer,
|
||||||
|
sampling_floor: Some(3),
|
||||||
|
};
|
||||||
|
let mut starved = known("starved", 0.0, 1.0); // zero value: would be deferred
|
||||||
|
starved.cycles_since_sampled = 5; // ... but it is past the floor
|
||||||
|
let fresh = known("fresh", 0.9, 1.0);
|
||||||
|
let candidates = vec![starved, fresh];
|
||||||
|
let p = Scheduler::new(config).plan(&candidates, &Budget::new(99.0, 99.0, 99.0));
|
||||||
|
// Both selected; the starved one is force-included with the Floor reason.
|
||||||
|
assert_eq!(p.selected.len(), 2);
|
||||||
|
let starved_sel = p
|
||||||
|
.selected
|
||||||
|
.iter()
|
||||||
|
.find(|a| a.sensor.as_str() == "starved")
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(starved_sel.reason, SelectReason::Floor);
|
||||||
|
// Floor is best-effort under a hard budget: it cannot fit → deferred.
|
||||||
|
let tight = Scheduler::new(config).plan(&candidates, &Budget::new(0.0, 0.0, 0.0));
|
||||||
|
assert!(tight.selected.is_empty());
|
||||||
|
assert!(tight.deferred.iter().all(|d| d.reason == DeferReason::Budget));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty candidate set → empty plan, nothing spent, no panic.
|
||||||
|
#[test]
|
||||||
|
fn empty_candidate_set_yields_empty_plan() {
|
||||||
|
let p = plan(&[], Budget::new(10.0, 10.0, 10.0));
|
||||||
|
assert!(p.is_empty());
|
||||||
|
assert!(p.selected.is_empty());
|
||||||
|
assert!(p.deferred.is_empty());
|
||||||
|
assert_eq!(p.spent, Cost::ZERO);
|
||||||
|
assert_eq!(p, SchedulePlan::empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Malformed input never panics: non-finite/negative cost defers the
|
||||||
|
// candidate (UNKNOWN cost); non-finite reduction becomes Unknown; negative
|
||||||
|
// reduction clamps to zero.
|
||||||
|
#[test]
|
||||||
|
fn malformed_input_never_panics() {
|
||||||
|
// Non-finite reduction → Unknown.
|
||||||
|
assert_eq!(ExpectedReduction::known(f64::NAN), ExpectedReduction::Unknown);
|
||||||
|
assert_eq!(
|
||||||
|
ExpectedReduction::known(f64::INFINITY),
|
||||||
|
ExpectedReduction::Unknown
|
||||||
|
);
|
||||||
|
// Negative reduction clamps to zero.
|
||||||
|
assert_eq!(ExpectedReduction::known(-3.0), ExpectedReduction::Known(0.0));
|
||||||
|
|
||||||
|
let candidates = vec![
|
||||||
|
action("nan-cost", ExpectedReduction::known(0.9), Cost::new(f64::NAN, 1.0, 1.0)),
|
||||||
|
action("neg-cost", ExpectedReduction::known(0.9), Cost::new(-1.0, 1.0, 1.0)),
|
||||||
|
action("inf-cost", ExpectedReduction::known(0.9), Cost::new(f64::INFINITY, 1.0, 1.0)),
|
||||||
|
known("good", 0.9, 1.0),
|
||||||
|
];
|
||||||
|
let p = plan(&candidates, Budget::new(99.0, 99.0, 99.0));
|
||||||
|
// Only the well-formed candidate is sampled; the rest defer as malformed.
|
||||||
|
assert_eq!(p.sampled_sensors(), vec![&sid("good")]);
|
||||||
|
let malformed: Vec<&str> = p
|
||||||
|
.deferred
|
||||||
|
.iter()
|
||||||
|
.filter(|d| d.reason == DeferReason::MalformedCost)
|
||||||
|
.map(|d| d.sensor.as_str())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(malformed, vec!["inf-cost", "nan-cost", "neg-cost"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A zero-cost (free) action gets a bounded, finite value density and is not
|
||||||
|
// rejected by a division by zero.
|
||||||
|
#[test]
|
||||||
|
fn zero_cost_action_is_bounded_not_infinite() {
|
||||||
|
let candidates = vec![action(
|
||||||
|
"free",
|
||||||
|
ExpectedReduction::known(1.0),
|
||||||
|
Cost::new(0.0, 0.0, 0.0),
|
||||||
|
)];
|
||||||
|
let p = plan(&candidates, Budget::new(1.0, 1.0, 1.0));
|
||||||
|
assert_eq!(p.selected.len(), 1);
|
||||||
|
let d = p.selected[0].value_density.unwrap();
|
||||||
|
assert!(d.is_finite());
|
||||||
|
}
|
||||||
|
|
||||||
|
// A known-zero-reduction candidate is deferred as NoGain (honest: no
|
||||||
|
// modelled information), distinct from UNKNOWN.
|
||||||
|
#[test]
|
||||||
|
fn known_zero_reduction_defers_as_no_gain() {
|
||||||
|
let candidates = vec![known("nogain", 0.0, 1.0)];
|
||||||
|
let p = plan(&candidates, Budget::new(99.0, 99.0, 99.0));
|
||||||
|
assert!(p.selected.is_empty());
|
||||||
|
assert_eq!(p.deferred.len(), 1);
|
||||||
|
assert_eq!(p.deferred[0].reason, DeferReason::NoGain);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The cost policy is a deployment choice: weighting a resource heavily can
|
||||||
|
// flip which sensor is more valuable per unit cost.
|
||||||
|
#[test]
|
||||||
|
fn cost_policy_weighting_changes_ranking() {
|
||||||
|
// "a" is cheap on compute but expensive on energy; "b" the reverse.
|
||||||
|
let a = action("a", ExpectedReduction::known(1.0), Cost::new(1.0, 10.0, 1.0));
|
||||||
|
let b = action("b", ExpectedReduction::known(1.0), Cost::new(10.0, 1.0, 1.0));
|
||||||
|
let candidates = vec![a, b];
|
||||||
|
|
||||||
|
// Energy-heavy policy (battery node): "a" is costlier → "b" ranks first.
|
||||||
|
let energy_heavy = SchedulerConfig {
|
||||||
|
policy: CostPolicy::new(1.0, 100.0, 1.0),
|
||||||
|
unknown: UnknownPolicy::Defer,
|
||||||
|
sampling_floor: None,
|
||||||
|
};
|
||||||
|
let p = Scheduler::new(energy_heavy).plan(&candidates, &Budget::new(99.0, 99.0, 99.0));
|
||||||
|
assert_eq!(p.selected[0].sensor.as_str(), "b");
|
||||||
|
|
||||||
|
// Compute-heavy policy (wired gateway): the ranking flips to "a".
|
||||||
|
let compute_heavy = SchedulerConfig {
|
||||||
|
policy: CostPolicy::new(100.0, 1.0, 1.0),
|
||||||
|
unknown: UnknownPolicy::Defer,
|
||||||
|
sampling_floor: None,
|
||||||
|
};
|
||||||
|
let p = Scheduler::new(compute_heavy).plan(&candidates, &Budget::new(99.0, 99.0, 99.0));
|
||||||
|
assert_eq!(p.selected[0].sensor.as_str(), "a");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determinism: identical inputs yield byte-identical plans across runs.
|
||||||
|
#[test]
|
||||||
|
fn plan_is_deterministic() {
|
||||||
|
let candidates = vec![
|
||||||
|
known("a", 0.7, 2.0),
|
||||||
|
known("b", 0.3, 1.0),
|
||||||
|
action("c", ExpectedReduction::Unknown, Cost::new(1.0, 1.0, 1.0)),
|
||||||
|
];
|
||||||
|
let budget = Budget::new(3.0, 3.0, 3.0);
|
||||||
|
let sched = Scheduler::new(SchedulerConfig::default());
|
||||||
|
assert_eq!(sched.plan(&candidates, &budget), sched.plan(&candidates, &budget));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The whole plan round-trips losslessly through serde (canonical output).
|
||||||
|
#[test]
|
||||||
|
fn plan_serde_round_trips() {
|
||||||
|
let candidates = vec![
|
||||||
|
known("a", 0.9, 1.0),
|
||||||
|
known("b", 0.1, 5.0),
|
||||||
|
action("c", ExpectedReduction::Unknown, Cost::new(1.0, 1.0, 1.0)),
|
||||||
|
];
|
||||||
|
let p = plan(&candidates, Budget::new(2.0, 2.0, 2.0));
|
||||||
|
let json = serde_json::to_string(&p).unwrap();
|
||||||
|
let back: SchedulePlan = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(p, back);
|
||||||
|
}
|
||||||
|
}
|
||||||
391
v2/crates/ruview-infogain/src/scheduler.rs
Normal file
391
v2/crates/ruview-infogain/src/scheduler.rs
Normal file
@@ -0,0 +1,391 @@
|
|||||||
|
//! The information-gain scheduler: rank candidates by value of information and
|
||||||
|
//! select the most informative subset under a resource budget (ADR-314 §2).
|
||||||
|
//!
|
||||||
|
//! **SYNTHETIC / L0 scaffold (ADR-282).** The scheduler emits an *allocation*
|
||||||
|
//! (a [`SchedulePlan`]), never a measurement and never a sensing claim. It has
|
||||||
|
//! **no** side effects: it starts no sampling, touches no hardware, and asserts
|
||||||
|
//! no efficiency figure — ADR-309 active sensing chooses the probe on each
|
||||||
|
//! selected sensor and ADR-311 fusion incorporates the result. A scheduling
|
||||||
|
//! decision is a resource choice, not evidence.
|
||||||
|
//!
|
||||||
|
//! ## Selection algorithm (documented)
|
||||||
|
//!
|
||||||
|
//! The value function is
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! Value(action) = expected_uncertainty_reduction / weighted_cost
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! where `weighted_cost` collapses the compute/energy/bandwidth triple under the
|
||||||
|
//! configured [`CostPolicy`](crate::CostPolicy). Maximising total expected
|
||||||
|
//! reduction under a multi-resource budget is a knapsack; this scheduler uses a
|
||||||
|
//! **bounded greedy** heuristic — sort candidates by value density (reduction
|
||||||
|
//! per unit weighted cost) and take each that still fits the remaining budget.
|
||||||
|
//! It is `O(n log n)`, allocates one bounded working vector, and is fully
|
||||||
|
//! deterministic. A candidate that does not fit is deferred, not dropped, and
|
||||||
|
//! the scheduler keeps scanning lower-density candidates that may still fit —
|
||||||
|
//! so a small cheap action can be picked after a large one is skipped.
|
||||||
|
//!
|
||||||
|
//! Two policies sit on top of the greedy core:
|
||||||
|
//! - **Sampling floor**: a candidate whose `cycles_since_sampled` has reached
|
||||||
|
//! the configured floor is *force-included* (subject only to the hard budget)
|
||||||
|
//! so a low-value sensor is re-evaluated as the scene changes rather than
|
||||||
|
//! being starved permanently.
|
||||||
|
//! - **Unknown-value handling**: a candidate with an
|
||||||
|
//! [`Unknown`](crate::ExpectedReduction::Unknown) reduction is never treated
|
||||||
|
//! as zero — the [`UnknownPolicy`] either probes it (assigns an explicit probe
|
||||||
|
//! value so budget is spent to *learn* its informativeness) or defers it.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use ruview_hal::Modality;
|
||||||
|
use ruview_ontology::SensorId;
|
||||||
|
|
||||||
|
use crate::candidate::{ExpectedReduction, SensorAction};
|
||||||
|
use crate::cost::{Cost, CostPolicy};
|
||||||
|
|
||||||
|
/// Tolerance for the budget fit comparison, absorbing float round-off so a
|
||||||
|
/// candidate that exactly fills the budget is not spuriously rejected.
|
||||||
|
const BUDGET_EPSILON: f64 = 1e-9;
|
||||||
|
|
||||||
|
/// How the scheduler treats a candidate with an
|
||||||
|
/// [`Unknown`](crate::ExpectedReduction::Unknown) expected reduction (ADR-314:
|
||||||
|
/// unknown value is not zero value).
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case", tag = "kind")]
|
||||||
|
pub enum UnknownPolicy {
|
||||||
|
/// Defer unknown-value candidates: they are not ranked by value (an unknown
|
||||||
|
/// is not asserted to be worthless), but they remain eligible for the
|
||||||
|
/// sampling floor so they are eventually re-evaluated.
|
||||||
|
Defer,
|
||||||
|
/// Probe unknown-value candidates: assign them an explicit optimistic
|
||||||
|
/// `probe_value` so the scheduler may spend budget to *learn* their
|
||||||
|
/// informativeness. The value is synthetic exploration pressure, not a
|
||||||
|
/// prediction; it is clamped to a finite non-negative number.
|
||||||
|
Probe {
|
||||||
|
/// The synthetic value density weight given to an unknown candidate.
|
||||||
|
probe_value: f64,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scheduler configuration: the cost policy, the unknown-value policy, and the
|
||||||
|
/// optional sampling floor.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct SchedulerConfig {
|
||||||
|
/// How the cost triple is weighted into the value-function denominator.
|
||||||
|
pub policy: CostPolicy,
|
||||||
|
/// How unknown-value candidates are handled.
|
||||||
|
pub unknown: UnknownPolicy,
|
||||||
|
/// Force-sample a candidate once `cycles_since_sampled >=` this value.
|
||||||
|
/// `None` disables the floor. The floor is best-effort under the hard
|
||||||
|
/// budget — a forced candidate that cannot fit any resource is still
|
||||||
|
/// deferred rather than violating the budget.
|
||||||
|
#[serde(default)]
|
||||||
|
pub sampling_floor: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for SchedulerConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
policy: CostPolicy::UNIFORM,
|
||||||
|
unknown: UnknownPolicy::Defer,
|
||||||
|
sampling_floor: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The multi-resource budget for one scheduling cycle. Each selected action
|
||||||
|
/// consumes its [`Cost`] triple; the cumulative spend may not exceed the budget
|
||||||
|
/// in any single dimension.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct Budget {
|
||||||
|
/// Compute budget for the cycle.
|
||||||
|
pub compute: f64,
|
||||||
|
/// Energy budget for the cycle.
|
||||||
|
pub energy: f64,
|
||||||
|
/// Bandwidth budget for the cycle.
|
||||||
|
pub bandwidth: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Budget {
|
||||||
|
/// Construct a budget, clamping any non-finite or negative dimension to
|
||||||
|
/// `0.0` (an unusable dimension admits nothing, rather than erroring).
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(compute: f64, energy: f64, bandwidth: f64) -> Self {
|
||||||
|
Self {
|
||||||
|
compute: clamp_budget(compute),
|
||||||
|
energy: clamp_budget(energy),
|
||||||
|
bandwidth: clamp_budget(bandwidth),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True when `spent + cost` stays within every dimension of this budget.
|
||||||
|
fn admits(&self, spent: &Cost, cost: &Cost) -> bool {
|
||||||
|
spent.compute + cost.compute <= self.compute + BUDGET_EPSILON
|
||||||
|
&& spent.energy + cost.energy <= self.energy + BUDGET_EPSILON
|
||||||
|
&& spent.bandwidth + cost.bandwidth <= self.bandwidth + BUDGET_EPSILON
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clamp_budget(v: f64) -> f64 {
|
||||||
|
if v.is_finite() && v >= 0.0 {
|
||||||
|
v
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Why a candidate was selected into the plan.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum SelectReason {
|
||||||
|
/// Selected by information-gain value density (the ordinary path).
|
||||||
|
Value,
|
||||||
|
/// Force-included by the sampling floor, not by its current value.
|
||||||
|
Floor,
|
||||||
|
/// Selected to probe an unknown-value candidate and learn its informativeness.
|
||||||
|
Probe,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Why a candidate was deferred (skipped this cycle).
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum DeferReason {
|
||||||
|
/// No remaining budget in at least one resource dimension.
|
||||||
|
Budget,
|
||||||
|
/// Unknown expected reduction under [`UnknownPolicy::Defer`] — deferred
|
||||||
|
/// explicitly, *not* treated as zero value.
|
||||||
|
UnknownDeferred,
|
||||||
|
/// A known, non-positive expected reduction: no modelled information to gain.
|
||||||
|
NoGain,
|
||||||
|
/// The cost triple was malformed (non-finite/negative); cost is UNKNOWN, so
|
||||||
|
/// the candidate is deferred rather than guessed at.
|
||||||
|
MalformedCost,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One selected action in the emitted plan.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct ScheduledAction {
|
||||||
|
/// The sensor to sample.
|
||||||
|
pub sensor: SensorId,
|
||||||
|
/// Its modality.
|
||||||
|
pub modality: Modality,
|
||||||
|
/// The value density that ranked it, when defined (`None` for an
|
||||||
|
/// unknown-value candidate forced in by the floor).
|
||||||
|
pub value_density: Option<f64>,
|
||||||
|
/// Why it was selected.
|
||||||
|
pub reason: SelectReason,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One deferred (skipped) action in the emitted plan.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct DeferredAction {
|
||||||
|
/// The sensor that was not sampled this cycle.
|
||||||
|
pub sensor: SensorId,
|
||||||
|
/// Its modality.
|
||||||
|
pub modality: Modality,
|
||||||
|
/// Why it was deferred.
|
||||||
|
pub reason: DeferReason,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The scheduler's output: a pure allocation for one cycle.
|
||||||
|
///
|
||||||
|
/// Recording both `selected` and `deferred` is the ADR-314 §3 honesty
|
||||||
|
/// requirement — skipping a sensor is a *deliberate* reduction in coverage, so
|
||||||
|
/// downstream observability (ADR-302) can raise `UNKNOWN` for an under-sampled
|
||||||
|
/// zone rather than reporting a stale estimate as current.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct SchedulePlan {
|
||||||
|
/// Actions to sample this cycle, in selection order (floor-forced first,
|
||||||
|
/// then descending value density).
|
||||||
|
pub selected: Vec<ScheduledAction>,
|
||||||
|
/// Actions skipped this cycle, each with its reason, sorted by sensor id.
|
||||||
|
pub deferred: Vec<DeferredAction>,
|
||||||
|
/// Total modelled cost the plan commits (component-wise, ≤ budget).
|
||||||
|
pub spent: Cost,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SchedulePlan {
|
||||||
|
/// The empty plan (no candidates, nothing spent).
|
||||||
|
#[must_use]
|
||||||
|
pub fn empty() -> Self {
|
||||||
|
Self {
|
||||||
|
selected: Vec::new(),
|
||||||
|
deferred: Vec::new(),
|
||||||
|
spent: Cost::ZERO,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True when nothing was selected.
|
||||||
|
#[must_use]
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.selected.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The sensors actually sampled by this plan, for the ADR-314 §3 sampling
|
||||||
|
/// record consumed downstream.
|
||||||
|
#[must_use]
|
||||||
|
pub fn sampled_sensors(&self) -> Vec<&SensorId> {
|
||||||
|
self.selected.iter().map(|a| &a.sensor).collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The information-gain scheduler.
|
||||||
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
pub struct Scheduler {
|
||||||
|
config: SchedulerConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Internal classification of a candidate before selection.
|
||||||
|
struct Ranked<'a> {
|
||||||
|
action: &'a SensorAction,
|
||||||
|
density: f64,
|
||||||
|
reason: SelectReason,
|
||||||
|
/// The value density to report, `None` when the reduction is unknown.
|
||||||
|
reported_density: Option<f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Scheduler {
|
||||||
|
/// Construct a scheduler with the given configuration.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(config: SchedulerConfig) -> Self {
|
||||||
|
Self { config }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Borrow the configuration.
|
||||||
|
#[must_use]
|
||||||
|
pub fn config(&self) -> &SchedulerConfig {
|
||||||
|
&self.config
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Produce an allocation for one cycle. Pure and deterministic: identical
|
||||||
|
/// candidates + budget always yield an identical plan, with no side effects.
|
||||||
|
#[must_use]
|
||||||
|
pub fn plan(&self, candidates: &[SensorAction], budget: &Budget) -> SchedulePlan {
|
||||||
|
let mut forced: Vec<Ranked<'_>> = Vec::new();
|
||||||
|
let mut ranked: Vec<Ranked<'_>> = Vec::new();
|
||||||
|
let mut deferred: Vec<DeferredAction> = Vec::new();
|
||||||
|
|
||||||
|
for action in candidates {
|
||||||
|
// Malformed cost is UNKNOWN cost — defer, never guess a number.
|
||||||
|
if !action.cost.is_well_formed() {
|
||||||
|
deferred.push(defer(action, DeferReason::MalformedCost));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let floor_forced = self
|
||||||
|
.config
|
||||||
|
.sampling_floor
|
||||||
|
.is_some_and(|n| action.cycles_since_sampled >= n);
|
||||||
|
|
||||||
|
// Determine the value density and the "ordinary" (non-floor) reason.
|
||||||
|
let (density, reported, ordinary_reason, defer_reason) =
|
||||||
|
self.classify(action);
|
||||||
|
|
||||||
|
if floor_forced {
|
||||||
|
// Force-included regardless of value; report the floor reason
|
||||||
|
// but keep the density we could compute (may be None).
|
||||||
|
forced.push(Ranked {
|
||||||
|
action,
|
||||||
|
density,
|
||||||
|
reason: SelectReason::Floor,
|
||||||
|
reported_density: reported,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
match ordinary_reason {
|
||||||
|
Some(reason) => ranked.push(Ranked {
|
||||||
|
action,
|
||||||
|
density,
|
||||||
|
reason,
|
||||||
|
reported_density: reported,
|
||||||
|
}),
|
||||||
|
// Not force-forced and no positive value: defer with the honest
|
||||||
|
// reason (NoGain or UnknownDeferred).
|
||||||
|
None => deferred.push(defer(action, defer_reason)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forced candidates go first, in a deterministic (sensor-id) order.
|
||||||
|
forced.sort_by(|a, b| a.action.sensor.as_str().cmp(b.action.sensor.as_str()));
|
||||||
|
|
||||||
|
// Value-ranked candidates: highest density first, then cheapest, then
|
||||||
|
// sensor id — a fully deterministic total order (no NaN, all clamped).
|
||||||
|
ranked.sort_by(|a, b| {
|
||||||
|
b.density
|
||||||
|
.total_cmp(&a.density)
|
||||||
|
.then_with(|| {
|
||||||
|
let ca = self.config.policy.scalar_cost(&a.action.cost);
|
||||||
|
let cb = self.config.policy.scalar_cost(&b.action.cost);
|
||||||
|
ca.total_cmp(&cb)
|
||||||
|
})
|
||||||
|
.then_with(|| a.action.sensor.as_str().cmp(b.action.sensor.as_str()))
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut selected: Vec<ScheduledAction> = Vec::new();
|
||||||
|
let mut spent = Cost::ZERO;
|
||||||
|
|
||||||
|
for r in forced.into_iter().chain(ranked.into_iter()) {
|
||||||
|
if budget.admits(&spent, &r.action.cost) {
|
||||||
|
spent = spent.plus(&r.action.cost);
|
||||||
|
selected.push(ScheduledAction {
|
||||||
|
sensor: r.action.sensor.clone(),
|
||||||
|
modality: r.action.modality.clone(),
|
||||||
|
value_density: r.reported_density,
|
||||||
|
reason: r.reason,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
deferred.push(defer(r.action, DeferReason::Budget));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deferred.sort_by(|a, b| a.sensor.as_str().cmp(b.sensor.as_str()));
|
||||||
|
|
||||||
|
SchedulePlan {
|
||||||
|
selected,
|
||||||
|
deferred,
|
||||||
|
spent,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Classify a well-formed candidate into `(density, reported_density,
|
||||||
|
/// ordinary_reason, defer_reason_if_no_value)`.
|
||||||
|
fn classify(
|
||||||
|
&self,
|
||||||
|
action: &SensorAction,
|
||||||
|
) -> (f64, Option<f64>, Option<SelectReason>, DeferReason) {
|
||||||
|
match action.expected_reduction {
|
||||||
|
ExpectedReduction::Known(v) if v > 0.0 => {
|
||||||
|
let density = v / self.config.policy.scalar_cost(&action.cost);
|
||||||
|
(density, Some(density), Some(SelectReason::Value), DeferReason::NoGain)
|
||||||
|
}
|
||||||
|
ExpectedReduction::Known(_) => {
|
||||||
|
// Known zero reduction: no modelled information to gain.
|
||||||
|
(0.0, Some(0.0), None, DeferReason::NoGain)
|
||||||
|
}
|
||||||
|
ExpectedReduction::Unknown => match self.config.unknown {
|
||||||
|
UnknownPolicy::Probe { probe_value } => {
|
||||||
|
let pv = if probe_value.is_finite() && probe_value >= 0.0 {
|
||||||
|
probe_value
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
let density = pv / self.config.policy.scalar_cost(&action.cost);
|
||||||
|
// Reported density stays None: an unknown reduction has no
|
||||||
|
// honest value figure even when probed.
|
||||||
|
(density, None, Some(SelectReason::Probe), DeferReason::UnknownDeferred)
|
||||||
|
}
|
||||||
|
UnknownPolicy::Defer => (0.0, None, None, DeferReason::UnknownDeferred),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn defer(action: &SensorAction, reason: DeferReason) -> DeferredAction {
|
||||||
|
DeferredAction {
|
||||||
|
sensor: action.sensor.clone(),
|
||||||
|
modality: action.modality.clone(),
|
||||||
|
reason,
|
||||||
|
}
|
||||||
|
}
|
||||||
17
v2/crates/ruview-memory/Cargo.toml
Normal file
17
v2/crates/ruview-memory/Cargo.toml
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
[package]
|
||||||
|
name = "ruview-memory"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
authors.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
thiserror.workspace = true
|
||||||
|
serde = { workspace = true, features = ["derive"] }
|
||||||
|
ruview-ontology = { path = "../ruview-ontology" }
|
||||||
|
ruview-twin = { path = "../ruview-twin" }
|
||||||
|
ruview-evidence = { path = "../ruview-evidence" }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
serde_json.workspace = true
|
||||||
171
v2/crates/ruview-memory/src/anomaly.rs
Normal file
171
v2/crates/ruview-memory/src/anomaly.rs
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
//! Deviation categories, per-channel assessment, and the anomaly event
|
||||||
|
//! (ADR-312 §3 — anomaly = deviation from learned normal).
|
||||||
|
//!
|
||||||
|
//! **SYNTHETIC / L0.** An [`AnomalyEvent`] is a *model-relative* statement: a
|
||||||
|
//! live value sits statistically far from the location's own learned normal. It
|
||||||
|
//! is a **candidate** change to corroborate, never a confident detection and
|
||||||
|
//! never a diagnosis (ADR-282 bounded-claims discipline, ADR-300). No accuracy,
|
||||||
|
//! detection-rate, or false-positive number is asserted anywhere. Consistent
|
||||||
|
//! with ADR-300 rule 1, [`Assessment::Unknown`] (insufficient history) is a
|
||||||
|
//! first-class value, never an error and never a false positive.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use ruview_evidence::{AccuracyMetrics, EvidenceContext, EvidenceError, EvidenceRecord};
|
||||||
|
use ruview_ontology::{EvidenceLevel, SemanticProvenance, ZoneId};
|
||||||
|
|
||||||
|
/// The coarse category of a learned-normal deviation (ADR-312 §1). None of
|
||||||
|
/// these is a labelled anomaly *class* trained from examples — each is a
|
||||||
|
/// deviation from a baseline of normality, so a novel change still registers.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum AnomalyKind {
|
||||||
|
/// The space is occupied (or unoccupied) at an hour-of-day it normally is
|
||||||
|
/// not — the "bedroom usually occupied certain hours" case.
|
||||||
|
UnusualOccupancyHour,
|
||||||
|
/// A link's propagation signature deviates from the learned normal — the
|
||||||
|
/// "chair moved" / "RF propagation changed" / "new reflector appeared"
|
||||||
|
/// cases, which all surface as a per-link RSSI delta.
|
||||||
|
PropagationChange,
|
||||||
|
/// A coarse per-modality signature channel deviates from normal — the
|
||||||
|
/// "machine's vibration signature changed" case.
|
||||||
|
ModalityChange,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AnomalyKind {
|
||||||
|
/// A stable snake_case label used in evidence-record context keys.
|
||||||
|
#[must_use]
|
||||||
|
pub fn label(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
AnomalyKind::UnusualOccupancyHour => "unusual_occupancy_hour",
|
||||||
|
AnomalyKind::PropagationChange => "propagation_change",
|
||||||
|
AnomalyKind::ModalityChange => "modality_change",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The outcome of scoring one channel (occupancy bucket, link, or modality
|
||||||
|
/// channel) against its learned baseline.
|
||||||
|
///
|
||||||
|
/// **SYNTHETIC / L0.** `significance` is a modelled standard-deviation count
|
||||||
|
/// against the baseline's own learned variance, not a calibrated probability.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "status", rename_all = "snake_case")]
|
||||||
|
pub enum Assessment {
|
||||||
|
/// Insufficient history to judge (fewer than `min_history` updates). A
|
||||||
|
/// first-class value (ADR-300 rule 1): the channel is *not* flagged, so no
|
||||||
|
/// anomaly is emitted before a baseline exists.
|
||||||
|
Unknown,
|
||||||
|
/// Evaluated and within normal variation (`significance < threshold`).
|
||||||
|
Normal {
|
||||||
|
/// Modelled deviation significance (standard deviations), `≥ 0`.
|
||||||
|
significance: f64,
|
||||||
|
},
|
||||||
|
/// Evaluated and statistically far from normal (`significance ≥ threshold`).
|
||||||
|
Anomalous {
|
||||||
|
/// Modelled deviation significance (standard deviations), `≥ 0`.
|
||||||
|
significance: f64,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Assessment {
|
||||||
|
/// True when the channel had too little history to judge.
|
||||||
|
#[must_use]
|
||||||
|
pub fn is_unknown(&self) -> bool {
|
||||||
|
matches!(self, Assessment::Unknown)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True when the channel deviated beyond the threshold.
|
||||||
|
#[must_use]
|
||||||
|
pub fn is_anomalous(&self) -> bool {
|
||||||
|
matches!(self, Assessment::Anomalous { .. })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The modelled significance when evaluated, `None` when UNKNOWN.
|
||||||
|
#[must_use]
|
||||||
|
pub fn significance(&self) -> Option<f64> {
|
||||||
|
match self {
|
||||||
|
Assessment::Unknown => None,
|
||||||
|
Assessment::Normal { significance } | Assessment::Anomalous { significance } => {
|
||||||
|
Some(*significance)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A flagged deviation from a zone's learned normal (ADR-312 §3).
|
||||||
|
///
|
||||||
|
/// **SYNTHETIC / L0.** Carries the baseline it deviated from, the deviation
|
||||||
|
/// magnitude and significance, and its evidence level — which is the floor of
|
||||||
|
/// the observations the baseline was learned from and is **never presented
|
||||||
|
/// above** them (ADR-282 no-upgrade rule).
|
||||||
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct AnomalyEvent {
|
||||||
|
/// The zone whose learned normal was deviated from.
|
||||||
|
pub zone: ZoneId,
|
||||||
|
/// The deviation category.
|
||||||
|
pub kind: AnomalyKind,
|
||||||
|
/// Producer-supplied observation time (Unix ms). Injected, never sampled.
|
||||||
|
pub at_unix_ms: i64,
|
||||||
|
/// UTC hour-of-day derived deterministically from `at_unix_ms`.
|
||||||
|
pub hour_of_day: u8,
|
||||||
|
/// Which channel deviated (link endpoints, modality channel, or hour).
|
||||||
|
pub detail: String,
|
||||||
|
/// The observed value.
|
||||||
|
pub observed: f64,
|
||||||
|
/// The learned baseline mean it deviated from.
|
||||||
|
pub baseline_mean: f64,
|
||||||
|
/// Modelled deviation significance (standard deviations), `≥ 0`.
|
||||||
|
pub significance: f64,
|
||||||
|
/// Number of observations backing the baseline.
|
||||||
|
pub baseline_count: u32,
|
||||||
|
/// Evidence level — the floor of the baseline's source observations, never
|
||||||
|
/// above them.
|
||||||
|
pub evidence_level: EvidenceLevel,
|
||||||
|
/// Provenance travelling with the event (SYNTHETIC / L0 scaffold).
|
||||||
|
pub provenance: SemanticProvenance,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AnomalyEvent {
|
||||||
|
/// The signed deviation `observed − baseline_mean`.
|
||||||
|
#[must_use]
|
||||||
|
pub fn deviation(&self) -> f64 {
|
||||||
|
self.observed - self.baseline_mean
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Project this anomaly into an append-only [`EvidenceRecord`]
|
||||||
|
/// (ADR-304/ADR-312: "emit anomalies as evidence records with provenance").
|
||||||
|
///
|
||||||
|
/// The record is always **synthetic** (forced [`ruview_evidence::EvidenceLevel::L0`]),
|
||||||
|
/// keyed by context `(room = zone, device = "spatial-memory-scaffold",
|
||||||
|
/// subject_class = "anomaly:<kind>", model_version)`. The deviation
|
||||||
|
/// magnitude is carried as the record's `drift` (fingerprint distance from
|
||||||
|
/// baseline) and the significance as its `uncertainty`; `sample_count` is
|
||||||
|
/// the baseline's backing history. No rate is fabricated — the accuracy
|
||||||
|
/// rates are left at `0.0` because this scaffold asserts none.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Propagates [`EvidenceError`] if a context field is empty/over-length.
|
||||||
|
pub fn to_evidence_record(
|
||||||
|
&self,
|
||||||
|
model_version: &str,
|
||||||
|
timestamp_ns: u64,
|
||||||
|
) -> Result<EvidenceRecord, EvidenceError> {
|
||||||
|
let context = EvidenceContext::new(
|
||||||
|
self.zone.as_str(),
|
||||||
|
"spatial-memory-scaffold",
|
||||||
|
format!("anomaly:{}", self.kind.label()),
|
||||||
|
model_version,
|
||||||
|
)?;
|
||||||
|
let metrics = AccuracyMetrics {
|
||||||
|
moving_recall: 0.0,
|
||||||
|
stationary_recall: 0.0,
|
||||||
|
false_positive_rate: 0.0,
|
||||||
|
drift: self.deviation().abs(),
|
||||||
|
uncertainty: self.significance,
|
||||||
|
calibration_age_secs: 0,
|
||||||
|
sample_count: u64::from(self.baseline_count.max(1)),
|
||||||
|
};
|
||||||
|
EvidenceRecord::synthetic(context, metrics, timestamp_ns)
|
||||||
|
}
|
||||||
|
}
|
||||||
384
v2/crates/ruview-memory/src/baseline.rs
Normal file
384
v2/crates/ruview-memory/src/baseline.rs
Normal file
@@ -0,0 +1,384 @@
|
|||||||
|
//! Configuration, the per-zone learned baseline, and the live observation
|
||||||
|
//! snapshot (ADR-312 §1–§2 — what "normal" is learned over, on the RuVector
|
||||||
|
//! temporal substrate; here a bounded in-memory scaffold).
|
||||||
|
//!
|
||||||
|
//! **SYNTHETIC / L0.** Every structure here is part of a simulation scaffold. A
|
||||||
|
//! [`ZoneBaseline`] is a *learned model* of a location's normal physics; it
|
||||||
|
//! predicts what is normal, it never measures. No value it holds is a hardware,
|
||||||
|
//! `MEASURED`, or accuracy claim (ADR-282, ADR-300).
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use ruview_ontology::{EvidenceLevel, ZoneId};
|
||||||
|
use ruview_twin::{LinkId, ObservationSet};
|
||||||
|
|
||||||
|
use crate::error::MemoryError;
|
||||||
|
use crate::stat::RunningStat;
|
||||||
|
|
||||||
|
/// Hours in the occupancy-by-hour periodicity model (ADR-312 §1).
|
||||||
|
pub const HOURS_PER_DAY: usize = 24;
|
||||||
|
|
||||||
|
/// Upper bound on distinct zones a memory holds. Bounds allocation on untrusted
|
||||||
|
/// input (CLAUDE.md); construction beyond this is rejected, never truncated.
|
||||||
|
pub const MAX_ZONES: usize = 4096;
|
||||||
|
|
||||||
|
/// Upper bound on learned links per zone.
|
||||||
|
pub const MAX_LINKS_PER_ZONE: usize = 65_536;
|
||||||
|
|
||||||
|
/// Upper bound on modality signature channels per zone.
|
||||||
|
pub const MAX_MODALITY_CHANNELS: usize = 256;
|
||||||
|
|
||||||
|
/// Upper bound, in bytes, on a modality channel identifier.
|
||||||
|
pub const MAX_CHANNEL_ID_LEN: usize = 256;
|
||||||
|
|
||||||
|
/// Tuning of the learned-normal model. All fields are validated at construction
|
||||||
|
/// so no downstream computation can divide by zero or adapt on a nonsensical
|
||||||
|
/// factor.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct MemoryConfig {
|
||||||
|
/// Forgetting factor `λ ∈ [0, 1)` — the weight retained on history each
|
||||||
|
/// update; learning rate `α = 1 − λ`. Near `1` tracks only slow legitimate
|
||||||
|
/// drift; near `0` adapts fast. See [`crate::stat`].
|
||||||
|
pub forgetting_factor: f64,
|
||||||
|
/// Minimum updates a channel needs before it is scored; below this the
|
||||||
|
/// channel is [`Assessment::Unknown`](crate::Assessment::Unknown) — never a
|
||||||
|
/// false positive on thin history (ADR-300 rule 1).
|
||||||
|
pub min_history: u32,
|
||||||
|
/// Significance gate (standard deviations). A channel whose deviation meets
|
||||||
|
/// or exceeds this is flagged. Not a calibrated false-alarm rate — a model
|
||||||
|
/// gate (cf. ADR-315 `DEFAULT_SIGNIFICANCE_THRESHOLD`).
|
||||||
|
pub significance_threshold: f64,
|
||||||
|
/// Standard-deviation floor for the occupancy model, so an always-empty hour
|
||||||
|
/// (zero variance) yields finite significance rather than a divide-by-zero.
|
||||||
|
pub occupancy_floor_std: f64,
|
||||||
|
/// Standard-deviation floor (dB) for propagation and modality signatures.
|
||||||
|
pub signature_floor_std: f64,
|
||||||
|
/// Model version handle stamped into emitted evidence records (ADR-136).
|
||||||
|
pub model_version: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MemoryConfig {
|
||||||
|
/// A neutral SYNTHETIC default: `λ = 0.9` (learning rate 0.1), `min_history
|
||||||
|
/// = 8`, `3σ` gate, occupancy floor `0.1`, signature floor `1.0 dB`. Asserts
|
||||||
|
/// nothing about any real environment.
|
||||||
|
#[must_use]
|
||||||
|
pub fn default_synthetic() -> Self {
|
||||||
|
Self {
|
||||||
|
forgetting_factor: 0.9,
|
||||||
|
min_history: 8,
|
||||||
|
significance_threshold: 3.0,
|
||||||
|
occupancy_floor_std: 0.1,
|
||||||
|
signature_floor_std: 1.0,
|
||||||
|
model_version: "ruview-memory-scaffold@0 (SYNTHETIC/L0)".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate the configuration at the boundary. Never panics.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// [`MemoryError::InvalidConfig`] for any out-of-domain field.
|
||||||
|
pub fn validate(&self) -> Result<(), MemoryError> {
|
||||||
|
if !(self.forgetting_factor.is_finite() && (0.0..1.0).contains(&self.forgetting_factor)) {
|
||||||
|
return Err(MemoryError::InvalidConfig {
|
||||||
|
what: "forgetting_factor must be finite and in [0, 1)",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if self.min_history < 1 {
|
||||||
|
return Err(MemoryError::InvalidConfig {
|
||||||
|
what: "min_history must be >= 1",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if !(self.significance_threshold.is_finite() && self.significance_threshold > 0.0) {
|
||||||
|
return Err(MemoryError::InvalidConfig {
|
||||||
|
what: "significance_threshold must be finite and > 0",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if !(self.occupancy_floor_std.is_finite() && self.occupancy_floor_std > 0.0) {
|
||||||
|
return Err(MemoryError::InvalidConfig {
|
||||||
|
what: "occupancy_floor_std must be finite and > 0",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if !(self.signature_floor_std.is_finite() && self.signature_floor_std > 0.0) {
|
||||||
|
return Err(MemoryError::InvalidConfig {
|
||||||
|
what: "signature_floor_std must be finite and > 0",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if self.model_version.is_empty() || self.model_version.len() > MAX_CHANNEL_ID_LEN {
|
||||||
|
return Err(MemoryError::InvalidConfig {
|
||||||
|
what: "model_version must be non-empty and bounded",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// UTC hour-of-day derived deterministically from an injected Unix-ms timestamp.
|
||||||
|
/// Pure arithmetic on the caller-supplied value — no wall-clock is read. Handles
|
||||||
|
/// negative timestamps (pre-1970) via Euclidean remainder.
|
||||||
|
#[must_use]
|
||||||
|
pub fn hour_of_day_utc(at_unix_ms: i64) -> u8 {
|
||||||
|
let hours = at_unix_ms.div_euclid(3_600_000);
|
||||||
|
hours.rem_euclid(HOURS_PER_DAY as i64) as u8
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One learned per-link propagation statistic within a zone. Stored as a `Vec`
|
||||||
|
/// (not a map) so the whole baseline serializes to JSON — [`LinkId`] is a
|
||||||
|
/// struct, not a string key.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct LinkStat {
|
||||||
|
/// The link this statistic describes.
|
||||||
|
pub link: LinkId,
|
||||||
|
/// The learned normal RSSI distribution for the link.
|
||||||
|
pub stat: RunningStat,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A live snapshot of a zone used to score against, and then update, its learned
|
||||||
|
/// normal. Time is injected; nothing here samples a clock.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct ZoneObservation {
|
||||||
|
/// The zone this snapshot is for.
|
||||||
|
pub zone: ZoneId,
|
||||||
|
/// Producer-supplied capture time (Unix ms). Injected; the occupancy hour is
|
||||||
|
/// derived from it deterministically.
|
||||||
|
pub at_unix_ms: i64,
|
||||||
|
/// Occupancy indicator / count for this snapshot (`≥ 0`, finite).
|
||||||
|
pub occupancy: f64,
|
||||||
|
/// Per-link observed values (reusing the twin's [`ObservationSet`]
|
||||||
|
/// vocabulary), scored against the learned propagation baseline.
|
||||||
|
pub links: ObservationSet,
|
||||||
|
/// Coarse per-modality signature channels (e.g. `"vibration_rms"`), scored
|
||||||
|
/// against the learned modality baseline.
|
||||||
|
pub modality: BTreeMap<String, f64>,
|
||||||
|
/// Evidence level of the source observations. A learned baseline never rises
|
||||||
|
/// above the floor of these (ADR-282 no-upgrade).
|
||||||
|
pub evidence_level: EvidenceLevel,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ZoneObservation {
|
||||||
|
/// A snapshot with no links or modality channels yet.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(zone: ZoneId, at_unix_ms: i64, occupancy: f64, evidence_level: EvidenceLevel) -> Self {
|
||||||
|
Self {
|
||||||
|
zone,
|
||||||
|
at_unix_ms,
|
||||||
|
occupancy,
|
||||||
|
links: ObservationSet::new(),
|
||||||
|
modality: BTreeMap::new(),
|
||||||
|
evidence_level,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a link observation (builder style).
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_link(mut self, link: LinkId, value: f64) -> Self {
|
||||||
|
self.links = self.links.with(link, value);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a modality signature channel (builder style).
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_modality(mut self, channel: impl Into<String>, value: f64) -> Self {
|
||||||
|
self.modality.insert(channel.into(), value);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate the snapshot at the boundary: finite, non-negative occupancy;
|
||||||
|
/// finite link/modality values; bounded channel count and id length. Never
|
||||||
|
/// panics.
|
||||||
|
pub(crate) fn validate(&self) -> Result<(), MemoryError> {
|
||||||
|
if !self.occupancy.is_finite() {
|
||||||
|
return Err(MemoryError::NonFiniteValue { what: "occupancy" });
|
||||||
|
}
|
||||||
|
if self.occupancy < 0.0 {
|
||||||
|
return Err(MemoryError::NegativeOccupancy {
|
||||||
|
value: self.occupancy,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for obs in &self.links.observations {
|
||||||
|
if !obs.value.is_finite() {
|
||||||
|
return Err(MemoryError::NonFiniteValue { what: "link value" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if self.modality.len() > MAX_MODALITY_CHANNELS {
|
||||||
|
return Err(MemoryError::TooManyChannels {
|
||||||
|
max: MAX_MODALITY_CHANNELS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (channel, value) in &self.modality {
|
||||||
|
if channel.len() > MAX_CHANNEL_ID_LEN {
|
||||||
|
return Err(MemoryError::ChannelIdTooLong {
|
||||||
|
len: channel.len(),
|
||||||
|
max: MAX_CHANNEL_ID_LEN,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if !value.is_finite() {
|
||||||
|
return Err(MemoryError::NonFiniteValue {
|
||||||
|
what: "modality value",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The learned normal physics of one zone (ADR-312 §1): occupancy periodicity,
|
||||||
|
/// per-link RF propagation, and coarse per-modality signatures.
|
||||||
|
///
|
||||||
|
/// **SYNTHETIC / L0.** A learned model of normality, never a measurement.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct ZoneBaseline {
|
||||||
|
/// Occupancy distribution indexed by UTC hour-of-day (`0..24`).
|
||||||
|
occupancy_by_hour: [RunningStat; HOURS_PER_DAY],
|
||||||
|
/// Learned per-link propagation normal, in deterministic insertion order.
|
||||||
|
propagation: Vec<LinkStat>,
|
||||||
|
/// Learned per-channel modality normal.
|
||||||
|
modality: BTreeMap<String, RunningStat>,
|
||||||
|
/// Floor of the evidence levels the baseline was learned from; `None` until
|
||||||
|
/// the first observation. A learned normal is never above this.
|
||||||
|
evidence_floor: Option<EvidenceLevel>,
|
||||||
|
/// Total observations folded into this baseline.
|
||||||
|
updates: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ZoneBaseline {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ZoneBaseline {
|
||||||
|
/// An empty baseline with no history.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
occupancy_by_hour: [RunningStat::new(); HOURS_PER_DAY],
|
||||||
|
propagation: Vec::new(),
|
||||||
|
modality: BTreeMap::new(),
|
||||||
|
evidence_floor: None,
|
||||||
|
updates: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The occupancy statistic for a UTC hour (`0..24`).
|
||||||
|
#[must_use]
|
||||||
|
pub fn occupancy_hour(&self, hour: u8) -> &RunningStat {
|
||||||
|
&self.occupancy_by_hour[(hour as usize) % HOURS_PER_DAY]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The learned statistic for a link, if any.
|
||||||
|
#[must_use]
|
||||||
|
pub fn link_stat(&self, link: &LinkId) -> Option<&RunningStat> {
|
||||||
|
self.propagation
|
||||||
|
.iter()
|
||||||
|
.find(|ls| &ls.link == link)
|
||||||
|
.map(|ls| &ls.stat)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The learned statistic for a modality channel, if any.
|
||||||
|
#[must_use]
|
||||||
|
pub fn modality_stat(&self, channel: &str) -> Option<&RunningStat> {
|
||||||
|
self.modality.get(channel)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The floor of evidence levels this baseline was learned from, `None`
|
||||||
|
/// before any observation.
|
||||||
|
#[must_use]
|
||||||
|
pub fn evidence_floor(&self) -> Option<EvidenceLevel> {
|
||||||
|
self.evidence_floor
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Total observations folded into this baseline.
|
||||||
|
#[must_use]
|
||||||
|
pub fn updates(&self) -> u64 {
|
||||||
|
self.updates
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The learned links, read-only.
|
||||||
|
#[must_use]
|
||||||
|
pub fn links(&self) -> &[LinkStat] {
|
||||||
|
&self.propagation
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Seed (or overwrite) a link's baseline from a prior mean/variance — used to
|
||||||
|
/// anchor the propagation model on the twin's expected distribution. Bounded.
|
||||||
|
pub(crate) fn seed_link(
|
||||||
|
&mut self,
|
||||||
|
link: LinkId,
|
||||||
|
mean: f64,
|
||||||
|
variance: f64,
|
||||||
|
count: u32,
|
||||||
|
) -> Result<(), MemoryError> {
|
||||||
|
let seeded = RunningStat::seeded(mean, variance, count);
|
||||||
|
if let Some(ls) = self.propagation.iter_mut().find(|ls| ls.link == link) {
|
||||||
|
ls.stat = seeded;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if self.propagation.len() >= MAX_LINKS_PER_ZONE {
|
||||||
|
return Err(MemoryError::TooManyLinks {
|
||||||
|
max: MAX_LINKS_PER_ZONE,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
self.propagation.push(LinkStat { link, stat: seeded });
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mutable access to a link statistic, inserting a fresh one if absent.
|
||||||
|
/// Bounded — an over-capacity zone is rejected, never grown unbounded.
|
||||||
|
pub(crate) fn link_stat_mut(&mut self, link: &LinkId) -> Result<&mut RunningStat, MemoryError> {
|
||||||
|
if let Some(pos) = self.propagation.iter().position(|ls| &ls.link == link) {
|
||||||
|
return Ok(&mut self.propagation[pos].stat);
|
||||||
|
}
|
||||||
|
if self.propagation.len() >= MAX_LINKS_PER_ZONE {
|
||||||
|
return Err(MemoryError::TooManyLinks {
|
||||||
|
max: MAX_LINKS_PER_ZONE,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
self.propagation.push(LinkStat {
|
||||||
|
link: link.clone(),
|
||||||
|
stat: RunningStat::new(),
|
||||||
|
});
|
||||||
|
let last = self.propagation.len() - 1;
|
||||||
|
Ok(&mut self.propagation[last].stat)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mutable access to a modality statistic, inserting a fresh one if absent.
|
||||||
|
/// Bounded.
|
||||||
|
pub(crate) fn modality_stat_mut(
|
||||||
|
&mut self,
|
||||||
|
channel: &str,
|
||||||
|
) -> Result<&mut RunningStat, MemoryError> {
|
||||||
|
if !self.modality.contains_key(channel) && self.modality.len() >= MAX_MODALITY_CHANNELS {
|
||||||
|
return Err(MemoryError::TooManyChannels {
|
||||||
|
max: MAX_MODALITY_CHANNELS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(self
|
||||||
|
.modality
|
||||||
|
.entry(channel.to_string())
|
||||||
|
.or_insert_with(RunningStat::new))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fold one snapshot's occupancy into the hour bucket.
|
||||||
|
pub(crate) fn update_occupancy(&mut self, hour: u8, occupancy: f64, forgetting: f64) {
|
||||||
|
self.occupancy_by_hour[(hour as usize) % HOURS_PER_DAY].update(occupancy, forgetting);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lower the evidence floor to include a prior/source at `level`, without
|
||||||
|
/// counting it as an observation. Used to record the twin's SYNTHETIC/L0
|
||||||
|
/// prior when seeding a propagation baseline.
|
||||||
|
pub(crate) fn record_prior(&mut self, level: EvidenceLevel) {
|
||||||
|
self.evidence_floor = Some(match self.evidence_floor {
|
||||||
|
Some(existing) => existing.min(level),
|
||||||
|
None => level,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lower the evidence floor to include a new source observation, and bump the
|
||||||
|
/// update count.
|
||||||
|
pub(crate) fn record_source(&mut self, level: EvidenceLevel) {
|
||||||
|
self.record_prior(level);
|
||||||
|
self.updates = self.updates.saturating_add(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
57
v2/crates/ruview-memory/src/error.rs
Normal file
57
v2/crates/ruview-memory/src/error.rs
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
//! Boundary errors (ADR-312 / CLAUDE.md — validate untrusted input, never
|
||||||
|
//! panic).
|
||||||
|
//!
|
||||||
|
//! Malformed input yields one of these typed errors; nothing here panics.
|
||||||
|
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
/// Errors raised at the spatial-memory input boundaries.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Error)]
|
||||||
|
pub enum MemoryError {
|
||||||
|
/// A configuration field was out of its valid domain.
|
||||||
|
#[error("invalid config: {what}")]
|
||||||
|
InvalidConfig {
|
||||||
|
/// Human-readable reason.
|
||||||
|
what: &'static str,
|
||||||
|
},
|
||||||
|
/// A supplied value was non-finite (`NaN`/`inf`).
|
||||||
|
#[error("non-finite value: {what}")]
|
||||||
|
NonFiniteValue {
|
||||||
|
/// Which value.
|
||||||
|
what: &'static str,
|
||||||
|
},
|
||||||
|
/// Occupancy was negative.
|
||||||
|
#[error("occupancy must be >= 0, got {value}")]
|
||||||
|
NegativeOccupancy {
|
||||||
|
/// The rejected value.
|
||||||
|
value: f64,
|
||||||
|
},
|
||||||
|
/// More zones than [`MAX_ZONES`](crate::MAX_ZONES).
|
||||||
|
#[error("too many zones (max {max})")]
|
||||||
|
TooManyZones {
|
||||||
|
/// The enforced maximum.
|
||||||
|
max: usize,
|
||||||
|
},
|
||||||
|
/// More links in a zone than [`MAX_LINKS_PER_ZONE`](crate::MAX_LINKS_PER_ZONE).
|
||||||
|
#[error("too many links in a zone (max {max})")]
|
||||||
|
TooManyLinks {
|
||||||
|
/// The enforced maximum.
|
||||||
|
max: usize,
|
||||||
|
},
|
||||||
|
/// More modality channels than
|
||||||
|
/// [`MAX_MODALITY_CHANNELS`](crate::MAX_MODALITY_CHANNELS).
|
||||||
|
#[error("too many modality channels (max {max})")]
|
||||||
|
TooManyChannels {
|
||||||
|
/// The enforced maximum.
|
||||||
|
max: usize,
|
||||||
|
},
|
||||||
|
/// A modality channel id exceeded
|
||||||
|
/// [`MAX_CHANNEL_ID_LEN`](crate::MAX_CHANNEL_ID_LEN).
|
||||||
|
#[error("modality channel id length {len} exceeds maximum {max}")]
|
||||||
|
ChannelIdTooLong {
|
||||||
|
/// Actual length in bytes.
|
||||||
|
len: usize,
|
||||||
|
/// The enforced maximum.
|
||||||
|
max: usize,
|
||||||
|
},
|
||||||
|
}
|
||||||
653
v2/crates/ruview-memory/src/lib.rs
Normal file
653
v2/crates/ruview-memory/src/lib.rs
Normal file
@@ -0,0 +1,653 @@
|
|||||||
|
//! # `ruview-memory` — long-term spatial memory (ADR-312, ADR-300 phase 3)
|
||||||
|
//!
|
||||||
|
//! **SYNTHETIC / L0 — a simulation / model scaffold, not a measurement system.**
|
||||||
|
//!
|
||||||
|
//! This crate is a *research-forward primitive*: it learns the **normal physics
|
||||||
|
//! of a location** so anomalies surface as *deviations from a learned baseline
|
||||||
|
//! of normality* — without training a detector for every anomaly class. It is a
|
||||||
|
//! **model**, not a sensor. It predicts what is normal for a place and time and
|
||||||
|
//! flags a statistically significant delta; it never *measures* anything, and it
|
||||||
|
//! asserts **no** detection-accuracy, false-positive, or health/safety number
|
||||||
|
//! (ADR-282 bounded-claims discipline, ADR-312 evidence discipline, CLAUDE.md
|
||||||
|
//! honesty rule). A flagged deviation is a *candidate change to corroborate*,
|
||||||
|
//! never a confident detection and never a diagnosis.
|
||||||
|
//!
|
||||||
|
//! Following ADR-300 rule 1, *insufficient information* is a first-class value
|
||||||
|
//! ([`Assessment::Unknown`]), never an error and never a false positive: no
|
||||||
|
//! anomaly is ever flagged before a baseline exists.
|
||||||
|
//!
|
||||||
|
//! ## What "normal" is learned over (ADR-312 §1)
|
||||||
|
//!
|
||||||
|
//! Per ADR-306 [`ZoneId`], a [`ZoneBaseline`] accumulates:
|
||||||
|
//!
|
||||||
|
//! - **Occupancy periodicity** — a distribution of occupancy by UTC hour-of-day
|
||||||
|
//! (the "bedroom usually occupied certain hours" case).
|
||||||
|
//! - **RF-propagation signature** — a per-link learned normal, *anchored on the
|
||||||
|
//! twin's expected distributions* ([`SpatialMemory::seed_zone_propagation_from_twin`])
|
||||||
|
//! and refined online (the "chair moved" / "propagation changed" / "new
|
||||||
|
//! reflector" cases, which all surface as a per-link RSSI delta).
|
||||||
|
//! - **Coarse modality signatures** — per-channel learned normal (the "machine's
|
||||||
|
//! vibration signature changed" case).
|
||||||
|
//!
|
||||||
|
//! Each baseline updates **online** with a documented forgetting factor
|
||||||
|
//! ([`crate::stat`]); slow legitimate drift is absorbed into the baseline while
|
||||||
|
//! an abrupt change deviates from it. Every baseline carries the **floor**
|
||||||
|
//! evidence level of the observations it was learned from and is never presented
|
||||||
|
//! above them (ADR-282 no-upgrade).
|
||||||
|
//!
|
||||||
|
//! ## Anomaly = deviation from learned normal (ADR-312 §3)
|
||||||
|
//!
|
||||||
|
//! [`SpatialMemory::observe`] scores a live [`ZoneObservation`] against the
|
||||||
|
//! applicable learned baseline (matched by zone and hour), returns an
|
||||||
|
//! [`ObserveOutcome`] of per-channel [`Assessment`]s, and emits an
|
||||||
|
//! [`AnomalyEvent`] for each channel whose deviation meets the significance
|
||||||
|
//! gate. Anomalies project to append-only [`ruview_evidence`] records with
|
||||||
|
//! provenance ([`AnomalyEvent::to_evidence_record`]).
|
||||||
|
//!
|
||||||
|
//! ## The four ADR-300 non-negotiable rules, as they bind this crate
|
||||||
|
//!
|
||||||
|
//! 1. **UNKNOWN is first-class, never an error.** A channel with fewer than
|
||||||
|
//! `min_history` updates is [`Assessment::Unknown`]; `observe` is total and
|
||||||
|
//! never panics on malformed input (it returns a typed [`MemoryError`]).
|
||||||
|
//! 2. **Certificates bind cryptographically.** Out of scope here; a baseline is
|
||||||
|
//! keyed by an already-authenticated ADR-306 [`ZoneId`] and its evidence
|
||||||
|
//! level is the floor of its source observations.
|
||||||
|
//! 3. **One canonical semantics.** The memory reuses the canonical
|
||||||
|
//! [`ZoneId`]/[`EvidenceLevel`]/[`SemanticProvenance`] vocabulary, the twin's
|
||||||
|
//! [`LinkId`]/[`ObservationSet`]/[`ExpectedDistribution`], and the
|
||||||
|
//! [`ruview_evidence`] ledger, rather than reinventing per-crate shapes.
|
||||||
|
//! 4. **Honest evidence.** Every emitted record is **synthetic** (forced L0);
|
||||||
|
//! the deviation is carried as `drift` and its significance as `uncertainty`.
|
||||||
|
//! No accuracy rate is fabricated.
|
||||||
|
//!
|
||||||
|
//! ## Determinism
|
||||||
|
//!
|
||||||
|
//! Everything is deterministic: injected time (no wall-clock), no randomness
|
||||||
|
//! (synthetic scenes vary only by the twin's explicit seed), fixed iteration
|
||||||
|
//! order, and bounded allocation. The same observations in the same order always
|
||||||
|
//! produce the same state and the same anomalies.
|
||||||
|
//!
|
||||||
|
//! ```
|
||||||
|
//! use ruview_memory::*;
|
||||||
|
//! use ruview_ontology::{EvidenceLevel, ZoneId};
|
||||||
|
//! use ruview_twin::{synthetic_deployment, RfTwin, ObservationSet};
|
||||||
|
//!
|
||||||
|
//! let mut mem = SpatialMemory::new(MemoryConfig::default_synthetic()).unwrap();
|
||||||
|
//! let zone = ZoneId::new("bedroom").unwrap();
|
||||||
|
//! let twin = RfTwin::build(synthetic_deployment(7)).unwrap();
|
||||||
|
//!
|
||||||
|
//! // Anchor the propagation baseline on the twin's expected distributions.
|
||||||
|
//! mem.seed_zone_propagation_from_twin(zone.clone(), &twin).unwrap();
|
||||||
|
//!
|
||||||
|
//! // A snapshot that matches the twin's predictions is normal, not an anomaly.
|
||||||
|
//! let mut obs = ZoneObservation::new(zone, 0, 1.0, EvidenceLevel::L0);
|
||||||
|
//! obs.links = ObservationSet::from_twin_prediction(&twin);
|
||||||
|
//! let outcome = mem.observe(&obs).unwrap();
|
||||||
|
//! assert!(!outcome.has_anomaly());
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
#![forbid(unsafe_code)]
|
||||||
|
#![warn(missing_docs)]
|
||||||
|
|
||||||
|
mod anomaly;
|
||||||
|
mod baseline;
|
||||||
|
mod error;
|
||||||
|
mod stat;
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use ruview_evidence::{EvidenceError, EvidenceLedger};
|
||||||
|
use ruview_twin::{predict_link, ExpectedDistribution, RfTwin};
|
||||||
|
|
||||||
|
pub use anomaly::{AnomalyEvent, AnomalyKind, Assessment};
|
||||||
|
pub use baseline::{
|
||||||
|
hour_of_day_utc, LinkStat, MemoryConfig, ZoneBaseline, ZoneObservation, HOURS_PER_DAY,
|
||||||
|
MAX_CHANNEL_ID_LEN, MAX_LINKS_PER_ZONE, MAX_MODALITY_CHANNELS, MAX_ZONES,
|
||||||
|
};
|
||||||
|
pub use error::MemoryError;
|
||||||
|
pub use stat::RunningStat;
|
||||||
|
|
||||||
|
// Re-export the canonical vocabulary consumers speak (ADR-300 rule 3, ADR-306),
|
||||||
|
// and the twin's link type the propagation model is keyed by.
|
||||||
|
pub use ruview_ontology::{EvidenceLevel, SemanticProvenance, ZoneId};
|
||||||
|
pub use ruview_twin::LinkId;
|
||||||
|
|
||||||
|
/// The provenance stamped on every anomaly this scaffold emits.
|
||||||
|
const PROVENANCE_MODEL: &str = "ruview-memory@0 (SYNTHETIC/L0)";
|
||||||
|
|
||||||
|
/// The learned normal physics of every zone, and the operation that scores a
|
||||||
|
/// live snapshot against it (ADR-312).
|
||||||
|
///
|
||||||
|
/// **SYNTHETIC / L0.** A learned model of normality, never a measurement.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct SpatialMemory {
|
||||||
|
config: MemoryConfig,
|
||||||
|
zones: BTreeMap<ZoneId, ZoneBaseline>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SpatialMemory {
|
||||||
|
/// Build an empty memory with the given configuration, validated at the
|
||||||
|
/// boundary.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// [`MemoryError::InvalidConfig`] for an out-of-domain configuration.
|
||||||
|
pub fn new(config: MemoryConfig) -> Result<Self, MemoryError> {
|
||||||
|
config.validate()?;
|
||||||
|
Ok(Self {
|
||||||
|
config,
|
||||||
|
zones: BTreeMap::new(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The active configuration.
|
||||||
|
#[must_use]
|
||||||
|
pub fn config(&self) -> &MemoryConfig {
|
||||||
|
&self.config
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The learned baseline for a zone, if one exists.
|
||||||
|
#[must_use]
|
||||||
|
pub fn baseline(&self, zone: &ZoneId) -> Option<&ZoneBaseline> {
|
||||||
|
self.zones.get(zone)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The number of zones with a learned baseline.
|
||||||
|
#[must_use]
|
||||||
|
pub fn zone_count(&self) -> usize {
|
||||||
|
self.zones.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Anchor a zone's propagation baseline on a twin's expected distributions
|
||||||
|
/// (ADR-312 §1). Each link the twin can predict seeds a learned statistic
|
||||||
|
/// with the twin's modelled mean/variance and a prior weight of
|
||||||
|
/// `min_history`, so the propagation model is usable immediately as a prior
|
||||||
|
/// and then refined online. The twin prior is SYNTHETIC/L0, so the zone's
|
||||||
|
/// evidence floor is lowered to L0.
|
||||||
|
///
|
||||||
|
/// Returns the number of links seeded.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// [`MemoryError::TooManyZones`] / [`MemoryError::TooManyLinks`] at the
|
||||||
|
/// bounded-allocation limits.
|
||||||
|
pub fn seed_zone_propagation_from_twin(
|
||||||
|
&mut self,
|
||||||
|
zone: ZoneId,
|
||||||
|
twin: &RfTwin,
|
||||||
|
) -> Result<usize, MemoryError> {
|
||||||
|
self.ensure_zone(&zone)?;
|
||||||
|
let min_history = self.config.min_history;
|
||||||
|
let baseline = self.zones.get_mut(&zone).expect("zone ensured above");
|
||||||
|
let mut seeded = 0;
|
||||||
|
for link in twin.links() {
|
||||||
|
if let ExpectedDistribution::Known { mean, variance, .. } = predict_link(twin, &link) {
|
||||||
|
baseline.seed_link(link, mean, variance, min_history)?;
|
||||||
|
seeded += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
baseline.record_prior(EvidenceLevel::L0);
|
||||||
|
Ok(seeded)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Score a live snapshot against the zone's learned normal, then fold it into
|
||||||
|
/// the baseline (ADR-312 §3). Scoring uses the baseline learned *before* this
|
||||||
|
/// snapshot, so a flagged anomaly is a genuine deviation and the current
|
||||||
|
/// value does not mask itself. Deterministic; never panics on malformed
|
||||||
|
/// input.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// [`MemoryError`] for non-finite/negative input or a bounded-allocation
|
||||||
|
/// limit; the memory is left unchanged when an error is returned.
|
||||||
|
pub fn observe(&mut self, obs: &ZoneObservation) -> Result<ObserveOutcome, MemoryError> {
|
||||||
|
obs.validate()?;
|
||||||
|
self.ensure_zone(&obs.zone)?;
|
||||||
|
|
||||||
|
let hour = hour_of_day_utc(obs.at_unix_ms);
|
||||||
|
let cfg = self.config.clone();
|
||||||
|
let baseline = self.zones.get_mut(&obs.zone).expect("zone ensured above");
|
||||||
|
|
||||||
|
// Evidence level attributed to any anomaly: the floor of the source
|
||||||
|
// observations that formed the baseline, never above the current source.
|
||||||
|
let source_level = baseline.evidence_floor().unwrap_or(obs.evidence_level);
|
||||||
|
|
||||||
|
// --- Read phase: copy stats out, score against the prior baseline. ---
|
||||||
|
let occ_stat = *baseline.occupancy_hour(hour);
|
||||||
|
let occupancy = assess(
|
||||||
|
Some(occ_stat),
|
||||||
|
obs.occupancy,
|
||||||
|
cfg.occupancy_floor_std,
|
||||||
|
cfg.min_history,
|
||||||
|
cfg.significance_threshold,
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut propagation: Vec<(LinkId, Assessment)> =
|
||||||
|
Vec::with_capacity(obs.links.observations.len());
|
||||||
|
for lo in &obs.links.observations {
|
||||||
|
let stat = baseline.link_stat(&lo.link).copied();
|
||||||
|
let a = assess(
|
||||||
|
stat,
|
||||||
|
lo.value,
|
||||||
|
cfg.signature_floor_std,
|
||||||
|
cfg.min_history,
|
||||||
|
cfg.significance_threshold,
|
||||||
|
);
|
||||||
|
propagation.push((lo.link.clone(), a));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut modality: Vec<(String, Assessment)> = Vec::with_capacity(obs.modality.len());
|
||||||
|
for (channel, value) in &obs.modality {
|
||||||
|
let stat = baseline.modality_stat(channel).copied();
|
||||||
|
let a = assess(
|
||||||
|
stat,
|
||||||
|
*value,
|
||||||
|
cfg.signature_floor_std,
|
||||||
|
cfg.min_history,
|
||||||
|
cfg.significance_threshold,
|
||||||
|
);
|
||||||
|
modality.push((channel.clone(), a));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Collect anomalies from the assessments made above. ---
|
||||||
|
let mut anomalies = Vec::new();
|
||||||
|
if let Assessment::Anomalous { significance } = occupancy {
|
||||||
|
anomalies.push(make_event(
|
||||||
|
obs.zone.clone(),
|
||||||
|
AnomalyKind::UnusualOccupancyHour,
|
||||||
|
obs.at_unix_ms,
|
||||||
|
hour,
|
||||||
|
format!("hour={hour}"),
|
||||||
|
obs.occupancy,
|
||||||
|
&occ_stat,
|
||||||
|
significance,
|
||||||
|
source_level,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
for (lo, (link, a)) in obs.links.observations.iter().zip(propagation.iter()) {
|
||||||
|
if let Assessment::Anomalous { significance } = a {
|
||||||
|
let stat = baseline.link_stat(link).copied().unwrap_or_default();
|
||||||
|
anomalies.push(make_event(
|
||||||
|
obs.zone.clone(),
|
||||||
|
AnomalyKind::PropagationChange,
|
||||||
|
obs.at_unix_ms,
|
||||||
|
hour,
|
||||||
|
format!("link={}~{}", link.a, link.b),
|
||||||
|
lo.value,
|
||||||
|
&stat,
|
||||||
|
*significance,
|
||||||
|
source_level,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for ((channel, value), (_, a)) in obs.modality.iter().zip(modality.iter()) {
|
||||||
|
if let Assessment::Anomalous { significance } = a {
|
||||||
|
let stat = baseline.modality_stat(channel).copied().unwrap_or_default();
|
||||||
|
anomalies.push(make_event(
|
||||||
|
obs.zone.clone(),
|
||||||
|
AnomalyKind::ModalityChange,
|
||||||
|
obs.at_unix_ms,
|
||||||
|
hour,
|
||||||
|
format!("channel={channel}"),
|
||||||
|
*value,
|
||||||
|
&stat,
|
||||||
|
*significance,
|
||||||
|
source_level,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Write phase: fold the snapshot into the baseline. ---
|
||||||
|
baseline.update_occupancy(hour, obs.occupancy, cfg.forgetting_factor);
|
||||||
|
for lo in &obs.links.observations {
|
||||||
|
baseline
|
||||||
|
.link_stat_mut(&lo.link)?
|
||||||
|
.update(lo.value, cfg.forgetting_factor);
|
||||||
|
}
|
||||||
|
for (channel, value) in &obs.modality {
|
||||||
|
baseline
|
||||||
|
.modality_stat_mut(channel)?
|
||||||
|
.update(*value, cfg.forgetting_factor);
|
||||||
|
}
|
||||||
|
baseline.record_source(obs.evidence_level);
|
||||||
|
|
||||||
|
Ok(ObserveOutcome {
|
||||||
|
zone: obs.zone.clone(),
|
||||||
|
hour_of_day: hour,
|
||||||
|
occupancy,
|
||||||
|
propagation,
|
||||||
|
modality,
|
||||||
|
anomalies,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Append each anomaly in an outcome to an [`EvidenceLedger`] as a synthetic
|
||||||
|
/// record (ADR-312: "emit anomalies as evidence records with provenance").
|
||||||
|
/// Returns the ledger sequence assigned to each record, in order.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Propagates [`EvidenceError`] from record construction or a full ledger.
|
||||||
|
pub fn record_anomalies(
|
||||||
|
&self,
|
||||||
|
outcome: &ObserveOutcome,
|
||||||
|
ledger: &mut EvidenceLedger,
|
||||||
|
timestamp_ns: u64,
|
||||||
|
) -> Result<Vec<u64>, EvidenceError> {
|
||||||
|
let mut seqs = Vec::with_capacity(outcome.anomalies.len());
|
||||||
|
for ev in &outcome.anomalies {
|
||||||
|
let record = ev.to_evidence_record(&self.config.model_version, timestamp_ns)?;
|
||||||
|
seqs.push(ledger.append(record)?);
|
||||||
|
}
|
||||||
|
Ok(seqs)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ensure a zone has a baseline, respecting the bounded-allocation cap.
|
||||||
|
fn ensure_zone(&mut self, zone: &ZoneId) -> Result<(), MemoryError> {
|
||||||
|
if !self.zones.contains_key(zone) {
|
||||||
|
if self.zones.len() >= MAX_ZONES {
|
||||||
|
return Err(MemoryError::TooManyZones { max: MAX_ZONES });
|
||||||
|
}
|
||||||
|
self.zones.insert(zone.clone(), ZoneBaseline::new());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Score one value against its (optional) learned statistic. UNKNOWN when there
|
||||||
|
/// is no statistic or its history is below `min_history` (ADR-300 rule 1).
|
||||||
|
fn assess(
|
||||||
|
stat: Option<RunningStat>,
|
||||||
|
x: f64,
|
||||||
|
floor: f64,
|
||||||
|
min_history: u32,
|
||||||
|
threshold: f64,
|
||||||
|
) -> Assessment {
|
||||||
|
match stat {
|
||||||
|
Some(s) if s.count() >= min_history => {
|
||||||
|
let significance = s.significance(x, floor);
|
||||||
|
if significance >= threshold {
|
||||||
|
Assessment::Anomalous { significance }
|
||||||
|
} else {
|
||||||
|
Assessment::Normal { significance }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => Assessment::Unknown,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Assemble an [`AnomalyEvent`] from a flagged channel and its baseline stat.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn make_event(
|
||||||
|
zone: ZoneId,
|
||||||
|
kind: AnomalyKind,
|
||||||
|
at_unix_ms: i64,
|
||||||
|
hour_of_day: u8,
|
||||||
|
detail: String,
|
||||||
|
observed: f64,
|
||||||
|
stat: &RunningStat,
|
||||||
|
significance: f64,
|
||||||
|
evidence_level: EvidenceLevel,
|
||||||
|
) -> AnomalyEvent {
|
||||||
|
AnomalyEvent {
|
||||||
|
zone,
|
||||||
|
kind,
|
||||||
|
at_unix_ms,
|
||||||
|
hour_of_day,
|
||||||
|
detail,
|
||||||
|
observed,
|
||||||
|
baseline_mean: stat.mean(),
|
||||||
|
significance,
|
||||||
|
baseline_count: stat.count(),
|
||||||
|
evidence_level,
|
||||||
|
provenance: SemanticProvenance::declared(PROVENANCE_MODEL),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The result of scoring one snapshot against a zone's learned normal.
|
||||||
|
///
|
||||||
|
/// **SYNTHETIC / L0.** Per-channel [`Assessment`]s and the derived
|
||||||
|
/// [`AnomalyEvent`]s are model-relative summaries, not detections.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct ObserveOutcome {
|
||||||
|
/// The zone scored.
|
||||||
|
pub zone: ZoneId,
|
||||||
|
/// UTC hour-of-day the snapshot was attributed to.
|
||||||
|
pub hour_of_day: u8,
|
||||||
|
/// Occupancy assessment for that hour.
|
||||||
|
pub occupancy: Assessment,
|
||||||
|
/// Per-link propagation assessments, in observation order.
|
||||||
|
pub propagation: Vec<(LinkId, Assessment)>,
|
||||||
|
/// Per-channel modality assessments, in channel order.
|
||||||
|
pub modality: Vec<(String, Assessment)>,
|
||||||
|
/// Flagged deviations, derived from the anomalous assessments above.
|
||||||
|
pub anomalies: Vec<AnomalyEvent>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ObserveOutcome {
|
||||||
|
/// True when any channel deviated beyond the significance gate.
|
||||||
|
#[must_use]
|
||||||
|
pub fn has_anomaly(&self) -> bool {
|
||||||
|
!self.anomalies.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The distinct anomaly kinds flagged, in first-seen order.
|
||||||
|
#[must_use]
|
||||||
|
pub fn anomaly_kinds(&self) -> Vec<AnomalyKind> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for ev in &self.anomalies {
|
||||||
|
if !out.contains(&ev.kind) {
|
||||||
|
out.push(ev.kind);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use ruview_evidence::{EvidenceContext, EvidenceLevel as EvLevel, EvidenceLedger};
|
||||||
|
use ruview_twin::{synthetic_deployment, LinkId, ObservationSet, RfTwin, SensorId};
|
||||||
|
|
||||||
|
fn cfg() -> MemoryConfig {
|
||||||
|
MemoryConfig::default_synthetic()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn zone() -> ZoneId {
|
||||||
|
ZoneId::new("bedroom").unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sensor(id: &str) -> SensorId {
|
||||||
|
SensorId::new(id).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// UTC-ms for a given day index and hour, so occupancy buckets are addressable
|
||||||
|
/// deterministically without a wall-clock.
|
||||||
|
fn ts(day: i64, hour: i64) -> i64 {
|
||||||
|
day * 86_400_000 + hour * 3_600_000
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_anomaly_within_normal_variation() {
|
||||||
|
let mut mem = SpatialMemory::new(cfg()).unwrap();
|
||||||
|
let twin = RfTwin::build(synthetic_deployment(7)).unwrap();
|
||||||
|
mem.seed_zone_propagation_from_twin(zone(), &twin).unwrap();
|
||||||
|
let predicted = ObservationSet::from_twin_prediction(&twin);
|
||||||
|
|
||||||
|
let mut last = None;
|
||||||
|
for day in 0..12 {
|
||||||
|
let mut obs = ZoneObservation::new(zone(), ts(day, 14), 1.0, EvidenceLevel::L0);
|
||||||
|
obs.links = predicted.clone();
|
||||||
|
let outcome = mem.observe(&obs).unwrap();
|
||||||
|
// A snapshot matching the twin prediction never flags an anomaly.
|
||||||
|
assert!(!outcome.has_anomaly(), "day {day} should be within normal variation");
|
||||||
|
// Propagation is assessable from the twin prior and stays Normal.
|
||||||
|
assert!(outcome.propagation.iter().all(|(_, a)| !a.is_anomalous()));
|
||||||
|
last = Some(outcome);
|
||||||
|
}
|
||||||
|
// After enough history the occupancy bucket is Normal (not Unknown).
|
||||||
|
let last = last.unwrap();
|
||||||
|
assert!(matches!(last.occupancy, Assessment::Normal { .. }));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn flags_a_propagation_signature_delta() {
|
||||||
|
let mut mem = SpatialMemory::new(cfg()).unwrap();
|
||||||
|
let twin = RfTwin::build(synthetic_deployment(2)).unwrap();
|
||||||
|
mem.seed_zone_propagation_from_twin(zone(), &twin).unwrap();
|
||||||
|
|
||||||
|
// One link observed far from its twin-anchored normal (a "chair moved" /
|
||||||
|
// "new reflector" style propagation change).
|
||||||
|
let target = twin.links()[0].clone();
|
||||||
|
let predicted_mean = mem
|
||||||
|
.baseline(&zone())
|
||||||
|
.unwrap()
|
||||||
|
.link_stat(&target)
|
||||||
|
.unwrap()
|
||||||
|
.mean();
|
||||||
|
let mut obs = ZoneObservation::new(zone(), ts(0, 12), 1.0, EvidenceLevel::L1);
|
||||||
|
obs.links = ObservationSet::new().with(target.clone(), predicted_mean + 30.0);
|
||||||
|
|
||||||
|
let outcome = mem.observe(&obs).unwrap();
|
||||||
|
assert!(outcome.has_anomaly());
|
||||||
|
assert!(outcome.anomaly_kinds().contains(&AnomalyKind::PropagationChange));
|
||||||
|
let (_, a) = &outcome.propagation[0];
|
||||||
|
assert!(a.is_anomalous());
|
||||||
|
// The anomaly's evidence level is the SYNTHETIC/L0 twin-prior floor,
|
||||||
|
// never above the source.
|
||||||
|
let ev = &outcome.anomalies[0];
|
||||||
|
assert_eq!(ev.kind, AnomalyKind::PropagationChange);
|
||||||
|
assert_eq!(ev.evidence_level, EvidenceLevel::L0);
|
||||||
|
assert!(ev.significance >= cfg().significance_threshold);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn flags_off_hours_occupancy() {
|
||||||
|
let mut mem = SpatialMemory::new(cfg()).unwrap();
|
||||||
|
|
||||||
|
// Learn that hour 3 (night) is normally unoccupied, and hour 14 (day) is
|
||||||
|
// normally occupied.
|
||||||
|
for day in 0..12 {
|
||||||
|
let night = ZoneObservation::new(zone(), ts(day, 3), 0.0, EvidenceLevel::L2);
|
||||||
|
let day_obs = ZoneObservation::new(zone(), ts(day, 14), 1.0, EvidenceLevel::L2);
|
||||||
|
assert!(!mem.observe(&night).unwrap().has_anomaly());
|
||||||
|
assert!(!mem.observe(&day_obs).unwrap().has_anomaly());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Occupied at 3am: an unusual-occupancy-hour deviation.
|
||||||
|
let off = ZoneObservation::new(zone(), ts(99, 3), 1.0, EvidenceLevel::L2);
|
||||||
|
let outcome = mem.observe(&off).unwrap();
|
||||||
|
assert!(outcome.occupancy.is_anomalous());
|
||||||
|
assert!(outcome.anomaly_kinds().contains(&AnomalyKind::UnusualOccupancyHour));
|
||||||
|
|
||||||
|
// Occupied at 2pm is normal, not flagged.
|
||||||
|
let normal = ZoneObservation::new(zone(), ts(100, 14), 1.0, EvidenceLevel::L2);
|
||||||
|
let outcome = mem.observe(&normal).unwrap();
|
||||||
|
assert!(matches!(outcome.occupancy, Assessment::Normal { .. }));
|
||||||
|
assert!(!outcome.has_anomaly());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn insufficient_history_is_unknown_not_false_positive() {
|
||||||
|
let mut mem = SpatialMemory::new(cfg()).unwrap();
|
||||||
|
|
||||||
|
// No baseline anywhere, and a wildly off snapshot: every channel is
|
||||||
|
// UNKNOWN (first-class), and nothing is flagged.
|
||||||
|
let obs = ZoneObservation::new(zone(), ts(0, 3), 999.0, EvidenceLevel::L2)
|
||||||
|
.with_link(LinkId::new(sensor("a"), sensor("b")), -999.0)
|
||||||
|
.with_modality("vibration_rms", 999.0);
|
||||||
|
let outcome = mem.observe(&obs).unwrap();
|
||||||
|
|
||||||
|
assert!(outcome.occupancy.is_unknown());
|
||||||
|
assert!(outcome.propagation.iter().all(|(_, a)| a.is_unknown()));
|
||||||
|
assert!(outcome.modality.iter().all(|(_, a)| a.is_unknown()));
|
||||||
|
assert!(!outcome.has_anomaly(), "must not false-positive on thin history");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn baseline_update_is_deterministic_and_serde_round_trips() {
|
||||||
|
let build = || {
|
||||||
|
let mut mem = SpatialMemory::new(cfg()).unwrap();
|
||||||
|
let twin = RfTwin::build(synthetic_deployment(5)).unwrap();
|
||||||
|
mem.seed_zone_propagation_from_twin(zone(), &twin).unwrap();
|
||||||
|
let predicted = ObservationSet::from_twin_prediction(&twin);
|
||||||
|
for day in 0..10 {
|
||||||
|
let mut obs = ZoneObservation::new(zone(), ts(day, 9), 1.0, EvidenceLevel::L1);
|
||||||
|
obs.links = predicted.clone();
|
||||||
|
obs.modality.insert("vibration_rms".into(), 0.5);
|
||||||
|
mem.observe(&obs).unwrap();
|
||||||
|
}
|
||||||
|
mem
|
||||||
|
};
|
||||||
|
|
||||||
|
// Same inputs in the same order ⇒ bitwise-identical learned state. (The
|
||||||
|
// update recursion is a pure, deterministic function of the input stream;
|
||||||
|
// see `stat::tests` for the per-statistic proof.)
|
||||||
|
let a = build();
|
||||||
|
let b = build();
|
||||||
|
assert_eq!(a, b, "same observations in the same order ⇒ identical state");
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&a).unwrap();
|
||||||
|
let back: SpatialMemory = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(a, back);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn flags_a_modality_signature_delta() {
|
||||||
|
let mut mem = SpatialMemory::new(cfg()).unwrap();
|
||||||
|
|
||||||
|
// Learn a normal vibration signature (constant baseline).
|
||||||
|
for day in 0..12 {
|
||||||
|
let obs = ZoneObservation::new(zone(), ts(day, 10), 1.0, EvidenceLevel::L2)
|
||||||
|
.with_modality("vibration_rms", 0.5);
|
||||||
|
assert!(!mem.observe(&obs).unwrap().has_anomaly());
|
||||||
|
}
|
||||||
|
|
||||||
|
// A machine whose vibration signature changed: a modality deviation.
|
||||||
|
let obs = ZoneObservation::new(zone(), ts(99, 10), 1.0, EvidenceLevel::L2)
|
||||||
|
.with_modality("vibration_rms", 5.0);
|
||||||
|
let outcome = mem.observe(&obs).unwrap();
|
||||||
|
assert!(outcome.anomaly_kinds().contains(&AnomalyKind::ModalityChange));
|
||||||
|
assert!(outcome.modality[0].1.is_anomalous());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn anomalies_emit_synthetic_evidence_records_with_provenance() {
|
||||||
|
let mut mem = SpatialMemory::new(cfg()).unwrap();
|
||||||
|
let twin = RfTwin::build(synthetic_deployment(3)).unwrap();
|
||||||
|
mem.seed_zone_propagation_from_twin(zone(), &twin).unwrap();
|
||||||
|
|
||||||
|
let target = twin.links()[0].clone();
|
||||||
|
let predicted_mean = mem
|
||||||
|
.baseline(&zone())
|
||||||
|
.unwrap()
|
||||||
|
.link_stat(&target)
|
||||||
|
.unwrap()
|
||||||
|
.mean();
|
||||||
|
let mut obs = ZoneObservation::new(zone(), ts(0, 12), 1.0, EvidenceLevel::L1);
|
||||||
|
obs.links = ObservationSet::new().with(target.clone(), predicted_mean - 40.0);
|
||||||
|
let outcome = mem.observe(&obs).unwrap();
|
||||||
|
assert!(outcome.has_anomaly());
|
||||||
|
|
||||||
|
let mut ledger = EvidenceLedger::new();
|
||||||
|
let seqs = mem.record_anomalies(&outcome, &mut ledger, 42).unwrap();
|
||||||
|
assert_eq!(seqs.len(), outcome.anomalies.len());
|
||||||
|
assert!(!ledger.is_empty());
|
||||||
|
|
||||||
|
let ev = &outcome.anomalies[0];
|
||||||
|
let ctx = EvidenceContext::new(
|
||||||
|
ev.zone.as_str(),
|
||||||
|
"spatial-memory-scaffold",
|
||||||
|
format!("anomaly:{}", ev.kind.label()),
|
||||||
|
&mem.config().model_version,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let slice = ledger.query(&ctx);
|
||||||
|
assert_eq!(slice.len(), 1);
|
||||||
|
let rec = slice.records()[0];
|
||||||
|
// Emitted evidence is honest: synthetic, forced L0.
|
||||||
|
assert_eq!(rec.level(), EvLevel::L0);
|
||||||
|
// The deviation magnitude is carried as drift.
|
||||||
|
assert!((rec.metrics().drift - ev.deviation().abs()).abs() < 1e-9);
|
||||||
|
assert!((rec.metrics().uncertainty - ev.significance).abs() < 1e-9);
|
||||||
|
}
|
||||||
|
}
|
||||||
185
v2/crates/ruview-memory/src/stat.rs
Normal file
185
v2/crates/ruview-memory/src/stat.rs
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
//! Online, forgetting running statistics (ADR-312 §2 — continuously learned
|
||||||
|
//! baseline).
|
||||||
|
//!
|
||||||
|
//! **SYNTHETIC / L0.** A [`RunningStat`] is a bounded, deterministic model of a
|
||||||
|
//! single scalar's *normal* value: an exponentially weighted mean and variance
|
||||||
|
//! that update online with a documented **forgetting factor**. It is part of a
|
||||||
|
//! simulation scaffold — it estimates a modelled normal, it never *measures*
|
||||||
|
//! anything, and it makes no accuracy claim (ADR-282 L0, ADR-300 evidence
|
||||||
|
//! discipline).
|
||||||
|
//!
|
||||||
|
//! ## The forgetting factor
|
||||||
|
//!
|
||||||
|
//! The forgetting factor `λ ∈ [0, 1)` is the weight retained on accumulated
|
||||||
|
//! history at each update; the effective learning rate is `α = 1 − λ`. A value
|
||||||
|
//! near `1` adapts slowly (long memory, tracks only slow legitimate drift); a
|
||||||
|
//! value near `0` adapts fast (short memory). Update rule (the standard
|
||||||
|
//! exponentially weighted moving mean/variance):
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! diff = x − mean
|
||||||
|
//! incr = α · diff
|
||||||
|
//! mean ← mean + incr
|
||||||
|
//! var ← λ · (var + diff · incr) // = λ·(var + α·diff²) ≥ 0
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! The recursion keeps `var ≥ 0` exactly, so `sqrt` is always defined. There is
|
||||||
|
//! no wall-clock and no randomness anywhere: the same inputs in the same order
|
||||||
|
//! always yield the same state (ADR-300 determinism discipline).
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// An exponentially weighted running mean/variance with an update count.
|
||||||
|
///
|
||||||
|
/// **SYNTHETIC / L0.** A modelled estimate of a scalar's normal value, never a
|
||||||
|
/// measurement.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct RunningStat {
|
||||||
|
mean: f64,
|
||||||
|
var: f64,
|
||||||
|
count: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for RunningStat {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RunningStat {
|
||||||
|
/// A fresh statistic with no history (`count == 0`).
|
||||||
|
#[must_use]
|
||||||
|
pub const fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
mean: 0.0,
|
||||||
|
var: 0.0,
|
||||||
|
count: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A statistic pre-seeded from an external prior — used to anchor a
|
||||||
|
/// propagation baseline on the twin's expected distribution (ADR-312 §1:
|
||||||
|
/// "RF-propagation baseline … via the twin's expected distributions").
|
||||||
|
///
|
||||||
|
/// `count` is the synthetic prior weight; a non-finite mean/variance is
|
||||||
|
/// clamped to a valid `(mean = if finite else 0, var = max(0))` so the
|
||||||
|
/// seeded stat never carries a poisoned value.
|
||||||
|
#[must_use]
|
||||||
|
pub fn seeded(mean: f64, var: f64, count: u32) -> Self {
|
||||||
|
Self {
|
||||||
|
mean: if mean.is_finite() { mean } else { 0.0 },
|
||||||
|
var: if var.is_finite() { var.max(0.0) } else { 0.0 },
|
||||||
|
count,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fold one observation into the estimate with the given forgetting factor
|
||||||
|
/// `λ ∈ [0, 1)`. Deterministic; total (never panics). The first observation
|
||||||
|
/// seeds the mean exactly and leaves the variance at zero.
|
||||||
|
pub fn update(&mut self, x: f64, forgetting: f64) {
|
||||||
|
if !x.is_finite() {
|
||||||
|
return; // malformed value is ignored, never panics or poisons state
|
||||||
|
}
|
||||||
|
if self.count == 0 {
|
||||||
|
self.mean = x;
|
||||||
|
self.var = 0.0;
|
||||||
|
self.count = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let alpha = 1.0 - forgetting;
|
||||||
|
let diff = x - self.mean;
|
||||||
|
let incr = alpha * diff;
|
||||||
|
self.mean += incr;
|
||||||
|
// λ·(var + α·diff²): the α·diff² term is non-negative, so var stays ≥ 0.
|
||||||
|
self.var = forgetting * (self.var + diff * incr);
|
||||||
|
if !self.var.is_finite() {
|
||||||
|
self.var = 0.0;
|
||||||
|
}
|
||||||
|
self.count = self.count.saturating_add(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The current modelled mean.
|
||||||
|
#[must_use]
|
||||||
|
pub fn mean(&self) -> f64 {
|
||||||
|
self.mean
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The current modelled (exponentially weighted) variance, always `≥ 0`.
|
||||||
|
#[must_use]
|
||||||
|
pub fn variance(&self) -> f64 {
|
||||||
|
self.var
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The number of observations folded in so far (seed weight included).
|
||||||
|
#[must_use]
|
||||||
|
pub fn count(&self) -> u32 {
|
||||||
|
self.count
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The standard deviation, floored at `floor` so significance is finite even
|
||||||
|
/// for a degenerate zero-variance baseline (a channel that has only ever
|
||||||
|
/// held one value). `floor` is expected to be `> 0` (config-validated).
|
||||||
|
#[must_use]
|
||||||
|
pub fn std_floored(&self, floor: f64) -> f64 {
|
||||||
|
self.var.max(0.0).sqrt().max(floor)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How many floored standard deviations `x` sits from the learned mean — the
|
||||||
|
/// deviation significance. Non-negative and finite whenever `floor > 0`.
|
||||||
|
#[must_use]
|
||||||
|
pub fn significance(&self, x: f64, floor: f64) -> f64 {
|
||||||
|
let std = self.std_floored(floor);
|
||||||
|
if std > 0.0 {
|
||||||
|
(x - self.mean).abs() / std
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn first_update_seeds_mean_and_zero_variance() {
|
||||||
|
let mut s = RunningStat::new();
|
||||||
|
s.update(10.0, 0.9);
|
||||||
|
assert_eq!(s.count(), 1);
|
||||||
|
assert_eq!(s.mean(), 10.0);
|
||||||
|
assert_eq!(s.variance(), 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn variance_never_negative_and_update_is_deterministic() {
|
||||||
|
let run = || {
|
||||||
|
let mut s = RunningStat::new();
|
||||||
|
for x in [1.0, 2.0, 1.5, 1.7, 1.6, 1.55] {
|
||||||
|
s.update(x, 0.8);
|
||||||
|
}
|
||||||
|
s
|
||||||
|
};
|
||||||
|
let a = run();
|
||||||
|
let b = run();
|
||||||
|
assert_eq!(a, b);
|
||||||
|
assert!(a.variance() >= 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn non_finite_value_is_ignored_not_panicking() {
|
||||||
|
let mut s = RunningStat::new();
|
||||||
|
s.update(f64::NAN, 0.9);
|
||||||
|
assert_eq!(s.count(), 0);
|
||||||
|
s.update(5.0, 0.9);
|
||||||
|
s.update(f64::INFINITY, 0.9);
|
||||||
|
assert_eq!(s.count(), 1);
|
||||||
|
assert_eq!(s.mean(), 5.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn significance_is_finite_under_zero_variance_floor() {
|
||||||
|
let s = RunningStat::seeded(0.0, 0.0, 10);
|
||||||
|
// std floored at 0.1 ⇒ significance of 1.0 is 10 sigma, finite.
|
||||||
|
assert!((s.significance(1.0, 0.1) - 10.0).abs() < 1e-9);
|
||||||
|
}
|
||||||
|
}
|
||||||
15
v2/crates/ruview-ontology/Cargo.toml
Normal file
15
v2/crates/ruview-ontology/Cargo.toml
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
[package]
|
||||||
|
name = "ruview-ontology"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
authors.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
thiserror.workspace = true
|
||||||
|
serde = { workspace = true, features = ["derive"] }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
serde_json.workspace = true
|
||||||
206
v2/crates/ruview-ontology/src/entity.rs
Normal file
206
v2/crates/ruview-ontology/src/entity.rs
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
//! Canonical entity types: the `Site ▸ Building ▸ Floor ▸ Space ▸ Zone`
|
||||||
|
//! containment spine and the leaf entities located within it (ADR-306 §1).
|
||||||
|
//!
|
||||||
|
//! Containment is expressed by a typed `parent` field on each spine node and a
|
||||||
|
//! [`Container`] reference on each leaf. This is the pure-hierarchy analogue of
|
||||||
|
//! the `worldgraph` `PartOf`/`LocatedIn` edges: a `Zone` is part of exactly one
|
||||||
|
//! `Space`, a `Space` on exactly one `Floor`, and so on. The [`WorldGraph`]
|
||||||
|
//! registry enforces those single-parent invariants.
|
||||||
|
//!
|
||||||
|
//! [`WorldGraph`]: crate::WorldGraph
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::id::{
|
||||||
|
BuildingId, EventId, FloorId, ObjectId, ObservationId, PersonId, SensorId, SiteId, SpaceId,
|
||||||
|
TrackId, ZoneId,
|
||||||
|
};
|
||||||
|
use crate::provenance::{EvidenceLevel, SemanticProvenance};
|
||||||
|
|
||||||
|
/// The containment root. A site has no parent.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct Site {
|
||||||
|
/// Stable id.
|
||||||
|
pub id: SiteId,
|
||||||
|
/// Human-readable name.
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A building within a [`Site`].
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct Building {
|
||||||
|
/// Stable id.
|
||||||
|
pub id: BuildingId,
|
||||||
|
/// Containing site.
|
||||||
|
pub parent: SiteId,
|
||||||
|
/// Human-readable name.
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A floor within a [`Building`].
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct Floor {
|
||||||
|
/// Stable id.
|
||||||
|
pub id: FloorId,
|
||||||
|
/// Containing building.
|
||||||
|
pub parent: BuildingId,
|
||||||
|
/// Storey index (ground = 0, basements negative).
|
||||||
|
pub level: i16,
|
||||||
|
/// Human-readable name.
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A bounded interior space within a [`Floor`] — the ADR-297 "room" and the
|
||||||
|
/// HomeCore `area_id` join point (ADR-127).
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct Space {
|
||||||
|
/// Stable id.
|
||||||
|
pub id: SpaceId,
|
||||||
|
/// Containing floor.
|
||||||
|
pub parent: FloorId,
|
||||||
|
/// HomeCore registry `area_id` — the external entity-linkage join key.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub area_id: Option<String>,
|
||||||
|
/// Human-readable name.
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A sub-region of a [`Space`] targeted for sensing.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct Zone {
|
||||||
|
/// Stable id.
|
||||||
|
pub id: ZoneId,
|
||||||
|
/// Containing space.
|
||||||
|
pub parent: SpaceId,
|
||||||
|
/// Human-readable name.
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where a leaf entity is located: directly in a [`Space`] or in a [`Zone`].
|
||||||
|
/// A zone resolves upward to its containing space via the registry.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "container", rename_all = "snake_case")]
|
||||||
|
pub enum Container {
|
||||||
|
/// Located directly in a space.
|
||||||
|
Space {
|
||||||
|
/// The space id.
|
||||||
|
id: SpaceId,
|
||||||
|
},
|
||||||
|
/// Located in a zone (which is itself part of a space).
|
||||||
|
Zone {
|
||||||
|
/// The zone id.
|
||||||
|
id: ZoneId,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A physical sensing device placement — the entity ADR-305 authenticates.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct Sensor {
|
||||||
|
/// Stable id.
|
||||||
|
pub id: SensorId,
|
||||||
|
/// ADR-305 authenticated device identity (HomeCore `device_id`).
|
||||||
|
pub device_id: String,
|
||||||
|
/// Where the sensor is placed.
|
||||||
|
pub located_in: Container,
|
||||||
|
/// Exactly one evidence level travels with this fact.
|
||||||
|
pub evidence_level: EvidenceLevel,
|
||||||
|
/// Mandatory provenance.
|
||||||
|
pub provenance: SemanticProvenance,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A tracked or known person.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct Person {
|
||||||
|
/// Stable id.
|
||||||
|
pub id: PersonId,
|
||||||
|
/// Where the person currently is.
|
||||||
|
pub located_in: Container,
|
||||||
|
/// Exactly one evidence level travels with this fact.
|
||||||
|
pub evidence_level: EvidenceLevel,
|
||||||
|
/// Mandatory provenance.
|
||||||
|
pub provenance: SemanticProvenance,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A persistent physical object / static anchor.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct Object {
|
||||||
|
/// Stable id.
|
||||||
|
pub id: ObjectId,
|
||||||
|
/// Where the object is.
|
||||||
|
pub located_in: Container,
|
||||||
|
/// Classification tag (e.g. `"furniture"`, `"reflector"`).
|
||||||
|
pub class: String,
|
||||||
|
/// Exactly one evidence level travels with this fact.
|
||||||
|
pub evidence_level: EvidenceLevel,
|
||||||
|
/// Mandatory provenance.
|
||||||
|
pub provenance: SemanticProvenance,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A calibrated observation produced from an authenticated frame (ADR-301).
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct Observation {
|
||||||
|
/// Stable id.
|
||||||
|
pub id: ObservationId,
|
||||||
|
/// The sensor that produced it.
|
||||||
|
pub sensor: SensorId,
|
||||||
|
/// Where it was observed.
|
||||||
|
pub located_in: Container,
|
||||||
|
/// Producer-supplied capture timestamp (Unix ms). Injected, never sampled
|
||||||
|
/// from a clock inside this crate.
|
||||||
|
pub at_unix_ms: i64,
|
||||||
|
/// Exactly one evidence level travels with this fact.
|
||||||
|
pub evidence_level: EvidenceLevel,
|
||||||
|
/// Mandatory provenance.
|
||||||
|
pub provenance: SemanticProvenance,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A persistent track (ADR-307), optionally resolved to a [`Person`].
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct Track {
|
||||||
|
/// Stable id.
|
||||||
|
pub id: TrackId,
|
||||||
|
/// Resolved person identity, if any.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub person: Option<PersonId>,
|
||||||
|
/// Where the track currently is.
|
||||||
|
pub located_in: Container,
|
||||||
|
/// Exactly one evidence level travels with this fact.
|
||||||
|
pub evidence_level: EvidenceLevel,
|
||||||
|
/// Mandatory provenance.
|
||||||
|
pub provenance: SemanticProvenance,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A discrete governed event (ADR-318 certified, ADR-319 witnessed).
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct Event {
|
||||||
|
/// Stable id.
|
||||||
|
pub id: EventId,
|
||||||
|
/// Event type tag (e.g. `"fall"`, `"entry"`).
|
||||||
|
pub event_type: String,
|
||||||
|
/// Producer-supplied event timestamp (Unix ms). Injected.
|
||||||
|
pub at_unix_ms: i64,
|
||||||
|
/// Where the event occurred.
|
||||||
|
pub located_in: Container,
|
||||||
|
/// Exactly one evidence level travels with this fact.
|
||||||
|
pub evidence_level: EvidenceLevel,
|
||||||
|
/// Mandatory provenance.
|
||||||
|
pub provenance: SemanticProvenance,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shared accessor: the [`Container`] a leaf entity is located in.
|
||||||
|
pub trait Located {
|
||||||
|
/// Borrow this entity's container.
|
||||||
|
fn container(&self) -> &Container;
|
||||||
|
}
|
||||||
|
|
||||||
|
macro_rules! impl_located {
|
||||||
|
($($ty:ty),+ $(,)?) => {
|
||||||
|
$(impl Located for $ty {
|
||||||
|
fn container(&self) -> &Container {
|
||||||
|
&self.located_in
|
||||||
|
}
|
||||||
|
})+
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
impl_located!(Sensor, Person, Object, Observation, Track, Event);
|
||||||
381
v2/crates/ruview-ontology/src/graph.rs
Normal file
381
v2/crates/ruview-ontology/src/graph.rs
Normal file
@@ -0,0 +1,381 @@
|
|||||||
|
//! [`WorldGraph`] — the canonical registry that holds the containment hierarchy
|
||||||
|
//! and resolves an entity's containing [`Space`]/[`Zone`] (ADR-306 §1).
|
||||||
|
//!
|
||||||
|
//! The registry is the sole insertion boundary: every `add_*` method rejects a
|
||||||
|
//! duplicate id and a dangling parent/container, so the single-parent
|
||||||
|
//! containment invariants of ADR-306 hold by construction. The graph is a pure
|
||||||
|
//! data structure — no I/O, no async, deterministic `BTreeMap` ordering for a
|
||||||
|
//! stable canonical serialization.
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
use crate::entity::{
|
||||||
|
Building, Container, Event, Floor, Object, Observation, Person, Sensor, Site, Space, Track, Zone,
|
||||||
|
};
|
||||||
|
use crate::id::{
|
||||||
|
BuildingId, EventId, FloorId, IdError, ObjectId, ObservationId, PersonId, SensorId, SiteId,
|
||||||
|
SpaceId, TrackId, ZoneId,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Errors returned when mutating the [`WorldGraph`].
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Error)]
|
||||||
|
pub enum OntologyError {
|
||||||
|
/// A raw id failed boundary validation.
|
||||||
|
#[error("invalid identifier: {0}")]
|
||||||
|
Id(#[from] IdError),
|
||||||
|
/// An entity with this id already exists.
|
||||||
|
#[error("duplicate {kind} id: {id}")]
|
||||||
|
Duplicate {
|
||||||
|
/// Entity kind tag.
|
||||||
|
kind: &'static str,
|
||||||
|
/// The conflicting id.
|
||||||
|
id: String,
|
||||||
|
},
|
||||||
|
/// The referenced parent entity does not exist in the registry.
|
||||||
|
#[error("missing {parent_kind} parent '{parent_id}' for {child_kind} '{child_id}'")]
|
||||||
|
MissingParent {
|
||||||
|
/// Kind of the missing parent.
|
||||||
|
parent_kind: &'static str,
|
||||||
|
/// Id of the missing parent.
|
||||||
|
parent_id: String,
|
||||||
|
/// Kind of the child being inserted.
|
||||||
|
child_kind: &'static str,
|
||||||
|
/// Id of the child being inserted.
|
||||||
|
child_id: String,
|
||||||
|
},
|
||||||
|
/// The [`Container`] a leaf references does not exist.
|
||||||
|
#[error("missing {container_kind} container '{container_id}' for {child_kind} '{child_id}'")]
|
||||||
|
MissingContainer {
|
||||||
|
/// `space` or `zone`.
|
||||||
|
container_kind: &'static str,
|
||||||
|
/// The missing container id.
|
||||||
|
container_id: String,
|
||||||
|
/// Kind of the leaf being inserted.
|
||||||
|
child_kind: &'static str,
|
||||||
|
/// Id of the leaf being inserted.
|
||||||
|
child_id: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The canonical world registry: the containment spine plus all leaf entities.
|
||||||
|
/// Serializes to one versioned canonical JSON document.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct WorldGraph {
|
||||||
|
/// Canonical schema version for the serialized form.
|
||||||
|
pub schema_version: u32,
|
||||||
|
/// Sites, keyed by id.
|
||||||
|
pub sites: BTreeMap<SiteId, Site>,
|
||||||
|
/// Buildings, keyed by id.
|
||||||
|
pub buildings: BTreeMap<BuildingId, Building>,
|
||||||
|
/// Floors, keyed by id.
|
||||||
|
pub floors: BTreeMap<FloorId, Floor>,
|
||||||
|
/// Spaces, keyed by id.
|
||||||
|
pub spaces: BTreeMap<SpaceId, Space>,
|
||||||
|
/// Zones, keyed by id.
|
||||||
|
pub zones: BTreeMap<ZoneId, Zone>,
|
||||||
|
/// Sensors, keyed by id.
|
||||||
|
pub sensors: BTreeMap<SensorId, Sensor>,
|
||||||
|
/// Persons, keyed by id.
|
||||||
|
pub persons: BTreeMap<PersonId, Person>,
|
||||||
|
/// Objects, keyed by id.
|
||||||
|
pub objects: BTreeMap<ObjectId, Object>,
|
||||||
|
/// Observations, keyed by id.
|
||||||
|
pub observations: BTreeMap<ObservationId, Observation>,
|
||||||
|
/// Tracks, keyed by id.
|
||||||
|
pub tracks: BTreeMap<TrackId, Track>,
|
||||||
|
/// Events, keyed by id.
|
||||||
|
pub events: BTreeMap<EventId, Event>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The canonical serialization version this build emits.
|
||||||
|
pub const SCHEMA_VERSION: u32 = 1;
|
||||||
|
|
||||||
|
impl Default for WorldGraph {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
schema_version: SCHEMA_VERSION,
|
||||||
|
sites: BTreeMap::new(),
|
||||||
|
buildings: BTreeMap::new(),
|
||||||
|
floors: BTreeMap::new(),
|
||||||
|
spaces: BTreeMap::new(),
|
||||||
|
zones: BTreeMap::new(),
|
||||||
|
sensors: BTreeMap::new(),
|
||||||
|
persons: BTreeMap::new(),
|
||||||
|
objects: BTreeMap::new(),
|
||||||
|
observations: BTreeMap::new(),
|
||||||
|
tracks: BTreeMap::new(),
|
||||||
|
events: BTreeMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WorldGraph {
|
||||||
|
/// A fresh, empty registry at the current [`SCHEMA_VERSION`].
|
||||||
|
#[must_use]
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- containment spine --------------------------------------------------
|
||||||
|
|
||||||
|
/// Insert a site (spine root; no parent to validate).
|
||||||
|
pub fn add_site(&mut self, site: Site) -> Result<(), OntologyError> {
|
||||||
|
if self.sites.contains_key(&site.id) {
|
||||||
|
return Err(OntologyError::Duplicate {
|
||||||
|
kind: "site",
|
||||||
|
id: site.id.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
self.sites.insert(site.id.clone(), site);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Insert a building; its parent site must already exist.
|
||||||
|
pub fn add_building(&mut self, building: Building) -> Result<(), OntologyError> {
|
||||||
|
if self.buildings.contains_key(&building.id) {
|
||||||
|
return Err(OntologyError::Duplicate {
|
||||||
|
kind: "building",
|
||||||
|
id: building.id.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if !self.sites.contains_key(&building.parent) {
|
||||||
|
return Err(OntologyError::MissingParent {
|
||||||
|
parent_kind: "site",
|
||||||
|
parent_id: building.parent.to_string(),
|
||||||
|
child_kind: "building",
|
||||||
|
child_id: building.id.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
self.buildings.insert(building.id.clone(), building);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Insert a floor; its parent building must already exist.
|
||||||
|
pub fn add_floor(&mut self, floor: Floor) -> Result<(), OntologyError> {
|
||||||
|
if self.floors.contains_key(&floor.id) {
|
||||||
|
return Err(OntologyError::Duplicate {
|
||||||
|
kind: "floor",
|
||||||
|
id: floor.id.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if !self.buildings.contains_key(&floor.parent) {
|
||||||
|
return Err(OntologyError::MissingParent {
|
||||||
|
parent_kind: "building",
|
||||||
|
parent_id: floor.parent.to_string(),
|
||||||
|
child_kind: "floor",
|
||||||
|
child_id: floor.id.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
self.floors.insert(floor.id.clone(), floor);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Insert a space; its parent floor must already exist.
|
||||||
|
pub fn add_space(&mut self, space: Space) -> Result<(), OntologyError> {
|
||||||
|
if self.spaces.contains_key(&space.id) {
|
||||||
|
return Err(OntologyError::Duplicate {
|
||||||
|
kind: "space",
|
||||||
|
id: space.id.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if !self.floors.contains_key(&space.parent) {
|
||||||
|
return Err(OntologyError::MissingParent {
|
||||||
|
parent_kind: "floor",
|
||||||
|
parent_id: space.parent.to_string(),
|
||||||
|
child_kind: "space",
|
||||||
|
child_id: space.id.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
self.spaces.insert(space.id.clone(), space);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Insert a zone; its parent space must already exist.
|
||||||
|
pub fn add_zone(&mut self, zone: Zone) -> Result<(), OntologyError> {
|
||||||
|
if self.zones.contains_key(&zone.id) {
|
||||||
|
return Err(OntologyError::Duplicate {
|
||||||
|
kind: "zone",
|
||||||
|
id: zone.id.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if !self.spaces.contains_key(&zone.parent) {
|
||||||
|
return Err(OntologyError::MissingParent {
|
||||||
|
parent_kind: "space",
|
||||||
|
parent_id: zone.parent.to_string(),
|
||||||
|
child_kind: "zone",
|
||||||
|
child_id: zone.id.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
self.zones.insert(zone.id.clone(), zone);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- leaf entities ------------------------------------------------------
|
||||||
|
|
||||||
|
/// Validate that a [`Container`] resolves to an existing space or zone.
|
||||||
|
fn check_container(
|
||||||
|
&self,
|
||||||
|
container: &Container,
|
||||||
|
child_kind: &'static str,
|
||||||
|
child_id: String,
|
||||||
|
) -> Result<(), OntologyError> {
|
||||||
|
match container {
|
||||||
|
Container::Space { id } => {
|
||||||
|
if self.spaces.contains_key(id) {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(OntologyError::MissingContainer {
|
||||||
|
container_kind: "space",
|
||||||
|
container_id: id.to_string(),
|
||||||
|
child_kind,
|
||||||
|
child_id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Container::Zone { id } => {
|
||||||
|
if self.zones.contains_key(id) {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(OntologyError::MissingContainer {
|
||||||
|
container_kind: "zone",
|
||||||
|
container_id: id.to_string(),
|
||||||
|
child_kind,
|
||||||
|
child_id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Insert a sensor; its container must already exist.
|
||||||
|
pub fn add_sensor(&mut self, sensor: Sensor) -> Result<(), OntologyError> {
|
||||||
|
if self.sensors.contains_key(&sensor.id) {
|
||||||
|
return Err(OntologyError::Duplicate {
|
||||||
|
kind: "sensor",
|
||||||
|
id: sensor.id.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
self.check_container(&sensor.located_in, "sensor", sensor.id.to_string())?;
|
||||||
|
self.sensors.insert(sensor.id.clone(), sensor);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Insert a person; its container must already exist.
|
||||||
|
pub fn add_person(&mut self, person: Person) -> Result<(), OntologyError> {
|
||||||
|
if self.persons.contains_key(&person.id) {
|
||||||
|
return Err(OntologyError::Duplicate {
|
||||||
|
kind: "person",
|
||||||
|
id: person.id.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
self.check_container(&person.located_in, "person", person.id.to_string())?;
|
||||||
|
self.persons.insert(person.id.clone(), person);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Insert an object; its container must already exist.
|
||||||
|
pub fn add_object(&mut self, object: Object) -> Result<(), OntologyError> {
|
||||||
|
if self.objects.contains_key(&object.id) {
|
||||||
|
return Err(OntologyError::Duplicate {
|
||||||
|
kind: "object",
|
||||||
|
id: object.id.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
self.check_container(&object.located_in, "object", object.id.to_string())?;
|
||||||
|
self.objects.insert(object.id.clone(), object);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Insert an observation; its sensor and container must already exist.
|
||||||
|
pub fn add_observation(&mut self, obs: Observation) -> Result<(), OntologyError> {
|
||||||
|
if self.observations.contains_key(&obs.id) {
|
||||||
|
return Err(OntologyError::Duplicate {
|
||||||
|
kind: "observation",
|
||||||
|
id: obs.id.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if !self.sensors.contains_key(&obs.sensor) {
|
||||||
|
return Err(OntologyError::MissingParent {
|
||||||
|
parent_kind: "sensor",
|
||||||
|
parent_id: obs.sensor.to_string(),
|
||||||
|
child_kind: "observation",
|
||||||
|
child_id: obs.id.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
self.check_container(&obs.located_in, "observation", obs.id.to_string())?;
|
||||||
|
self.observations.insert(obs.id.clone(), obs);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Insert a track; its container (and resolved person, if any) must exist.
|
||||||
|
pub fn add_track(&mut self, track: Track) -> Result<(), OntologyError> {
|
||||||
|
if self.tracks.contains_key(&track.id) {
|
||||||
|
return Err(OntologyError::Duplicate {
|
||||||
|
kind: "track",
|
||||||
|
id: track.id.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if let Some(person) = &track.person {
|
||||||
|
if !self.persons.contains_key(person) {
|
||||||
|
return Err(OntologyError::MissingParent {
|
||||||
|
parent_kind: "person",
|
||||||
|
parent_id: person.to_string(),
|
||||||
|
child_kind: "track",
|
||||||
|
child_id: track.id.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.check_container(&track.located_in, "track", track.id.to_string())?;
|
||||||
|
self.tracks.insert(track.id.clone(), track);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Insert an event; its container must already exist.
|
||||||
|
pub fn add_event(&mut self, event: Event) -> Result<(), OntologyError> {
|
||||||
|
if self.events.contains_key(&event.id) {
|
||||||
|
return Err(OntologyError::Duplicate {
|
||||||
|
kind: "event",
|
||||||
|
id: event.id.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
self.check_container(&event.located_in, "event", event.id.to_string())?;
|
||||||
|
self.events.insert(event.id.clone(), event);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- containment resolution ---------------------------------------------
|
||||||
|
|
||||||
|
/// Resolve the [`Zone`] a container references, if it is a zone container.
|
||||||
|
/// A space container has no zone.
|
||||||
|
#[must_use]
|
||||||
|
pub fn zone_of(&self, container: &Container) -> Option<&Zone> {
|
||||||
|
match container {
|
||||||
|
Container::Zone { id } => self.zones.get(id),
|
||||||
|
Container::Space { .. } => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve the containing [`Space`] for any container, walking a zone up to
|
||||||
|
/// its parent space. Returns `None` if the container (or a zone's parent
|
||||||
|
/// space) is not registered.
|
||||||
|
#[must_use]
|
||||||
|
pub fn space_of(&self, container: &Container) -> Option<&Space> {
|
||||||
|
match container {
|
||||||
|
Container::Space { id } => self.spaces.get(id),
|
||||||
|
Container::Zone { id } => {
|
||||||
|
let zone = self.zones.get(id)?;
|
||||||
|
self.spaces.get(&zone.parent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve the containing [`Floor`] for any container.
|
||||||
|
#[must_use]
|
||||||
|
pub fn floor_of(&self, container: &Container) -> Option<&Floor> {
|
||||||
|
let space = self.space_of(container)?;
|
||||||
|
self.floors.get(&space.parent)
|
||||||
|
}
|
||||||
|
}
|
||||||
161
v2/crates/ruview-ontology/src/id.rs
Normal file
161
v2/crates/ruview-ontology/src/id.rs
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
//! Typed, deterministic identifier scheme (ADR-306 §1).
|
||||||
|
//!
|
||||||
|
//! Every ontology entity carries a stable, caller-provided string id wrapped in
|
||||||
|
//! a distinct newtype. Ids are *never* randomly generated here: the ontology is
|
||||||
|
//! a pure representation, so identity is supplied by the producing surface
|
||||||
|
//! (ADR-305 `DeviceId`, HomeCore `area_id`, tracker `track_id`, …) and only
|
||||||
|
//! validated at the crate boundary.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
/// Maximum accepted id length, in bytes. Bounds allocation on untrusted input.
|
||||||
|
pub const MAX_ID_LEN: usize = 256;
|
||||||
|
|
||||||
|
/// Reasons a raw id string is rejected at the boundary.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Error)]
|
||||||
|
pub enum IdError {
|
||||||
|
/// The id was empty after trimming was *not* applied (empty is invalid).
|
||||||
|
#[error("identifier must not be empty")]
|
||||||
|
Empty,
|
||||||
|
/// The id exceeded [`MAX_ID_LEN`] bytes.
|
||||||
|
#[error("identifier length {len} exceeds maximum {max}")]
|
||||||
|
TooLong {
|
||||||
|
/// Actual length in bytes.
|
||||||
|
len: usize,
|
||||||
|
/// The enforced maximum.
|
||||||
|
max: usize,
|
||||||
|
},
|
||||||
|
/// The id contained an ASCII control character (newline, NUL, …).
|
||||||
|
#[error("identifier contains a control character at byte {pos}")]
|
||||||
|
ControlChar {
|
||||||
|
/// Byte offset of the offending control character.
|
||||||
|
pos: usize,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate a raw id string: non-empty, bounded length, no control characters.
|
||||||
|
pub(crate) fn validate_id(raw: &str) -> Result<(), IdError> {
|
||||||
|
if raw.is_empty() {
|
||||||
|
return Err(IdError::Empty);
|
||||||
|
}
|
||||||
|
if raw.len() > MAX_ID_LEN {
|
||||||
|
return Err(IdError::TooLong {
|
||||||
|
len: raw.len(),
|
||||||
|
max: MAX_ID_LEN,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if let Some(pos) = raw.bytes().position(|b| b.is_ascii_control()) {
|
||||||
|
return Err(IdError::ControlChar { pos });
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
macro_rules! typed_id {
|
||||||
|
($(#[$meta:meta])* $name:ident, $kind:literal) => {
|
||||||
|
$(#[$meta])*
|
||||||
|
///
|
||||||
|
/// A stable, caller-supplied identifier. Construct with [`Self::new`] to
|
||||||
|
/// validate untrusted input; serde round-trips it transparently as a
|
||||||
|
/// plain JSON string so it is usable as a canonical map key.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||||
|
#[serde(transparent)]
|
||||||
|
pub struct $name(String);
|
||||||
|
|
||||||
|
impl $name {
|
||||||
|
/// Construct a validated id, rejecting empty, over-long, or
|
||||||
|
/// control-character input at the boundary.
|
||||||
|
pub fn new(raw: impl Into<String>) -> Result<Self, IdError> {
|
||||||
|
let s = raw.into();
|
||||||
|
validate_id(&s)?;
|
||||||
|
Ok(Self(s))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Borrow the underlying id string.
|
||||||
|
#[must_use]
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The stable type tag for this id kind (e.g. `"site"`).
|
||||||
|
#[must_use]
|
||||||
|
pub const fn kind() -> &'static str {
|
||||||
|
$kind
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl core::fmt::Display for $name {
|
||||||
|
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
|
||||||
|
f.write_str(&self.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
typed_id!(
|
||||||
|
/// Identifier for a [`Site`](crate::Site) — the containment-spine root.
|
||||||
|
SiteId, "site"
|
||||||
|
);
|
||||||
|
typed_id!(
|
||||||
|
/// Identifier for a [`Building`](crate::Building).
|
||||||
|
BuildingId, "building"
|
||||||
|
);
|
||||||
|
typed_id!(
|
||||||
|
/// Identifier for a [`Floor`](crate::Floor).
|
||||||
|
FloorId, "floor"
|
||||||
|
);
|
||||||
|
typed_id!(
|
||||||
|
/// Identifier for a [`Space`](crate::Space) (ADR-297 room / HomeCore area).
|
||||||
|
SpaceId, "space"
|
||||||
|
);
|
||||||
|
typed_id!(
|
||||||
|
/// Identifier for a [`Zone`](crate::Zone) — a sub-region of a space.
|
||||||
|
ZoneId, "zone"
|
||||||
|
);
|
||||||
|
typed_id!(
|
||||||
|
/// Identifier for a [`Sensor`](crate::Sensor) (ADR-305 authenticated device).
|
||||||
|
SensorId, "sensor"
|
||||||
|
);
|
||||||
|
typed_id!(
|
||||||
|
/// Identifier for a [`Person`](crate::Person).
|
||||||
|
PersonId, "person"
|
||||||
|
);
|
||||||
|
typed_id!(
|
||||||
|
/// Identifier for an [`Object`](crate::Object).
|
||||||
|
ObjectId, "object"
|
||||||
|
);
|
||||||
|
typed_id!(
|
||||||
|
/// Identifier for an [`Observation`](crate::Observation).
|
||||||
|
ObservationId, "observation"
|
||||||
|
);
|
||||||
|
typed_id!(
|
||||||
|
/// Identifier for a [`Track`](crate::Track) (ADR-307 persistent track).
|
||||||
|
TrackId, "track"
|
||||||
|
);
|
||||||
|
typed_id!(
|
||||||
|
/// Identifier for an [`Event`](crate::Event) (ADR-318/ADR-319 governed output).
|
||||||
|
EventId, "event"
|
||||||
|
);
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_empty_and_overlong_and_control() {
|
||||||
|
assert_eq!(SiteId::new(""), Err(IdError::Empty));
|
||||||
|
let long = "x".repeat(MAX_ID_LEN + 1);
|
||||||
|
assert!(matches!(SiteId::new(long), Err(IdError::TooLong { .. })));
|
||||||
|
assert!(matches!(
|
||||||
|
SiteId::new("a\nb"),
|
||||||
|
Err(IdError::ControlChar { pos: 1 })
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn kind_tags_are_stable() {
|
||||||
|
assert_eq!(SiteId::kind(), "site");
|
||||||
|
assert_eq!(ZoneId::kind(), "zone");
|
||||||
|
assert_eq!(EventId::kind(), "event");
|
||||||
|
}
|
||||||
|
}
|
||||||
349
v2/crates/ruview-ontology/src/lib.rs
Normal file
349
v2/crates/ruview-ontology/src/lib.rs
Normal file
@@ -0,0 +1,349 @@
|
|||||||
|
//! # `ruview-ontology` — the canonical spatial ontology (ADR-306, ADR-300 §6)
|
||||||
|
//!
|
||||||
|
//! One `Site ▸ Building ▸ Floor ▸ Space ▸ Zone` containment model, plus the
|
||||||
|
//! leaf entities `Sensor`, `Person`, `Object`, `Observation`, `Track`, and
|
||||||
|
//! `Event`, that **every** RuView surface reads from and writes to. The same
|
||||||
|
//! physical fact — "a person is in the kitchen" — is encoded *once* here and
|
||||||
|
//! every surface (MQTT/Home-Assistant, REST, WebSocket, RuField, Matter, agent
|
||||||
|
//! queries) is a *projection* of this model rather than an independent schema.
|
||||||
|
//!
|
||||||
|
//! This crate is a **pure data / relationship representation**: no I/O, no
|
||||||
|
//! async, no inference. It says nothing about *how* a `Track` or `Event` is
|
||||||
|
//! produced (that is owned by ADR-301/ADR-307/ADR-302) and makes no accuracy
|
||||||
|
//! claim. Identity is caller-supplied and deterministic — ids are never
|
||||||
|
//! randomly generated here.
|
||||||
|
//!
|
||||||
|
//! ## Model at a glance
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! Site ▸ Building ▸ Floor ▸ Space ▸ Zone
|
||||||
|
//! └▸ { Sensor, Person, Object,
|
||||||
|
//! Observation, Track, Event }
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! - The spine is enforced single-parent by the [`WorldGraph`] registry: a
|
||||||
|
//! `Zone` is part of exactly one `Space`, a `Space` on exactly one `Floor`,
|
||||||
|
//! and so on. Inserting a child whose parent is absent is rejected with
|
||||||
|
//! [`OntologyError::MissingParent`].
|
||||||
|
//! - Each leaf carries a [`Container`] (a `Space` or `Zone`); the registry
|
||||||
|
//! resolves it upward with [`WorldGraph::space_of`] / [`WorldGraph::zone_of`].
|
||||||
|
//! - Every leaf carries exactly one [`EvidenceLevel`] and a
|
||||||
|
//! [`SemanticProvenance`] record, so lineage and evidence level travel *with*
|
||||||
|
//! the fact across every projection and cannot be silently dropped.
|
||||||
|
//!
|
||||||
|
//! ## Example
|
||||||
|
//!
|
||||||
|
//! ```
|
||||||
|
//! use ruview_ontology::*;
|
||||||
|
//!
|
||||||
|
//! let mut g = WorldGraph::new();
|
||||||
|
//! g.add_site(Site { id: SiteId::new("home")?, name: "Home".into() })?;
|
||||||
|
//! g.add_building(Building {
|
||||||
|
//! id: BuildingId::new("b1")?, parent: SiteId::new("home")?, name: "House".into(),
|
||||||
|
//! })?;
|
||||||
|
//! g.add_floor(Floor {
|
||||||
|
//! id: FloorId::new("f1")?, parent: BuildingId::new("b1")?, level: 0, name: "Ground".into(),
|
||||||
|
//! })?;
|
||||||
|
//! g.add_space(Space {
|
||||||
|
//! id: SpaceId::new("kitchen")?, parent: FloorId::new("f1")?,
|
||||||
|
//! area_id: Some("area-42".into()), name: "Kitchen".into(),
|
||||||
|
//! })?;
|
||||||
|
//!
|
||||||
|
//! let here = Container::Space { id: SpaceId::new("kitchen")? };
|
||||||
|
//! g.add_person(Person {
|
||||||
|
//! id: PersonId::new("p1")?, located_in: here.clone(),
|
||||||
|
//! evidence_level: EvidenceLevel::L2,
|
||||||
|
//! provenance: SemanticProvenance::declared("fusion@1"),
|
||||||
|
//! })?;
|
||||||
|
//!
|
||||||
|
//! assert_eq!(g.space_of(&here).unwrap().name, "Kitchen");
|
||||||
|
//! # Ok::<(), Box<dyn std::error::Error>>(())
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! ## Migration path from existing per-surface shapes (docs only)
|
||||||
|
//!
|
||||||
|
//! ADR-306 §3 requires a documented, tested bidirectional mapping from each
|
||||||
|
//! existing per-surface schema onto these canonical types. This crate does not
|
||||||
|
//! edit those surfaces; the mappings below are the contract each surface's
|
||||||
|
//! projection implements when it is cut over (one surface at a time). Until a
|
||||||
|
//! surface is cut over, its mapping layer is authoritative and round-tripped so
|
||||||
|
//! no fact is lost.
|
||||||
|
//!
|
||||||
|
//! | Legacy shape | Source | Canonical target |
|
||||||
|
//! |---|---|---|
|
||||||
|
//! | `NodeInference` | ADR-297 MQTT/HA mapper | `Sensor` + an `Observation` whose `sensor` is that node; node-vs-room separation is preserved because the observation is sensor-scoped, not space-scoped. |
|
||||||
|
//! | `RoomInference` | ADR-297 MQTT/HA mapper | The `Space`-level fused inference: a `Person`/`Track` (or `Event`) whose `located_in` is `Container::Space`. `RoomInference.area_id` ↦ [`Space::area_id`]. |
|
||||||
|
//! | `WorldNode::Room { area_id, name, floor }` | `worldgraph` | [`Space`] (`area_id`, `name` retained; `floor` index ↦ the parent [`Floor::level`]). |
|
||||||
|
//! | `WorldNode::Zone { parent_room }` | `worldgraph` | [`Zone`] (`parent_room` ↦ [`Zone::parent`]). |
|
||||||
|
//! | `WorldNode::Sensor { device_id, modality }` | `worldgraph` | [`Sensor`] (`device_id` retained; placement ↦ its [`Container`]). |
|
||||||
|
//! | `WorldNode::PersonTrack { track_id }` | `worldgraph` | [`Track`] (`track_id` ↦ [`TrackId`]) optionally resolved to a [`Person`]. |
|
||||||
|
//! | `WorldNode::Event { event_type, at_unix_ms, located_in }` | `worldgraph` | [`Event`] (fields map 1:1; `located_in` ↦ [`Container`]). |
|
||||||
|
//! | `SemanticProvenance` | `worldgraph` / RuField `SemanticProvenance` | [`SemanticProvenance`] (`evidence`, `model_version`, `calibration_version`, `privacy_decision` map 1:1). |
|
||||||
|
//! | MQTT topic `.../<area>/<sensor>` payload | MQTT/HA surface | `area` ↦ [`Space::area_id`], `sensor` ↦ [`Sensor::device_id`]; the payload's belief becomes a `Person`/`Event` under the resolved `Container`. |
|
||||||
|
//! | REST `GET /spaces/{id}` / `/events` | REST surface | Direct projection of [`Space`] / [`Event`] JSON produced by this crate's canonical serializer. |
|
||||||
|
//! | RuField observation + `SemanticProvenance` | RuField | [`Observation`] carrying the same [`SemanticProvenance`] and [`EvidenceLevel`]. |
|
||||||
|
//! | Matter/HomeKit area model | Matter surface | Matter "area" ↦ [`Space`] via the HomeCore `area_id` (ADR-127) join key. |
|
||||||
|
//!
|
||||||
|
//! The HomeCore `area_id` linkage (ADR-127) remains the join key between a
|
||||||
|
//! canonical [`Space`] and external area registries. New surfaces (ROS 2,
|
||||||
|
//! OpenUSD, OPC UA) plug in as additional projections — the translation matrix
|
||||||
|
//! stays O(surfaces), not O(surfaces²).
|
||||||
|
|
||||||
|
#![forbid(unsafe_code)]
|
||||||
|
#![warn(missing_docs)]
|
||||||
|
|
||||||
|
mod entity;
|
||||||
|
mod graph;
|
||||||
|
mod id;
|
||||||
|
mod provenance;
|
||||||
|
|
||||||
|
pub use entity::{
|
||||||
|
Building, Container, Event, Floor, Located, Object, Observation, Person, Sensor, Site, Space,
|
||||||
|
Track, Zone,
|
||||||
|
};
|
||||||
|
pub use graph::{OntologyError, WorldGraph, SCHEMA_VERSION};
|
||||||
|
pub use id::{
|
||||||
|
BuildingId, EventId, FloorId, IdError, ObjectId, ObservationId, PersonId, SensorId, SiteId,
|
||||||
|
SpaceId, TrackId, ZoneId, MAX_ID_LEN,
|
||||||
|
};
|
||||||
|
pub use provenance::{EvidenceLevel, SemanticProvenance};
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Build a small but complete two-level hierarchy for reuse in tests.
|
||||||
|
fn fixture() -> WorldGraph {
|
||||||
|
let mut g = WorldGraph::new();
|
||||||
|
g.add_site(Site {
|
||||||
|
id: SiteId::new("home").unwrap(),
|
||||||
|
name: "Home".into(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
g.add_building(Building {
|
||||||
|
id: BuildingId::new("b1").unwrap(),
|
||||||
|
parent: SiteId::new("home").unwrap(),
|
||||||
|
name: "House".into(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
g.add_floor(Floor {
|
||||||
|
id: FloorId::new("f1").unwrap(),
|
||||||
|
parent: BuildingId::new("b1").unwrap(),
|
||||||
|
level: 0,
|
||||||
|
name: "Ground".into(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
g.add_space(Space {
|
||||||
|
id: SpaceId::new("kitchen").unwrap(),
|
||||||
|
parent: FloorId::new("f1").unwrap(),
|
||||||
|
area_id: Some("area-42".into()),
|
||||||
|
name: "Kitchen".into(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
g.add_zone(Zone {
|
||||||
|
id: ZoneId::new("stove-zone").unwrap(),
|
||||||
|
parent: SpaceId::new("kitchen").unwrap(),
|
||||||
|
name: "Stove".into(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
g
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prov() -> SemanticProvenance {
|
||||||
|
SemanticProvenance::declared("fusion@1")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn construction_builds_full_spine() {
|
||||||
|
let g = fixture();
|
||||||
|
assert_eq!(g.sites.len(), 1);
|
||||||
|
assert_eq!(g.buildings.len(), 1);
|
||||||
|
assert_eq!(g.floors.len(), 1);
|
||||||
|
assert_eq!(g.spaces.len(), 1);
|
||||||
|
assert_eq!(g.zones.len(), 1);
|
||||||
|
assert_eq!(g.schema_version, SCHEMA_VERSION);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn containment_resolution_walks_zone_to_space_to_floor() {
|
||||||
|
let mut g = fixture();
|
||||||
|
let in_zone = Container::Zone {
|
||||||
|
id: ZoneId::new("stove-zone").unwrap(),
|
||||||
|
};
|
||||||
|
// A sensor placed in the stove zone resolves up to the kitchen space
|
||||||
|
// and the ground floor.
|
||||||
|
g.add_sensor(Sensor {
|
||||||
|
id: SensorId::new("s1").unwrap(),
|
||||||
|
device_id: "dev-aa".into(),
|
||||||
|
located_in: in_zone.clone(),
|
||||||
|
evidence_level: EvidenceLevel::L3,
|
||||||
|
provenance: prov(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let sensor = g.sensors.get(&SensorId::new("s1").unwrap()).unwrap();
|
||||||
|
let container = sensor.located_in.clone();
|
||||||
|
assert_eq!(g.zone_of(&container).unwrap().name, "Stove");
|
||||||
|
assert_eq!(g.space_of(&container).unwrap().name, "Kitchen");
|
||||||
|
assert_eq!(g.space_of(&container).unwrap().area_id.as_deref(), Some("area-42"));
|
||||||
|
assert_eq!(g.floor_of(&container).unwrap().level, 0);
|
||||||
|
|
||||||
|
// A person placed directly in the space has no zone but the same space.
|
||||||
|
let in_space = Container::Space {
|
||||||
|
id: SpaceId::new("kitchen").unwrap(),
|
||||||
|
};
|
||||||
|
assert!(g.zone_of(&in_space).is_none());
|
||||||
|
assert_eq!(g.space_of(&in_space).unwrap().name, "Kitchen");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn json_round_trip_is_lossless() {
|
||||||
|
let mut g = fixture();
|
||||||
|
g.add_person(Person {
|
||||||
|
id: PersonId::new("p1").unwrap(),
|
||||||
|
located_in: Container::Space {
|
||||||
|
id: SpaceId::new("kitchen").unwrap(),
|
||||||
|
},
|
||||||
|
evidence_level: EvidenceLevel::L2,
|
||||||
|
provenance: prov(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
g.add_sensor(Sensor {
|
||||||
|
id: SensorId::new("s1").unwrap(),
|
||||||
|
device_id: "dev-aa".into(),
|
||||||
|
located_in: Container::Zone {
|
||||||
|
id: ZoneId::new("stove-zone").unwrap(),
|
||||||
|
},
|
||||||
|
evidence_level: EvidenceLevel::L4,
|
||||||
|
provenance: prov(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
g.add_observation(Observation {
|
||||||
|
id: ObservationId::new("o1").unwrap(),
|
||||||
|
sensor: SensorId::new("s1").unwrap(),
|
||||||
|
located_in: Container::Zone {
|
||||||
|
id: ZoneId::new("stove-zone").unwrap(),
|
||||||
|
},
|
||||||
|
at_unix_ms: 1_700_000_000_000,
|
||||||
|
evidence_level: EvidenceLevel::L3,
|
||||||
|
provenance: prov(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
g.add_track(Track {
|
||||||
|
id: TrackId::new("t1").unwrap(),
|
||||||
|
person: Some(PersonId::new("p1").unwrap()),
|
||||||
|
located_in: Container::Space {
|
||||||
|
id: SpaceId::new("kitchen").unwrap(),
|
||||||
|
},
|
||||||
|
evidence_level: EvidenceLevel::L3,
|
||||||
|
provenance: prov(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
g.add_event(Event {
|
||||||
|
id: EventId::new("e1").unwrap(),
|
||||||
|
event_type: "entry".into(),
|
||||||
|
at_unix_ms: 1_700_000_000_500,
|
||||||
|
located_in: Container::Space {
|
||||||
|
id: SpaceId::new("kitchen").unwrap(),
|
||||||
|
},
|
||||||
|
evidence_level: EvidenceLevel::L5,
|
||||||
|
provenance: prov(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
g.add_object(Object {
|
||||||
|
id: ObjectId::new("obj1").unwrap(),
|
||||||
|
located_in: Container::Space {
|
||||||
|
id: SpaceId::new("kitchen").unwrap(),
|
||||||
|
},
|
||||||
|
class: "reflector".into(),
|
||||||
|
evidence_level: EvidenceLevel::L1,
|
||||||
|
provenance: prov(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let json = serde_json::to_string_pretty(&g).unwrap();
|
||||||
|
let back: WorldGraph = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(g, back);
|
||||||
|
|
||||||
|
// Canonical serialization uses stable string keys (typed ids) and a
|
||||||
|
// versioned envelope.
|
||||||
|
assert!(json.contains("\"schema_version\": 1"));
|
||||||
|
assert!(json.contains("\"container\": \"space\""));
|
||||||
|
assert!(json.contains("\"evidence_level\": \"L5\""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalid_parent_is_rejected() {
|
||||||
|
let mut g = WorldGraph::new();
|
||||||
|
// Building without its site.
|
||||||
|
let err = g
|
||||||
|
.add_building(Building {
|
||||||
|
id: BuildingId::new("b1").unwrap(),
|
||||||
|
parent: SiteId::new("ghost").unwrap(),
|
||||||
|
name: "Orphan".into(),
|
||||||
|
})
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(
|
||||||
|
err,
|
||||||
|
OntologyError::MissingParent {
|
||||||
|
parent_kind: "site",
|
||||||
|
..
|
||||||
|
}
|
||||||
|
));
|
||||||
|
|
||||||
|
// Leaf into a non-existent container.
|
||||||
|
let mut g = fixture();
|
||||||
|
let err = g
|
||||||
|
.add_person(Person {
|
||||||
|
id: PersonId::new("p1").unwrap(),
|
||||||
|
located_in: Container::Zone {
|
||||||
|
id: ZoneId::new("nope").unwrap(),
|
||||||
|
},
|
||||||
|
evidence_level: EvidenceLevel::L0,
|
||||||
|
provenance: prov(),
|
||||||
|
})
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(
|
||||||
|
err,
|
||||||
|
OntologyError::MissingContainer {
|
||||||
|
container_kind: "zone",
|
||||||
|
..
|
||||||
|
}
|
||||||
|
));
|
||||||
|
|
||||||
|
// Observation referencing an unknown sensor.
|
||||||
|
let err = g
|
||||||
|
.add_observation(Observation {
|
||||||
|
id: ObservationId::new("o1").unwrap(),
|
||||||
|
sensor: SensorId::new("ghost-sensor").unwrap(),
|
||||||
|
located_in: Container::Space {
|
||||||
|
id: SpaceId::new("kitchen").unwrap(),
|
||||||
|
},
|
||||||
|
at_unix_ms: 0,
|
||||||
|
evidence_level: EvidenceLevel::L2,
|
||||||
|
provenance: prov(),
|
||||||
|
})
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(
|
||||||
|
err,
|
||||||
|
OntologyError::MissingParent {
|
||||||
|
parent_kind: "sensor",
|
||||||
|
..
|
||||||
|
}
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn duplicate_id_is_rejected() {
|
||||||
|
let mut g = fixture();
|
||||||
|
let err = g
|
||||||
|
.add_space(Space {
|
||||||
|
id: SpaceId::new("kitchen").unwrap(),
|
||||||
|
parent: FloorId::new("f1").unwrap(),
|
||||||
|
area_id: None,
|
||||||
|
name: "Dup".into(),
|
||||||
|
})
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(err, OntologyError::Duplicate { kind: "space", .. }));
|
||||||
|
}
|
||||||
|
}
|
||||||
68
v2/crates/ruview-ontology/src/provenance.rs
Normal file
68
v2/crates/ruview-ontology/src/provenance.rs
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
//! Evidence ladder and provenance carried by every fact (ADR-306 §2, ADR-282).
|
||||||
|
//!
|
||||||
|
//! The ontology mandates that a fact cannot cross a surface boundary and lose
|
||||||
|
//! its lineage: every leaf entity carries exactly one [`EvidenceLevel`] plus a
|
||||||
|
//! [`SemanticProvenance`] record, so no projection can silently upgrade or drop
|
||||||
|
//! the evidence level.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// The ADR-282 evidence ladder, L0–L5. Exactly one level travels with each
|
||||||
|
/// fact. Ordering is meaningful: `L0 < L1 < … < L5`.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
||||||
|
pub enum EvidenceLevel {
|
||||||
|
/// L0 — declared/assumed, no signal evidence.
|
||||||
|
L0,
|
||||||
|
/// L1 — heuristic/synthetic.
|
||||||
|
L1,
|
||||||
|
/// L2 — single-surface signal evidence.
|
||||||
|
L2,
|
||||||
|
/// L3 — corroborated across surfaces.
|
||||||
|
L3,
|
||||||
|
/// L4 — calibrated and held-out validated.
|
||||||
|
L4,
|
||||||
|
/// L5 — witnessed / certified (ADR-319).
|
||||||
|
L5,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mandatory provenance for every fact (mirrors the `worldgraph`
|
||||||
|
/// `SemanticProvenance` house rule so the two can map losslessly). Every field
|
||||||
|
/// is a bounded string handle, not embedded data.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct SemanticProvenance {
|
||||||
|
/// Evidence content-address handle(s) (ADR-137 `EvidenceRef`).
|
||||||
|
#[serde(default)]
|
||||||
|
pub evidence: Vec<String>,
|
||||||
|
/// Model version that produced the fact (ADR-136).
|
||||||
|
pub model_version: String,
|
||||||
|
/// Calibration baseline in effect (ADR-135/ADR-301).
|
||||||
|
pub calibration_version: String,
|
||||||
|
/// Privacy decision the fact was derived under (ADR-141).
|
||||||
|
pub privacy_decision: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SemanticProvenance {
|
||||||
|
/// A minimal declared-provenance record for L0/L1 structural facts that
|
||||||
|
/// have no signal evidence yet. Deterministic; no I/O.
|
||||||
|
#[must_use]
|
||||||
|
pub fn declared(model_version: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
evidence: Vec::new(),
|
||||||
|
model_version: model_version.into(),
|
||||||
|
calibration_version: "none".to_string(),
|
||||||
|
privacy_decision: "none".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn evidence_level_orders_ascending() {
|
||||||
|
assert!(EvidenceLevel::L0 < EvidenceLevel::L5);
|
||||||
|
assert!(EvidenceLevel::L3 > EvidenceLevel::L2);
|
||||||
|
}
|
||||||
|
}
|
||||||
15
v2/crates/ruview-ood/Cargo.toml
Normal file
15
v2/crates/ruview-ood/Cargo.toml
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
[package]
|
||||||
|
name = "ruview-ood"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
authors.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
thiserror.workspace = true
|
||||||
|
serde = { workspace = true, features = ["derive"] }
|
||||||
|
wifi-densepose-calibration = { path = "../wifi-densepose-calibration", default-features = false }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
serde_json.workspace = true
|
||||||
72
v2/crates/ruview-ood/src/certificate.rs
Normal file
72
v2/crates/ruview-ood/src/certificate.rs
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
//! Cross-ADR adapter: turn an ADR-301 [`CalibrationCertificate`] plus a live
|
||||||
|
//! fingerprint into the two OOD inputs it governs — the [`FingerprintDistance`]
|
||||||
|
//! and the [`CalibrationCompat`] (ADR-302 §1 inputs 1 and 3).
|
||||||
|
//!
|
||||||
|
//! This is the point where certificate *staleness* becomes a domain signal:
|
||||||
|
//! an expired, tampered, drifted, or identity-mismatched certificate maps to a
|
||||||
|
//! non-`Valid` compatibility, which the state machine drives straight to
|
||||||
|
//! UNKNOWN (ADR-300 staleness guard). Absence of a certificate is handled by
|
||||||
|
//! [`no_certificate`] and likewise defaults to UNKNOWN — absence of evidence is
|
||||||
|
//! absence of capability (ADR-302 §3).
|
||||||
|
|
||||||
|
use wifi_densepose_calibration::certificate::{
|
||||||
|
CalibrationCertificate, CertificateStatus, CertificateVerifier, FingerprintDistance, RoomFingerprint,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::domain::CalibrationCompat;
|
||||||
|
|
||||||
|
/// Identity the live inference expects the certificate to attest: which space
|
||||||
|
/// (ADR-306) and which signed device (ADR-305). Validated before the
|
||||||
|
/// certificate's own status, so a certificate for the wrong room/device can
|
||||||
|
/// never present as compatible.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct ExpectedIdentity<'a> {
|
||||||
|
/// The canonical space id the inference is running in.
|
||||||
|
pub space_id: &'a str,
|
||||||
|
/// The signed device id producing the live traffic.
|
||||||
|
pub device_id: &'a str,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Assess a present certificate against the live fingerprint and expected
|
||||||
|
/// identity, returning the domain distance and the calibration compatibility.
|
||||||
|
///
|
||||||
|
/// `now_unix_s` is **injected** — never read from the wall clock — so the
|
||||||
|
/// staleness decision is deterministic and testable. The distance is always the
|
||||||
|
/// certificate-fingerprint-vs-live distance, computed even for a stale/tampered
|
||||||
|
/// certificate so the drift is still reported.
|
||||||
|
///
|
||||||
|
/// Precedence mirrors ADR-301 `status()` but adds the identity checks first:
|
||||||
|
/// space mismatch → device mismatch → tampered → expired → drifted → valid.
|
||||||
|
pub fn assess_certificate<V: CertificateVerifier>(
|
||||||
|
cert: &CalibrationCertificate,
|
||||||
|
live: &RoomFingerprint,
|
||||||
|
expected: ExpectedIdentity<'_>,
|
||||||
|
now_unix_s: i64,
|
||||||
|
verifier: &V,
|
||||||
|
) -> (FingerprintDistance, CalibrationCompat) {
|
||||||
|
let distance = cert.fingerprint.distance(live);
|
||||||
|
|
||||||
|
// Identity binding first (ADR-305/303): a certificate for the wrong
|
||||||
|
// space/device is incompatible regardless of its own validity.
|
||||||
|
if cert.space_id != expected.space_id {
|
||||||
|
return (distance, CalibrationCompat::SpaceMismatch);
|
||||||
|
}
|
||||||
|
if cert.sensor_id != expected.device_id {
|
||||||
|
return (distance, CalibrationCompat::DeviceMismatch);
|
||||||
|
}
|
||||||
|
|
||||||
|
let compat = match cert.status(live, now_unix_s, verifier) {
|
||||||
|
CertificateStatus::Valid { .. } => CalibrationCompat::Valid,
|
||||||
|
CertificateStatus::Expired { .. } => CalibrationCompat::Expired,
|
||||||
|
CertificateStatus::Drifted { .. } => CalibrationCompat::DriftedBeyondEnvelope,
|
||||||
|
CertificateStatus::TamperedSignature => CalibrationCompat::Tampered,
|
||||||
|
};
|
||||||
|
(distance, compat)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The compatibility for a space/device with **no** certificate present. Always
|
||||||
|
/// [`CalibrationCompat::Absent`], which the gate treats as UNKNOWN (ADR-302 §3:
|
||||||
|
/// the default state without a valid certificate is UNKNOWN, not KNOWN).
|
||||||
|
pub fn no_certificate() -> CalibrationCompat {
|
||||||
|
CalibrationCompat::Absent
|
||||||
|
}
|
||||||
350
v2/crates/ruview-ood/src/domain.rs
Normal file
350
v2/crates/ruview-ood/src/domain.rs
Normal file
@@ -0,0 +1,350 @@
|
|||||||
|
//! The domain-state machine: KNOWN → DEGRADED → UNKNOWN.
|
||||||
|
//!
|
||||||
|
//! Implements the ADR-300 staleness guard `VALID → DEGRADED → UNKNOWN` as a
|
||||||
|
//! **pure** classification over four measured inputs (ADR-302 §1):
|
||||||
|
//!
|
||||||
|
//! 1. **domain distance** — [`FingerprintDistance`] of the live fingerprint vs
|
||||||
|
//! the certified one (ADR-301 `distance()`);
|
||||||
|
//! 2. **signal quality** — [`SignalQuality`] (ADR-137 coherence/contradiction
|
||||||
|
//! plus per-frame validity);
|
||||||
|
//! 3. **calibration compatibility** — [`CalibrationCompat`]: is a valid,
|
||||||
|
//! non-invalidated, device/space-matched certificate present?
|
||||||
|
//!
|
||||||
|
//! (The model's own predictive **uncertainty** — the fourth ADR-302 input — is
|
||||||
|
//! attached and acted on at the [`crate::InferenceGate`], keeping `classify`'s
|
||||||
|
//! signature exactly the three-plus-envelope form the phase-1 spec pins.)
|
||||||
|
//!
|
||||||
|
//! The transition is monotone escalation (worst signal wins) so a degraded
|
||||||
|
//! room can never be reported as KNOWN, and hysteresis is provided by keeping
|
||||||
|
//! the inner (enter-DEGRADED) and outer (enter-UNKNOWN) thresholds distinct so
|
||||||
|
//! the gate does not flap on drift noise straddling a single line.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use wifi_densepose_calibration::certificate::{CompatibilityEnvelope, FingerprintDistance, RoomFingerprint};
|
||||||
|
|
||||||
|
use crate::error::{require_unit_interval, Result};
|
||||||
|
|
||||||
|
/// The domain-distance primitive (ADR-302 §1): drift of the **live** room
|
||||||
|
/// fingerprint away from the **certified** reference distribution.
|
||||||
|
///
|
||||||
|
/// Reuses the calibration crate's [`FingerprintDistance`] (ADR-301), which
|
||||||
|
/// already splits drift into an empty-baseline (geometry) component and an
|
||||||
|
/// occupancy component, so a consumer can distinguish "the room itself changed"
|
||||||
|
/// from "occupancy statistics changed". This is a thin, documented adapter — no
|
||||||
|
/// second distance definition is introduced.
|
||||||
|
///
|
||||||
|
/// `certified` is the certificate's attested fingerprint; `live` is the
|
||||||
|
/// currently observed one.
|
||||||
|
pub fn domain_distance(certified: &RoomFingerprint, live: &RoomFingerprint) -> FingerprintDistance {
|
||||||
|
certified.distance(live)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The specific reason a domain left KNOWN. Always reported alongside the state
|
||||||
|
/// (ADR-302: "never a bare label").
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum DomainCause {
|
||||||
|
// --- UNKNOWN-grade causes (hard) ---
|
||||||
|
/// No calibration certificate is present for this space/device.
|
||||||
|
NoCertificate,
|
||||||
|
/// The certificate is past its expiry (stale — ADR-300 staleness guard).
|
||||||
|
CertificateExpired,
|
||||||
|
/// The certificate's signature did not verify (tamper).
|
||||||
|
CertificateTampered,
|
||||||
|
/// The certificate was minted by a different signed device (ADR-305).
|
||||||
|
DeviceMismatch,
|
||||||
|
/// The certificate attests a different space (ADR-306).
|
||||||
|
SpaceMismatch,
|
||||||
|
/// Empty-baseline / total drift crossed the **outer** envelope threshold —
|
||||||
|
/// the room changed materially (furniture, AP channel, geometry).
|
||||||
|
DriftBeyondEnvelope,
|
||||||
|
/// Signal quality fell below the usability floor — nothing can be trusted.
|
||||||
|
SignalUnusable,
|
||||||
|
|
||||||
|
// --- DEGRADED-grade causes (soft) ---
|
||||||
|
/// Moderate drift: past the **inner** threshold but within the envelope.
|
||||||
|
ModerateDrift,
|
||||||
|
/// An ADR-137 contradiction flag was raised (tolerated, but lower-evidence).
|
||||||
|
Contradiction,
|
||||||
|
/// Signal quality dipped below the KNOWN threshold but above the floor.
|
||||||
|
LowSignalQuality,
|
||||||
|
/// The model's own predictive uncertainty is elevated (attached at the gate).
|
||||||
|
ElevatedUncertainty,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DomainCause {
|
||||||
|
/// A stable machine-readable slug for evidence records (ADR-304).
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
DomainCause::NoCertificate => "no_certificate",
|
||||||
|
DomainCause::CertificateExpired => "certificate_expired",
|
||||||
|
DomainCause::CertificateTampered => "certificate_tampered",
|
||||||
|
DomainCause::DeviceMismatch => "device_mismatch",
|
||||||
|
DomainCause::SpaceMismatch => "space_mismatch",
|
||||||
|
DomainCause::DriftBeyondEnvelope => "drift_beyond_envelope",
|
||||||
|
DomainCause::SignalUnusable => "signal_unusable",
|
||||||
|
DomainCause::ModerateDrift => "moderate_drift",
|
||||||
|
DomainCause::Contradiction => "contradiction",
|
||||||
|
DomainCause::LowSignalQuality => "low_signal_quality",
|
||||||
|
DomainCause::ElevatedUncertainty => "elevated_uncertainty",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The gate's decision for one inference (ADR-302 §2).
|
||||||
|
///
|
||||||
|
/// `DEGRADED` and `UNKNOWN` always carry the triggering [`DomainCause`]; a bare
|
||||||
|
/// state is never produced.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum DomainState {
|
||||||
|
/// In-distribution: drift within the envelope, quality high, certificate
|
||||||
|
/// valid & compatible. Confident classifications may be returned.
|
||||||
|
Known,
|
||||||
|
/// A soft threshold was crossed. Classifications are still returned but must
|
||||||
|
/// be treated as lower-evidence; carries the specific cause.
|
||||||
|
Degraded(DomainCause),
|
||||||
|
/// The room changed materially or calibration is absent/stale. RuView stops
|
||||||
|
/// returning confident classifications. This is required behavior, not an
|
||||||
|
/// error (ADR-300 rule 1).
|
||||||
|
Unknown(DomainCause),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DomainState {
|
||||||
|
/// `true` only for [`DomainState::Known`].
|
||||||
|
pub fn is_known(self) -> bool {
|
||||||
|
matches!(self, DomainState::Known)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `true` for [`DomainState::Unknown`].
|
||||||
|
pub fn is_unknown(self) -> bool {
|
||||||
|
matches!(self, DomainState::Unknown(_))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `true` for [`DomainState::Degraded`].
|
||||||
|
pub fn is_degraded(self) -> bool {
|
||||||
|
matches!(self, DomainState::Degraded(_))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The triggering cause, if the domain is not KNOWN.
|
||||||
|
pub fn cause(self) -> Option<DomainCause> {
|
||||||
|
match self {
|
||||||
|
DomainState::Known => None,
|
||||||
|
DomainState::Degraded(c) | DomainState::Unknown(c) => Some(c),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pure classification with the default thresholds (ADR-302 §2). This is the
|
||||||
|
/// canonical `classify(distance, envelope, signal_quality, calibration_compat)`
|
||||||
|
/// entry point: it takes only measured inputs and returns a state — no clock,
|
||||||
|
/// no randomness, no allocation.
|
||||||
|
pub fn classify(
|
||||||
|
distance: FingerprintDistance,
|
||||||
|
envelope: CompatibilityEnvelope,
|
||||||
|
signal_quality: SignalQuality,
|
||||||
|
calibration_compat: CalibrationCompat,
|
||||||
|
) -> DomainState {
|
||||||
|
DomainThresholds::default().classify(distance, envelope, signal_quality, calibration_compat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-frame signal-quality summary (ADR-137 reuse + per-frame validity).
|
||||||
|
///
|
||||||
|
/// `score` folds fusion coherence and per-frame SNR/validity into a single
|
||||||
|
/// `[0, 1]` health value; `contradiction` mirrors the ADR-137 contradiction
|
||||||
|
/// flag; `valid` is the per-frame validity bit. Constructed through a validated
|
||||||
|
/// boundary so a non-finite or out-of-range score can never enter the gate.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct SignalQuality {
|
||||||
|
/// Combined coherence/SNR health in `[0, 1]` (higher is better).
|
||||||
|
pub score: f32,
|
||||||
|
/// ADR-137 contradiction flag for this frame.
|
||||||
|
pub contradiction: bool,
|
||||||
|
/// Per-frame validity bit (a structurally invalid frame is unusable).
|
||||||
|
pub valid: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SignalQuality {
|
||||||
|
/// Validated constructor. Rejects a non-finite or out-of-`[0, 1]` score
|
||||||
|
/// (bounded-input discipline at the fusion boundary).
|
||||||
|
pub fn new(score: f32, contradiction: bool, valid: bool) -> Result<Self> {
|
||||||
|
let score = require_unit_interval("signal_quality.score", score)?;
|
||||||
|
Ok(Self {
|
||||||
|
score,
|
||||||
|
contradiction,
|
||||||
|
valid,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Derive a quality score from raw ADR-137 signals. `coherence` is clamped
|
||||||
|
/// to `[0, 1]`; `snr_db` is mapped through a bounded, monotone squash so a
|
||||||
|
/// hostile/NaN SNR cannot poison the score. Never fails — a wholly invalid
|
||||||
|
/// input yields a zero score and `valid = false`.
|
||||||
|
pub fn from_signals(coherence: f32, snr_db: f32, contradiction: bool, valid: bool) -> Self {
|
||||||
|
let coherence = clamp_unit(coherence);
|
||||||
|
// Map SNR (dB) into [0, 1]: <=0 dB -> 0, >=30 dB -> 1, linear between.
|
||||||
|
let snr_norm = if snr_db.is_finite() {
|
||||||
|
(snr_db / 30.0).clamp(0.0, 1.0)
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
let score = 0.5 * coherence + 0.5 * snr_norm;
|
||||||
|
Self {
|
||||||
|
score,
|
||||||
|
contradiction,
|
||||||
|
valid,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a valid, non-invalidated calibration certificate is present for this
|
||||||
|
/// space and signed device (ADR-302 §1 input 3). Derived from an ADR-301
|
||||||
|
/// [`CertificateStatus`](wifi_densepose_calibration::certificate::CertificateStatus)
|
||||||
|
/// plus space/device identity checks; see [`crate::assess_certificate`].
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum CalibrationCompat {
|
||||||
|
/// A valid certificate, matching space and device, drift within envelope.
|
||||||
|
Valid,
|
||||||
|
/// Certificate present but drifted beyond its envelope (stale distribution).
|
||||||
|
DriftedBeyondEnvelope,
|
||||||
|
/// Certificate present but expired.
|
||||||
|
Expired,
|
||||||
|
/// Certificate signature did not verify.
|
||||||
|
Tampered,
|
||||||
|
/// Certificate was minted by a different signed device.
|
||||||
|
DeviceMismatch,
|
||||||
|
/// Certificate attests a different space.
|
||||||
|
SpaceMismatch,
|
||||||
|
/// No certificate at all for this space/device.
|
||||||
|
Absent,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CalibrationCompat {
|
||||||
|
/// `true` only when a fully valid, compatible certificate is present.
|
||||||
|
pub fn is_compatible(self) -> bool {
|
||||||
|
matches!(self, CalibrationCompat::Valid)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The hard (UNKNOWN-grade) cause this compatibility state implies, if any.
|
||||||
|
/// A non-`Valid` compatibility is always a hard failure: a stale, absent,
|
||||||
|
/// or mismatched certificate cannot support a KNOWN domain (ADR-302 §3,
|
||||||
|
/// "absence of evidence is absence of capability").
|
||||||
|
fn hard_cause(self) -> Option<DomainCause> {
|
||||||
|
match self {
|
||||||
|
CalibrationCompat::Valid => None,
|
||||||
|
CalibrationCompat::DriftedBeyondEnvelope => Some(DomainCause::DriftBeyondEnvelope),
|
||||||
|
CalibrationCompat::Expired => Some(DomainCause::CertificateExpired),
|
||||||
|
CalibrationCompat::Tampered => Some(DomainCause::CertificateTampered),
|
||||||
|
CalibrationCompat::DeviceMismatch => Some(DomainCause::DeviceMismatch),
|
||||||
|
CalibrationCompat::SpaceMismatch => Some(DomainCause::SpaceMismatch),
|
||||||
|
CalibrationCompat::Absent => Some(DomainCause::NoCertificate),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The gate's calibration thresholds (ADR-302 §2). These are the "calibration
|
||||||
|
/// parameters, reported with each decision" the ADR requires — not baked-in
|
||||||
|
/// magic numbers. All are validated at construction.
|
||||||
|
///
|
||||||
|
/// Hysteresis is expressed as the gap between the inner (enter-DEGRADED) and
|
||||||
|
/// outer (enter-UNKNOWN) drift lines: `inner = envelope.max_total_drift *
|
||||||
|
/// inner_drift_fraction`, strictly below the outer envelope, so drift noise
|
||||||
|
/// straddling one line cannot flap KNOWN⇄UNKNOWN directly.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct DomainThresholds {
|
||||||
|
/// Fraction of the envelope's `max_total_drift` at which drift enters
|
||||||
|
/// DEGRADED. In `[0, 1)` so the inner line stays strictly inside the outer.
|
||||||
|
pub inner_drift_fraction: f32,
|
||||||
|
/// Minimum signal-quality score to remain KNOWN. Below it (but at/above the
|
||||||
|
/// floor) → DEGRADED.
|
||||||
|
pub quality_known_min: f32,
|
||||||
|
/// Usability floor. Below it the frame is unusable → UNKNOWN.
|
||||||
|
pub quality_floor: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for DomainThresholds {
|
||||||
|
fn default() -> Self {
|
||||||
|
// Conservative phase-1 defaults; consumers tune per space/model.
|
||||||
|
Self {
|
||||||
|
inner_drift_fraction: 0.6,
|
||||||
|
quality_known_min: 0.6,
|
||||||
|
quality_floor: 0.3,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DomainThresholds {
|
||||||
|
/// Validated constructor. Enforces `0 <= floor <= known_min <= 1`, and
|
||||||
|
/// `inner_drift_fraction` in `[0, 1)`, so the inner drift line is always
|
||||||
|
/// strictly below the outer envelope (bounded-input discipline).
|
||||||
|
pub fn new(inner_drift_fraction: f32, quality_known_min: f32, quality_floor: f32) -> Result<Self> {
|
||||||
|
if !inner_drift_fraction.is_finite() || !(0.0..1.0).contains(&inner_drift_fraction) {
|
||||||
|
return Err(crate::error::OodError::InvalidParameter {
|
||||||
|
field: "inner_drift_fraction",
|
||||||
|
reason: format!("must be finite in [0, 1), got {inner_drift_fraction}"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let quality_known_min = require_unit_interval("quality_known_min", quality_known_min)?;
|
||||||
|
let quality_floor = require_unit_interval("quality_floor", quality_floor)?;
|
||||||
|
if quality_floor > quality_known_min {
|
||||||
|
return Err(crate::error::OodError::InvalidParameter {
|
||||||
|
field: "quality_floor",
|
||||||
|
reason: format!(
|
||||||
|
"floor {quality_floor} must not exceed known_min {quality_known_min}"
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(Self {
|
||||||
|
inner_drift_fraction,
|
||||||
|
quality_known_min,
|
||||||
|
quality_floor,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pure classification (ADR-300 staleness guard `VALID → DEGRADED →
|
||||||
|
/// UNKNOWN`). Monotone escalation: the first matching hard cause wins
|
||||||
|
/// UNKNOWN; otherwise the first matching soft cause wins DEGRADED; else
|
||||||
|
/// KNOWN. Deterministic, allocation-free, no clock.
|
||||||
|
pub fn classify(
|
||||||
|
self,
|
||||||
|
distance: FingerprintDistance,
|
||||||
|
envelope: CompatibilityEnvelope,
|
||||||
|
signal_quality: SignalQuality,
|
||||||
|
calibration_compat: CalibrationCompat,
|
||||||
|
) -> DomainState {
|
||||||
|
// --- Hard failures → UNKNOWN (checked first; certificate before drift) ---
|
||||||
|
if let Some(cause) = calibration_compat.hard_cause() {
|
||||||
|
return DomainState::Unknown(cause);
|
||||||
|
}
|
||||||
|
let outer = envelope.max_total_drift;
|
||||||
|
// A non-finite live distance is treated as maximal drift, never a panic.
|
||||||
|
if !distance.total.is_finite() || distance.total > outer {
|
||||||
|
return DomainState::Unknown(DomainCause::DriftBeyondEnvelope);
|
||||||
|
}
|
||||||
|
if !signal_quality.valid || signal_quality.score < self.quality_floor {
|
||||||
|
return DomainState::Unknown(DomainCause::SignalUnusable);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Soft failures → DEGRADED (drift first, then quality signals) ---
|
||||||
|
let inner = outer * self.inner_drift_fraction;
|
||||||
|
if distance.total > inner {
|
||||||
|
return DomainState::Degraded(DomainCause::ModerateDrift);
|
||||||
|
}
|
||||||
|
if signal_quality.contradiction {
|
||||||
|
return DomainState::Degraded(DomainCause::Contradiction);
|
||||||
|
}
|
||||||
|
if signal_quality.score < self.quality_known_min {
|
||||||
|
return DomainState::Degraded(DomainCause::LowSignalQuality);
|
||||||
|
}
|
||||||
|
|
||||||
|
DomainState::Known
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clamp into `[0, 1]`, mapping non-finite to `0.0` (worst). Shared helper so no
|
||||||
|
/// untrusted float can escape the unit interval without panicking.
|
||||||
|
pub(crate) fn clamp_unit(v: f32) -> f32 {
|
||||||
|
if v.is_finite() {
|
||||||
|
v.clamp(0.0, 1.0)
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
}
|
||||||
|
}
|
||||||
40
v2/crates/ruview-ood/src/error.rs
Normal file
40
v2/crates/ruview-ood/src/error.rs
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
//! Boundary errors for the OOD gate.
|
||||||
|
//!
|
||||||
|
//! Errors are raised only when *configuration* input is malformed (a threshold
|
||||||
|
//! outside its valid range, a non-finite quality score). Runtime domain
|
||||||
|
//! ambiguity is **never** an error: it is the first-class [`DomainState::Unknown`]
|
||||||
|
//! value (ADR-300 rule 1). Nothing in this crate panics on malformed runtime
|
||||||
|
//! input.
|
||||||
|
//!
|
||||||
|
//! [`DomainState::Unknown`]: crate::DomainState::Unknown
|
||||||
|
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
/// Errors from constructing OOD configuration values at their boundary.
|
||||||
|
#[derive(Debug, Error, Clone, PartialEq)]
|
||||||
|
pub enum OodError {
|
||||||
|
/// A configuration value was non-finite or outside its documented range.
|
||||||
|
#[error("invalid OOD parameter '{field}': {reason}")]
|
||||||
|
InvalidParameter {
|
||||||
|
/// The offending field.
|
||||||
|
field: &'static str,
|
||||||
|
/// Why it was rejected (value + expected range).
|
||||||
|
reason: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenience result alias for boundary-validated constructors.
|
||||||
|
pub type Result<T> = core::result::Result<T, OodError>;
|
||||||
|
|
||||||
|
/// Validate that `value` is finite and within `[0, 1]`, or return a boundary
|
||||||
|
/// error naming `field`. Shared by every bounded `[0, 1]` config field so the
|
||||||
|
/// discipline is identical at each boundary.
|
||||||
|
pub(crate) fn require_unit_interval(field: &'static str, value: f32) -> Result<f32> {
|
||||||
|
if !value.is_finite() || !(0.0..=1.0).contains(&value) {
|
||||||
|
return Err(OodError::InvalidParameter {
|
||||||
|
field,
|
||||||
|
reason: format!("must be finite in [0, 1], got {value}"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(value)
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user