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:
rUv
2026-08-11 13:24:42 -04:00
committed by GitHub
164 changed files with 35636 additions and 58 deletions

53
.github/workflows/csi-data-policy.yml vendored Normal file
View 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
View File

@@ -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

View 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 20242025: 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.

View 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
20160 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 (20160 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.

View 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.10.5 Hz) and heart
rate (0.82.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), BlandAltman
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.

View 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 (I1I3 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.

View 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`.

View 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.

View 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`.

View 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.

View 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.

View 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` L0L5 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).

View 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` (L0L5, 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.

View 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.

View 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/
BlandAltman/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/BlandAltman/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.

View 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` L0L5 (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` (L0L5, 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.

View 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.

View 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` (L0L5, 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.

View 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.

View 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.

View 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`
P0P5 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 P0P5 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.

View 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` (L0L5, 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.

View 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.

View 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.

View 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` L0L5 (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.

View 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.

View 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.

View 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.

View 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` (L0L5, 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.

View 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 L0L5 (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.

View 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` (L0L5, 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.

View 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` (L0L5, 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).

View 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 L0L5 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`.

View File

@@ -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) |
--- ---

View File

@@ -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-295296 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
View 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 "$@"

View 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

View File

@@ -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)

View File

@@ -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';
} }
}); });

View File

@@ -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
View File

@@ -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"

View File

@@ -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"

View 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

View 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(())
}
}

View 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());
}
}

View 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))
}

View 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

View 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));
}
}

View 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

View 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;

View 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));
}

View 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

View 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,
},
}

View 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
}

View 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);
}
}

View 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

View 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`] (L0L5, 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, L0L5, 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 }
);
}
}

View 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

View 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()),
}
}

View 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,
})
}

View 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);
}
}

View 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()
}
}

View 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)
}
}

View 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

View 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 }
}
}

View 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(),
})
}

View 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(())
}

View 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 { .. }
));
}
}

View 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,
}

View 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 25 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,
})
}
}

View 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()
}
}

View 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,
})
}
}

View 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

View 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,
}
}
}

View 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()
}
}

View 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)
}
}

View 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);
}
}

View 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
)
}
}

View 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
}
}

View 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

View 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,
}
}
}

View 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
}
}

View 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);
}
}

View 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,
}
}

View 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

View 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)
}
}

View 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);
}
}

View 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,
},
}

View 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);
}
}

View 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);
}
}

View 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

View 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);

View 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)
}
}

View 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");
}
}

View 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", .. }));
}
}

View 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, L0L5. 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);
}
}

View 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

View 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
}

View 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
}
}

View 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