diff --git a/.github/workflows/csi-data-policy.yml b/.github/workflows/csi-data-policy.yml new file mode 100644 index 00000000..bac0d06c --- /dev/null +++ b/.github/workflows/csi-data-policy.yml @@ -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" diff --git a/.gitignore b/.gitignore index beac4bcf..607fe8d7 100644 --- a/.gitignore +++ b/.gitignore @@ -28,8 +28,13 @@ firmware/esp32-csi-node/test/*.obj # Claude Flow swarm runtime state .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/ +**/*.csi.jsonl +**/*.csi.meta.json # NVS partition images and CSVs (contain WiFi credentials) nvs.bin diff --git a/docs/adr/ADR-291-public-benchmark-evaluation-harness.md b/docs/adr/ADR-291-public-benchmark-evaluation-harness.md new file mode 100644 index 00000000..da3312a7 --- /dev/null +++ b/docs/adr/ADR-291-public-benchmark-evaluation-harness.md @@ -0,0 +1,106 @@ +# ADR-291: Public-benchmark evaluation harness — Widar3.0 ingest, standard split protocols, leakage guards + +- **Status**: Accepted — initial implementation (this PR) +- **Date**: 2026-08-10 +- **Deciders**: ruv +- **Tags**: training, evaluation, benchmarks, widar, mm-fi, leakage, honesty + +## Context + +RuView implements the field's key techniques (CSI ratio, BVP features, MAE +pretraining, rapid adaptation) but reports results only on self-collected data +with self-defined metrics (e.g. the README's held-out temporal-triplet +accuracy). A 2026 deep-research sweep of the WiFi-sensing literature found: + +1. Cross-domain generalization is the field's central unsolved problem; the + only widely reproduced cross-domain result is Widar3.0's BVP benchmark. +2. MM-Fi (NeurIPS 2023) is the standard WiFi-pose benchmark, with defined + cross-subject and cross-environment protocols. +3. The field had a documented leakage reckoning in 2024–2025: window-level + random splits on continuous recordings inflate accuracy (one dataset's F1 + collapsed from ~90% to ~22% under subject-disjoint splits — Sensors + 24(10):3159; Signals 6(4):59). + +`wifi-densepose-train` already has an `MmFiDataset` NPY loader and a +deterministic `SyntheticCsiDataset`, but no Widar3.0 ingest, no standard split +protocols, and no structural leakage guard. CLAUDE.md already requires +mean-pose baselines and leakage-free held-out splits for pose PCK; nothing in +the code enforces this. + +Without leaderboard-comparable numbers, RuView's claims cannot be ranked +against published systems, which blocks both scientific credibility and +commercial (OEM licensing) conversations. + +## Options considered + +1. **Do nothing; keep self-collected metrics.** Rejected: perpetuates the + comparability gap. +2. **Port a Python eval stack (SenseFi) alongside the Rust pipeline.** + Rejected: violates the v2 Rust-workspace direction and adds an unreviewed + dependency surface. +3. **Extend `wifi-densepose-train` with native loaders + protocol machinery.** + Chosen. + +## Decision + +Extend `v2/crates/wifi-densepose-train` with three additions: + +### 1. Widar3.0 ingest (`dataset::widar`) + +- A parser for the Intel 5300 `.dat` CSI log format ("bfee" records) used by + the Widar3.0 raw distribution: framed records with a 3-byte header + (2-byte little-endian length + 1-byte code 0xBB), a 20-byte bfee header + (timestamp_low, bfee_count, Nrx, Ntx, RSSI a/b/c, noise, agc, antenna_sel, + len, rate), and a packed 10-bit-per-component complex CSI payload of + 30 subcarrier groups. Invalid records are skipped with a warning, not a + panic — untrusted file input is validated at the boundary per CLAUDE.md. +- A `WidarDataset` implementing the existing `CsiDataset` trait, mapping + Widar's `Nrx × Ntx × 30` CSI into windowed `CsiSample`s via the existing + subcarrier interpolation, with domain metadata (user, room, orientation, + gesture) parsed from Widar's documented directory/file naming convention. +- No network access: the loader reads a local dataset root. Dataset download + remains a documented manual step. + +### 2. Split protocols (`protocols`) + +- A `SplitProtocol` type expressing the standard evaluations: cross-subject + (MM-Fi style), cross-environment/room, cross-orientation (Widar style), and + random-baseline (explicitly labelled as leakage-prone, for comparison only). +- Split assignment is a pure function of sample metadata + a seed — fully + deterministic, no RNG state. + +### 3. Leakage guards (`protocols::leakage`) + +- A structural `LeakageAudit` that, given a proposed train/test split, + verifies: (a) subject-disjointness, (b) environment-disjointness where the + protocol claims it, (c) no two windows from the same continuous recording + span both sides of the split. A failed audit is an `Err`, not a warning. +- PCK/accuracy reporting requires a `MeanPoseBaseline` computed from the + training split only, and reports model-vs-baseline together, enforcing the + CLAUDE.md rule in the type system rather than by convention. +- Evaluation output is an evidence-tagged report (`MEASURED` requires a + reproducer command line embedded in the report; anything else is emitted as + `SYNTHETIC` or `CLAIMED`). + +## Consequences + +- RuView results become comparable to published numbers (Widar3.0 cross-domain + gesture; MM-Fi cross-subject pose) for the first time. +- The leakage audit will make some existing internal numbers look worse. That + is the point. +- Parsing a legacy binary format adds maintenance surface; mitigated by + fixture-based tests with synthetic, deterministically generated `.dat` + bytes (no dataset redistribution). +- Widar's raw distribution is Intel 5300-specific; ESP32-captured data + continues through existing loaders. The protocols/leakage machinery is + loader-agnostic. + +## Validation + +- `cargo test -p wifi-densepose-train` — unit tests for the bfee parser + (truncated, corrupt, and valid synthetic fixtures), split determinism, + leakage-audit rejection cases, and mean-pose baseline math. +- `cargo bench -p wifi-densepose-train` — criterion benchmark for parser + throughput and split assignment on synthetic corpora. +- No accuracy numbers are claimed by this ADR; it delivers the machinery to + produce MEASURED ones. diff --git a/docs/adr/ADR-292-wideband-80211ax-csi-ingest.md b/docs/adr/ADR-292-wideband-80211ax-csi-ingest.md new file mode 100644 index 00000000..3e5fd60b --- /dev/null +++ b/docs/adr/ADR-292-wideband-80211ax-csi-ingest.md @@ -0,0 +1,91 @@ +# ADR-292: Wideband 802.11ax CSI ingest — FeitCSI/AX210 adapter and subcarrier-agnostic plumbing + +- **Status**: Accepted — initial implementation (this PR) +- **Date**: 2026-08-10 +- **Deciders**: ruv +- **Tags**: hardware, csi, 80211ax, ax210, feitcsi, ingest, mat + +## Context + +RuView's CSI ingest (`wifi-densepose-mat/src/integration/hardware_adapter.rs`) +supports ESP32 serial streams, the legacy Intel 5300 tool, and Atheros/Nexmon +paths. All of these are 802.11n-class: ≤40 MHz bandwidth, ≤114 subcarriers, +2.4/5 GHz. + +The 2026 research sweep found the field's center of gravity has moved to +Intel AX200/AX210 NICs via PicoScenes (closed-source core) and FeitCSI +(open-source, GPL): 802.11ax CSI at up to 160 MHz / 1992 subcarriers, +including the 6 GHz band. This is both the research-grade tier today and the +shape of the data 802.11bf silicon will deliver from ~2026 onward. RuView's +`wifi-densepose-hardware` crate already models 802.11bf session types, but no +ingest path can carry wideband CSI into the pipeline. + +Without a wideband path, RuView cannot develop against the best available +signal, cannot compare ESP32-grade results to wideband upper bounds, and will +meet 802.11bf silicon with no tested plumbing for >114-subcarrier frames. + +## Options considered + +1. **PicoScenes `.csi` ingest.** Rejected for now: the format is produced by a + closed-source core and is versioned/complex; parsing it without a + maintained spec invites silent corruption. +2. **Raw pcap + radiotap parsing.** Rejected: duplicates what FeitCSI already + does on-device, and pulls a packet-capture dependency into the pipeline. +3. **FeitCSI file/stream ingest.** Chosen: FeitCSI is open-source (its header + layout is auditable against the source), targets AX200/AX210, covers + 20–160 MHz including 6 GHz, and emits a compact binary record per frame. + +## Decision + +Extend `v2/crates/wifi-densepose-mat/src/integration` with: + +### 1. `feitcsi` record parser + +- A validated parser for FeitCSI's binary CSI record layout (header with + CSI buffer length, rate/bandwidth/channel metadata, antenna counts, RSSI, + timestamp, followed by interleaved complex CSI). The parser is written + against the documented layout, is version-checked, and rejects + records whose declared dimensions disagree with the buffer length — + untrusted file/stream input is validated at the boundary. +- Bounded allocation: a hard cap on subcarrier count (4096) and antenna + count (8) so a corrupt length field cannot cause unbounded allocation. + +### 2. `DeviceType::FeitCsi` in the hardware adapter + +- File-replay mode (read a recorded FeitCSI capture deterministically) and a + streaming mode fed by an external process writing to a path/pipe. No + privileged operations inside the crate: RuView does not configure the NIC; + FeitCSI's own tooling owns that, per least-authority. + +### 3. Subcarrier-agnostic plumbing + +- Ingest carries native subcarrier dimensionality end-to-end and converts to + pipeline width explicitly via the existing interpolation/decimation stage, + recording the native → pipeline mapping in frame metadata so downstream + consumers know the true spectral resolution. Bandwidth (20–160 MHz) and + band (2.4/5/6 GHz) become first-class frame metadata. + +## Consequences + +- RuView gains a research-grade wideband development path and a tested + ingest shape for future 802.11bf reporting (truncated CIR is a natural + extension of the same plumbing). +- GPL FeitCSI is used as an external tool, never linked: only its output + format is parsed. No licensing contamination of the MIT workspace. +- The parser tracks an external project's format; version checks fail loudly + on mismatch rather than misparse. +- ESP32 remains the deployed sensor tier; wideband is a development/ + validation tier. Accuracy claims from wideband captures must be tagged with + the capture hardware. + +## Validation + +- `cargo test -p wifi-densepose-mat` — parser tests over synthetic fixtures: + valid records at 20/80/160 MHz shapes, truncated buffer, dimension + mismatch, version mismatch, allocation-cap enforcement; adapter replay + determinism. +- `cargo bench -p wifi-densepose-mat` — criterion benchmark for record parse + throughput at 1992-subcarrier frames. +- Hardware validation on real AX210 silicon is explicitly out of scope for + this PR and remains required (per CLAUDE.md) before any capture-path + hardware claim; the file-replay path is testable without silicon. diff --git a/docs/adr/ADR-293-vitals-ground-truth-rig.md b/docs/adr/ADR-293-vitals-ground-truth-rig.md new file mode 100644 index 00000000..c513d8fe --- /dev/null +++ b/docs/adr/ADR-293-vitals-ground-truth-rig.md @@ -0,0 +1,93 @@ +# ADR-293: Vitals ground-truth rig — reference ingest, time alignment, and agreement metrics + +- **Status**: Accepted — initial implementation (this PR) +- **Date**: 2026-08-10 +- **Deciders**: ruv +- **Tags**: vitals, validation, ground-truth, bland-altman, evidence, honesty + +## Context + +`wifi-densepose-vitals` (ADR-021) extracts breathing (0.1–0.5 Hz) and heart +rate (0.8–2.0 Hz) from CSI. The 2026 research sweep found that every credible +vitals result in the literature ships with reference-sensor ground truth +(chest strap, pulse oximeter, ECG, or PSG), and that WiFi heart-rate numbers +without stated scope (single person, static, line-of-sight, short range) are +systematically misleading. RuView currently has no way to produce a MEASURED +vitals number: there is no reference-signal ingest, no time alignment between +CSI-derived estimates and a reference device, and no agreement statistics. + +CLAUDE.md requires accuracy statements to be tagged MEASURED (with a +reproducer), CLAIMED, or SYNTHETIC. For vitals, MEASURED is currently +unreachable. + +## Options considered + +1. **Live BLE/ANT+ integration with reference devices.** Rejected for now: + drivers and pairing are a hardware/product concern; the blocking gap is + the evaluation math, not the radio link. +2. **File-based reference ingest + offline agreement analysis.** Chosen: + every consumer reference device (Polar, Garmin, oximeters) exports + timestamped series; a file boundary keeps the crate dependency-free and + the pipeline deterministic. + +## Decision + +Add a `groundtruth` module to `v2/crates/wifi-densepose-vitals`: + +### 1. Reference series ingest + +- `ReferenceSeries`: timestamped samples (unix millis + value) for one + measurand (`HeartRateBpm` or `BreathingRateBrpm`), with device metadata + (make/model, measurement principle). Parsed from CSV (`timestamp_ms,value` + with a header line); malformed rows are rejected with row-numbered errors — + untrusted file input validated at the boundary. Non-monotonic timestamps + are an error, not silently sorted. + +### 2. Time alignment + +- Constant-offset estimation by maximizing normalized cross-correlation of + the estimate series against the reference over a bounded lag window + (default ±30 s), on a common resampled grid (nearest-sample, no + interpolation of physiological values across gaps larger than a + configurable limit). +- Optional linear clock-drift fit (offset + rate) for long sessions. + Alignment parameters are reported, never silently applied. + +### 3. Agreement metrics + +- `AgreementReport`: n paired samples, coverage fraction (time where both + series had valid samples), MAE, RMSE, mean error (bias), Bland–Altman + 95% limits of agreement, and percentage-within-tolerance (configurable, + default ±2 bpm HR / ±1 brpm breathing). +- Session scope is mandatory metadata: subject count, motion state + (static/moving), line-of-sight (LOS/NLOS/through-wall), distance band. + A report without scope cannot be constructed. + +### 4. Evidence tagging + +- `EvidenceGrade::Measured` is only constructible when the report carries a + reference device, non-zero paired samples, minimum coverage, and a + reproducer command string; otherwise the report grades as `Claimed` (real + data, no reference) or `Synthetic` (generated input). This mirrors + ADR-291's enforcement-in-types approach and the CLAUDE.md tagging rule. + +## Consequences + +- RuView can convert vitals claims from CLAIMED to MEASURED with a + reproducible offline analysis, session by session, scope by scope. +- Honest reporting will likely show heart-rate performance below marketing + intuition, especially NLOS/moving — that is the purpose. +- CSV ingest means a manual export step per session; acceptable at current + scale, and the format is the de-facto export of consumer reference gear. +- No clinical claim is implied: agreement statistics against consumer + reference devices are engineering evidence, not medical validation. + +## Validation + +- `cargo test -p wifi-densepose-vitals` — CSV rejection cases, alignment + recovery of known synthetic offsets/drifts, agreement metrics against + hand-computed fixtures, evidence-grade constructibility rules. +- `cargo bench -p wifi-densepose-vitals` — criterion benchmark for alignment + over hour-scale synthetic sessions. +- Real-session validation (ESP32 capture + chest strap) remains a follow-up + requiring hardware evidence per CLAUDE.md. diff --git a/docs/adr/ADR-294-wifi-veil-integration.md b/docs/adr/ADR-294-wifi-veil-integration.md new file mode 100644 index 00000000..5e4c7691 --- /dev/null +++ b/docs/adr/ADR-294-wifi-veil-integration.md @@ -0,0 +1,83 @@ +# ADR-294: WiFi Veil integration — emission-shaping countermeasure as an advisory BFLD dependency + +- **Status**: Accepted — initial implementation (this PR) +- **Date**: 2026-08-10 +- **Deciders**: ruv +- **Tags**: privacy, bfld, bfi, wifi-veil, countermeasure, dependency + +## Context + +RuView's BFLD layer (ADR-118, ADR-141) senses via beamforming feedback while +enforcing structural privacy invariants on data entering the node. The 2026 +research sweep identified the complementary, unaddressed surface: a node's own +*outgoing* BFI is unencrypted and enables passive third-party +re-identification (BFId, ACM CCS 2025); IEEE 802.11bf-2025 shipped with no +privacy mechanism; and no commercial product occupies the countermeasure +category. + +[`wifi-veil`](https://github.com/ruvnet/wifi-veil) (codename VEIL, extracted +from this monorepo as a standalone crate) models a compliant emission-shaping +defense: keyed Givens rotations over the fine subspace of compressed +beamforming reports, energy-preserving (never jamming), reversible by a +keyed legitimate receiver. The crate is dependency-free, deterministic, +std-only, WASM-ready, dual MIT/Apache-2.0, and explicitly SYNTHETIC/L0: it +models waveform controls and never drives a radio. + +RuView should consume this capability rather than re-implement it, giving the +sensing stack a defensive counterpart under one evidence regime. + +## Options considered + +1. **Vendor the veil sources into a RuView crate.** Rejected: forks the + witness-pinned upstream and duplicates maintenance. +2. **crates.io dependency.** Not yet available (v0.1.0 unpublished at + decision time); revisit when released. +3. **Git dependency pinned to an exact rev, feature-gated in + `wifi-densepose-bfld`.** Chosen. + +## Decision + +- Add `wifi-veil` to `v2/Cargo.toml` `[workspace.dependencies]` as a git + dependency pinned to rev `018468b5d2bf41f35c552910f35659830af0eb91` + (v0.1.0). Exact-rev pinning preserves provenance and reproducibility for a + pre-release upstream; bumping the rev is an explicit, reviewable change. +- Gate it in `wifi-densepose-bfld` behind a new `veil` feature + (`veil = ["std", "dep:wifi-veil"]`), off by default — the default build + remains dependency-light and unchanged. +- New `bfld::veil` module (advisory-only): + - `ShieldAssessment`: stable projection of wifi-veil's deterministic + attacker-vs-protector `ExperimentReport` (re-ID accuracy shield-off/on, + chance level, throughput ratio, energy-conservation audit), always + carrying the `SYNTHETIC/L0` evidence label. + - `assess` / `assess_default`: run the deterministic experiment. + - `optimized_shield`: wrap `hyper_optimize` to derive the + optimizer-shipped shield config plus its verifying assessment. +- Boundaries, stated structurally and in docs: + - **Advisory only.** Nothing in the integration emits RF, alters frames, + or relaxes any BFLD gate/invariant (I1–I3 untouched). + - **Evidence honesty.** Every veil-derived figure is labeled + `SYNTHETIC/L0`; no MEASURED claim is possible from this path (hardware + validation lives in wifi-veil's own P5 roadmap). + - ESP32 nodes cannot shield their own feedback (per wifi-veil's platform + matrix); the integration therefore informs posture and reporting, not + on-node emission control. + +## Consequences + +- RuView gains a sense-and-defend posture no commercial offering has, under + a single claim taxonomy. +- First git dependency in the workspace: builds now fetch one pinned + external rev. Acceptable: the crate is dependency-free, small, witness- + pinned upstream, and license-compatible (MIT OR Apache-2.0 into MIT). +- Feature-gated consumers (e.g. sensing-server privacy reporting, the + desktop UI) can surface shield assessments later without new deps. +- When wifi-veil publishes to crates.io, switch the workspace entry to a + version requirement in a follow-up ADR amendment. + +## Validation + +- `cargo test -p wifi-densepose-bfld --features veil` — determinism, + shield-reduces-re-ID, compliance (energy conservation), chance-band + attainment, evidence labeling, optimizer wrapper. +- `cargo test -p wifi-densepose-bfld` (default features) — unchanged + behavior with the feature off. diff --git a/docs/adr/ADR-295-source-provenance-state-machine.md b/docs/adr/ADR-295-source-provenance-state-machine.md new file mode 100644 index 00000000..c101d9e6 --- /dev/null +++ b/docs/adr/ADR-295-source-provenance-state-machine.md @@ -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`. diff --git a/docs/adr/ADR-296-sensor-data-plane-bind-hardening.md b/docs/adr/ADR-296-sensor-data-plane-bind-hardening.md new file mode 100644 index 00000000..d6c02792 --- /dev/null +++ b/docs/adr/ADR-296-sensor-data-plane-bind-hardening.md @@ -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. diff --git a/docs/adr/ADR-297-multi-node-semantic-correctness.md b/docs/adr/ADR-297-multi-node-semantic-correctness.md new file mode 100644 index 00000000..f4e0b8dd --- /dev/null +++ b/docs/adr/ADR-297-multi-node-semantic-correctness.md @@ -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`. diff --git a/docs/adr/ADR-298-model-release-sanity-gates.md b/docs/adr/ADR-298-model-release-sanity-gates.md new file mode 100644 index 00000000..6af231e1 --- /dev/null +++ b/docs/adr/ADR-298-model-release-sanity-gates.md @@ -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. diff --git a/docs/adr/ADR-299-csi-data-incident-repo-controls.md b/docs/adr/ADR-299-csi-data-incident-repo-controls.md new file mode 100644 index 00000000..edb677da --- /dev/null +++ b/docs/adr/ADR-299-csi-data-incident-repo-controls.md @@ -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. diff --git a/docs/adr/ADR-300-perception-substrate-program.md b/docs/adr/ADR-300-perception-substrate-program.md new file mode 100644 index 00000000..11b4d087 --- /dev/null +++ b/docs/adr/ADR-300-perception-substrate-program.md @@ -0,0 +1,189 @@ +# ADR-300: RuView perception substrate — a phased program for the calibration, evidence, trust, and deployment layer + +- **Status**: Accepted — program framing; child ADRs carry their own status +- **Date**: 2026-08-11 +- **Deciders**: ruv +- **Tags**: program, architecture, calibration, evidence, provenance, fusion, fleet, epic + +## Context + +Three independent analyses converged on the same conclusion in 2026: a deep +research sweep of the WiFi-sensing state of the art, an external technical and +industry review, and an internal strategic assessment. All three found that +RuView's gap is **not another sensing modality** but the horizontal layer that +turns RF research into repeatable spatial infrastructure — measurement, +calibration, out-of-distribution awareness, evidence accounting, authenticated +identity, a canonical spatial model, and fleet deployment. + +Several of these primitives already have foundations in the tree and should be +**unified and made to produce signed, expiring certificates**, not rebuilt: + +- `wifi-densepose-calibration` (enrollment, bank, anchor, runtime, specialist). +- `frame::EvidenceLevel` L0–L5 as mandatory policy (ADR-282). +- AetherArena benchmark infrastructure — v0 complete, CI-gated, witness ledger, + live HF Space (ADR-149); board intentionally empty (benchmark-first). +- RuField provenance/signature types (ADR-260/262/277/279) and BFLD + attestation (ADR-141). +- `worldgraph` crate; `wifi-densepose-mat/tracking` (tracker, fingerprint). +- The in-flight ADR-295 (provenance state machine), ADR-296 (authenticated + data plane, step one), ADR-298 (model sanity gates) — the first bricks. + +## What RuView is optimizing for + +Not inference capability — **epistemic reliability**: + +``` +signal → observation → calibration → inference → uncertainty → evidence + → certificate → policy → governed action +``` + +That pipeline is the product. The defensible category is not "RuView perceives +the physical world" but "RuView determines what machines are justified in +believing about it, proves why, and constrains what they may do with that +belief." + +### Four non-negotiable program rules + +Every child ADR and implementation is bound by these: + +1. **UNKNOWN is a first-class output, never an error condition.** A surface that + cannot answer says UNKNOWN and stays legible; it does not throw, default to a + confident class, or silently hold a stale value. +2. **Capability certificates bind cryptographically.** Hardware, environment, + model, calibration, metrics, expiry, and evidence level are bound under one + signature (ADR-318/ADR-305). An unsigned or partially-bound certificate is + not a certificate. +3. **One canonical semantics downstream.** Every surface (MQTT, REST, WebSocket, + RuField, Matter, agents, UI) consumes the same Observation → Inference → + GovernedEvent types (ADR-306). No transport- or UI-specific reinterpretation. +4. **Benchmarks expose worst-domain performance and confidence intervals.** + Pooled accuracy is never sufficient for promotion (ADR-317). + +### Certificate conditionality (the staleness guard) + +The central architectural risk is **certificate staleness**: a room can remain +syntactically calibrated while its RF distribution has drifted enough to +invalidate the certificate. Therefore a capability certificate is **conditional +on a continuously evaluated domain signature** (ADR-302), not a one-time stamp. +Crossing the OOD threshold automatically degrades state and triggers +recalibration rather than silently continuing: + +``` +VALID → DEGRADED → UNKNOWN (auto-degrade on domain drift; triggers recalibration) +``` + +This binds ADR-301 (calibration), ADR-302 (OOD), ADR-318 (certificate), and +ADR-321 (policy): a degraded/unknown domain must invalidate the affected +capability *before* a false confident inference reaches an actuator. + +### Commercial framing — three primitives, not one product + +- **RuView Runtime** — provides perception. +- **RuView Certify** — establishes what a deployment can legitimately claim + (calibration + evidence + capability certificate + policy). +- **RuView Trust / Fleet** — keeps that claim valid across hardware, firmware, + models, and environmental drift (ADR-316). + +Certify and Trust are the parts that are hard to commoditize; presence +detection alone is not. + +## Decision + +Adopt a **21-primitive phased program**. Each primitive gets a child ADR +(ADR-301…ADR-321) that owns its detailed decision, status, and validation. +This ADR owns the framing, the dependency order, and the phase assignment. + +### Primitive → ADR map + +| # | Primitive | ADR | Phase | +|---|---|---|---| +| 1 | Automatic domain calibration | ADR-301 | 1 | +| 2 | Out-of-distribution detection | ADR-302 | 1 | +| 3 | Ground-truth synchronization | ADR-303 | 2 | +| 4 | Evidence engine | ADR-304 | 1 | +| 5 | Authenticated sensor identity | ADR-305 | 1 | +| 6 | Canonical spatial ontology | ADR-306 | 1 | +| 7 | Persistent identity & tracking | ADR-307 | 2 | +| 8 | Sensor placement optimizer | ADR-308 | 3 | +| 9 | Active sensing | ADR-309 | 3 | +| 10 | 802.11bf-native architecture | ADR-310 | 2 | +| 11 | Real sensor fusion | ADR-311 | 2 | +| 12 | Long-term spatial memory | ADR-312 | 3 | +| 13 | Counterfactual inference | ADR-313 | 3 | +| 14 | Information-gain scheduler | ADR-314 | 3 | +| 15 | Digital RF twin | ADR-315 | 3 | +| 16 | Fleet control plane | ADR-316 | 2 | +| 17 | Real benchmark service (multi-domain scorecard) | ADR-317 | 1 | +| 18 | Capability certificates | ADR-318 | 1 | +| 19 | Witness chain | ADR-319 | 1 | +| 20 | RuView sensor HAL | ADR-320 | 2 | +| 21 | Decision policy — action authorization | ADR-321 | 1 | + +### Dependency order (why phase, not score, drives sequencing) + +``` + ADR-306 spatial ontology ──┐ + ADR-305 auth identity ─────┼──► ADR-301 calibration cert ──► ADR-302 OOD gating + │ │ │ + └──► ADR-319 witness chain │ (VALID→DEGRADED→UNKNOWN) + │ ▼ + ADR-304 evidence engine ──► ADR-318 capability certificate + │ │ (conditional on domain signature) + │ ▼ + │ ADR-321 decision policy ──► governed action + └──► ADR-317 benchmark scorecard (per-PR gate) +``` + +- **Phase 1 (the certificate spine, built now):** foundational roots 303, 302, + 301, 298 (implemented first, in their own crates); then the dependent wave + 316, 299, 315, 314, 318. This set is exactly the acceptance test decomposed + and is buildable without new hardware (types, logic, signatures, tests). The + dependent wave adds the staleness guard (299 auto-degrades 315) and the + action gate (318) that denies at the actuator on a degraded/unknown domain. +- **Phase 2 (integration & operations):** 300 ground truth, 304 tracking, 307 + 802.11bf-native, 308 fusion, 313 fleet, 317 HAL. Depends on the spine. +- **Phase 3 (higher-ceiling, research-forward):** 305 placement optimizer, 306 + active sensing, 309 spatial memory, 310 counterfactual, 311 info-gain + scheduler, 312 RF twin. Sit on top of the fused world state. + +Phase-2 and phase-3 child ADRs are authored as **Proposed** (design intent, +validation plan) and are not implemented by the phase-1 swarm. + +### Acceptance test A — onboarding (from the strategic assessment) + +> Connect a new sensor type in an unseen room. Within 30 minutes RuView should +> identify the hardware (HAL, ADR-320), calibrate the environment (ADR-301), +> quantify whether it can reliably sense the requested phenomenon (ADR-302), +> generate a signed capability certificate (ADR-318), expose governed spatial +> events (ADR-306), and return UNKNOWN whenever evidence falls outside that +> certificate (ADR-302). + +### Acceptance test B — drift invalidation (the staleness guard) + +> Deliberately change the room after certification — move furniture, change the +> AP channel, or substitute hardware. RuView should detect distribution drift +> (ADR-302), invalidate the affected capability (ADR-318) **before** a false +> confident inference reaches an actuator (ADR-321 denies with the specific +> failed condition), emit UNKNOWN, preserve the complete witness chain +> (ADR-319), and explain exactly which certificate condition failed. + +Test B is the load-bearing one: it proves the substrate fails safe, not just +that it perceives well. Phase 1 makes every clause except HAL testable in +software; HAL (phase 2) +closes the "identify the hardware" clause. + +## Consequences + +- One coherent substrate replaces overlapping ad-hoc schemas; every surface + (MQTT, REST, WebSocket, RuField, Matter, agents) eventually consumes the + ADR-306 ontology and the ADR-318 certificate. +- Headline applications (pose/vitals/pointcloud models) are explicitly **not** + the investment focus during this program, per the strategic direction. +- Later ADRs may be revised as the spine lands; that is expected for a phased + program and is why phase-2/3 ADRs ship as Proposed. + +## Validation + +- Each child ADR defines its own tests. The program-level exit is the + acceptance test above, run end-to-end once phase 1 lands, and encoded as an + AetherArena scenario (ADR-317). diff --git a/docs/adr/ADR-301-automatic-domain-calibration.md b/docs/adr/ADR-301-automatic-domain-calibration.md new file mode 100644 index 00000000..4d48606c --- /dev/null +++ b/docs/adr/ADR-301-automatic-domain-calibration.md @@ -0,0 +1,149 @@ +# ADR-301: Automatic domain calibration — signed, versioned, invalidatable room fingerprint + +- **Status**: Accepted — initial implementation planned (ADR-300 phase 1) +- **Date**: 2026-08-11 +- **Deciders**: ruv +- **Tags**: calibration, provenance, drift, evidence, honesty, substrate + +## Context + +This ADR is primitive 1 of the perception-substrate program (ADR-300) and the +first brick of that program's "certificate spine" (ADR-300 phase 1). It depends +on the canonical spatial ontology (ADR-306) to name *which space* it +characterizes, on authenticated sensor identity (ADR-305) to bind a fingerprint +to *which signed device* produced it, and on the witness chain (ADR-319) to +anchor the resulting artifact. Its output is consumed directly by +out-of-distribution detection (ADR-302). + +WiFi sensing is only reproducible inside the environment it was tuned for. +Multipath, furniture geometry, transceiver placement, and AP channel all shape +the CSI distribution, so a model that reads a room correctly one week can drift +silently the next. RuView already has the raw ingredients for room-aware +sensing but not a single portable, signed, expiring artifact that says "this is +the room, here is when it was measured, and here is the evidence that it is +still the same room." + +Existing scaffolding to build on, not rebuild (`v2/crates/wifi-densepose-calibration`): + +- `enrollment` / `anchor` — guided human anchors with an adaptive quality gate. +- `bank` / `specialist` / `runtime` — a versioned bank of small specialist + models and a confidence-gated mixture runtime (`RoomState`), including the + crate's existing honest `STALE` degradation when the ADR-135 empty-room + baseline drifts. +- `geometry` / `geometry_embedding` — transceiver-geometry record and its + fixed-length conditioning featurization (ADR-152). + +What is missing is (a) an *automatic* observe-only characterization phase that +does not require a human enrollment ritual, (b) empty-vs-occupied baseline +separation as a first-class pair, (c) a signed, versioned, comparable +`CalibrationCertificate` artifact, and (d) explicit invalidation on drift rather +than a soft `STALE` flag buried in the runtime. + +## Options considered + +1. **Keep calibration internal to the runtime (status quo).** Rejected: the + room characterization exists only as in-process state; it cannot be signed, + shipped, compared across time, or presented as evidence to ADR-302/ADR-318. +2. **Build a new calibration crate.** Rejected: `wifi-densepose-calibration` + already owns enrollment, the specialist bank, geometry embedding, and the + baseline-drift concept. A parallel crate would fork the room model. +3. **Extend `wifi-densepose-calibration` with an automatic characterization + phase and a signed certificate artifact.** Chosen. + +## Decision + +Extend `v2/crates/wifi-densepose-calibration` with an `autocal` characterization +phase and a `certificate` artifact module. The target UX is: + +> install → observe (~10 min) → room fingerprint → calibration certificate → +> sensing. + +### 1. Automatic characterization (`autocal`) + +- An observe-only pass (default ~10 minutes, configurable) that collects CSI + without requiring guided human anchors, reusing the `anchor` quality gate to + reject frames it cannot trust. It layers on the existing ADR-135 empty-room + baseline rather than replacing it. +- Produces a `RoomFingerprint`: a bounded, fixed-length statistical summary of + the room's CSI distribution (subcarrier amplitude/phase moments, multipath + structure, occupancy-band energy), plus the `geometry_embedding` when a + geometry record is present. The fingerprint is the distance-comparable object + ADR-302 measures against; its schema is versioned. + +### 2. Empty / occupied baseline pair + +- Characterization establishes a paired baseline: an **empty** distribution + (no occupant motion) and an **occupied** distribution (motion present), + separated by the existing occupancy signal rather than a manual label. Both + are stored on the fingerprint so downstream OOD gating can distinguish "the + empty room changed" (furniture/geometry drift) from "occupancy statistics + changed" (different subject dynamics). + +### 3. `CalibrationCertificate` artifact + +- A serializable `CalibrationCertificate` binding: the `RoomFingerprint`; a + space identifier from the ADR-306 ontology; the signing sensor identity from + ADR-305; `captured_at_unix_s`; a monotonic `version`; a schema version; the + calibration `tier`; and an `EvidenceLevel` (L0–L5, ADR-282) — an automatic + characterization on real captured CSI is at most L1/L2 and is labelled as + such, never L3+. +- The certificate is **signed** using RuField provenance/signature types + (ADR-260/262/277/279) and anchored in the witness chain (ADR-319). Signature + and witness anchoring are mandatory: an unsigned certificate is not a valid + certificate. +- Two certificates for the same space are **comparable**: `distance(a, b)` + returns a bounded fingerprint distance, which is the primitive ADR-302 uses + to gate KNOWN → DEGRADED → UNKNOWN. + +### 4. Invalidation and continuous drift compensation + +- A certificate carries an explicit validity policy: it is invalidated when + fingerprint distance against live traffic exceeds a threshold, when the AP + channel or transceiver geometry changes, when the signing device identity + changes, or on age expiry. Invalidation is an explicit state transition that + emits a witness record (ADR-319), not a silent `STALE` flag. +- Continuous drift compensation runs as a bounded online update of the + fingerprint within a **compatibility envelope**: small drift is absorbed and + logged; drift beyond the envelope invalidates the certificate and forces + re-characterization. Compensation never silently rewrites a signed + certificate — it produces a new version, preserving the append-only history. + +### Provenance and honesty discipline + +- No accuracy number is claimed by this ADR; it delivers the artifact and the + distance/invalidation machinery. Any certificate produced from generated CSI + is L0/`Synthetic` by construction; the constructor rejects labelling + synthetic characterization as measured (ADR-279 invariant 6, ADR-282 ladder). +- Certificates never leave the edge except through the governed control plane + (ADR-277); a room fingerprint is treated as potentially sensitive spatial + data, not free telemetry. + +## Consequences + +- Room characterization becomes a portable, signed, versioned artifact that + ADR-302 (OOD), ADR-318 (capability certificates), and ADR-317 (benchmark) + can consume without re-deriving room state. +- The automatic observe-only path lowers deployment friction (no mandatory + enrollment ritual) but yields a weaker evidence level than guided enrollment; + the certificate states which path produced it so consumers can weight it. +- Explicit invalidation means RuView will sometimes refuse to sense a changed + room until re-characterization. That refusal is the intended honest behavior, + surfaced by ADR-302, not a regression. +- The existing enrollment/bank/runtime path is preserved; `autocal` is an + additional entry point that produces the same `RoomFingerprint` object the + guided path can also emit. + +## Validation + +- `cargo test -p wifi-densepose-calibration` — fingerprint determinism from + fixed synthetic CSI; empty/occupied separation on synthetic occupancy; + certificate signing/verification round-trip and tamper rejection; + `distance()` monotonicity on progressively perturbed fixtures; invalidation + transitions (channel change, geometry change, age, drift-envelope breach) + each emit the expected witness record; constructor rejects synthetic→measured + mislabeling. +- Cross-ADR: an ADR-302 test consumes a certificate and asserts the gating + state transitions on a drifted fingerprint. +- Real-silicon characterization (ESP32 capture over a real 10-minute window) + remains a follow-up requiring hardware evidence per CLAUDE.md; a successful + build or synthetic run is not hardware evidence. diff --git a/docs/adr/ADR-302-out-of-distribution-detection.md b/docs/adr/ADR-302-out-of-distribution-detection.md new file mode 100644 index 00000000..88726158 --- /dev/null +++ b/docs/adr/ADR-302-out-of-distribution-detection.md @@ -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. diff --git a/docs/adr/ADR-303-ground-truth-synchronization.md b/docs/adr/ADR-303-ground-truth-synchronization.md new file mode 100644 index 00000000..0b0928cd --- /dev/null +++ b/docs/adr/ADR-303-ground-truth-synchronization.md @@ -0,0 +1,125 @@ +# ADR-303: Ground-truth synchronization — reference sensors as a formal validation plane + +- **Status**: Accepted — initial implementation (ADR-300 phase 2) +- **Date**: 2026-08-11 +- **Deciders**: ruv +- **Tags**: ground-truth, validation, fusion, evidence, benchmark, honesty, substrate + +## Context + +This ADR is primitive 3 of the perception-substrate program (ADR-300), authored +as **Proposed** in phase 2: it is design intent and a validation plan, not +implemented by the phase-1 swarm. It sits on top of the phase-1 certificate +spine and feeds the evidence engine (ADR-304) and the real benchmark service +(ADR-317). It generalizes the vitals ground-truth rig (ADR-293) from a single +measurand to a modality-agnostic plane. + +RuView's evidence discipline (CLAUDE.md; ADR-282 ladder) requires MEASURED +accuracy claims to be backed by an independent reference. ADR-293 built exactly +this for vitals: reference-series ingest, time alignment (cross-correlation +lag + optional clock-drift fit), and agreement statistics (MAE/RMSE/bias/ +Bland–Altman/within-tolerance), with an `EvidenceGrade` that is only +constructible as `Measured` when a real reference, non-zero paired samples, +minimum coverage, and a reproducer are present. That machinery is measurand- and +device-shaped: it knows about heart rate and breathing rate. + +The substrate needs the same discipline for *every* phenomenon RuView senses — +presence, count, localization, pose, posture, activity — and for reference +sources of many modalities (cameras, mmWave, pressure mats, wearables, pulse +oximeters, microphones, manual labels). The critical design decision is that +these reference sensors form a **validation plane**, not additional inference +inputs. + +## Options considered + +1. **Fuse reference sensors as extra inference inputs.** Rejected on principle: + folding cameras/mmWave into the estimator would make RuView's RF claims + unfalsifiable — the reference would be training the thing it is meant to + check, and a camera-fed result is no longer a camera-free RF result. It + would also violate the ADR-282 layering (RuView is probabilistic + exteroception, never ground truth) and the honesty rule against presenting + fused-with-camera output as WiFi sensing. +2. **One-off rigs per measurand (extend ADR-293 ad hoc each time).** Rejected: + duplicates alignment/agreement code per phenomenon and never yields a shared + validation surface for the benchmark. +3. **A first-class, modality-agnostic `GroundTruth` API that is strictly a + validation plane.** Chosen. + +## Decision + +Introduce a `GroundTruth` API — a modality-agnostic validation plane that +compares RF inference against independent observation and never feeds it. + +### 1. Modality-agnostic reference ingest + +- A `ReferenceObservation` generalizing ADR-293's `ReferenceSeries`: a + timestamped, typed observation of a `Phenomenon` (presence, count, + localization, pose keypoints, posture, activity, heart rate, breathing rate) + from a `ReferenceModality` (camera, mmWave, pressure, wearable, pulse + oximeter, microphone, manual label), with device/source metadata and the + measurement principle recorded. +- Untrusted reference files are validated at the boundary (row-numbered + rejections, non-monotonic timestamps are errors), reusing ADR-293's ingest + discipline. Camera/mmWave references arrive as exported label/keypoint + streams, not live model feeds. + +### 2. Synchronization + +- Generalize ADR-293's time alignment (bounded-lag normalized cross-correlation + + optional linear clock-drift fit) to arbitrary measurands on a common + resampled grid, with no interpolation across gaps beyond a configurable + limit. Alignment parameters are always reported, never silently applied. +- Spatial synchronization where relevant: reference observations are expressed + in the ADR-306 spatial ontology so an RF localization/pose result and a + camera/mmWave observation are compared in one coordinate frame. + +### 3. Agreement as validation, not fusion + +- A modality-appropriate `AgreementReport` per phenomenon: continuous + measurands reuse ADR-293's MAE/RMSE/bias/Bland–Altman/within-tolerance; + categorical/detection phenomena (presence, activity) report confusion-matrix + metrics; spatial phenomena report localization error percentiles and pose + PCK **with the mandatory mean-pose baseline and leakage-free split** + (CLAUDE.md; ADR-291). +- Session scope is mandatory metadata (subject count, motion state, LOS/NLOS/ + through-wall, distance band) — a report without scope cannot be constructed, + as in ADR-293. + +### 4. Evidence and isolation guarantees + +- The plane is one-directional by type: the inference path has no read access + to `GroundTruth` at runtime. A build/test-time isolation check (and the type + boundary) prevents a reference observation from becoming an estimator input. +- Reports carry an `EvidenceLevel` (ADR-282) and an `EvidenceGrade` + constructible as `Measured` only with a real reference, paired samples, + coverage, and a reproducer (ADR-293 rule). Reports feed the ADR-304 evidence + engine and are the substrate ADR-317 scores against. + +## Consequences + +- Every phenomenon RuView senses gets the same MEASURED-vs-independent-observer + discipline vitals already has, in one shared surface. +- Keeping references strictly as validation preserves the falsifiability and + the camera-free identity of RF results; it costs the (tempting) accuracy a + camera-fused estimator would show, which is the correct trade. +- Reference capture is an operational burden (a camera/mmWave rig per validated + session); acceptable because it is a validation activity, not a runtime + requirement, and it is what turns CLAIMED into MEASURED. +- Because this is Proposed (phase 2), the API shape may be revised once the + phase-1 spine (ADR-301/299/301/303) lands and the benchmark (ADR-317) + exercises it. + +## Validation + +- Unit tests (planned): modality-agnostic ingest rejection cases; alignment + recovery of known synthetic offsets/drifts across measurands; agreement math + per phenomenon against hand-computed fixtures; pose PCK path requires a + mean-pose baseline and rejects leaky splits; evidence-grade constructibility; + the isolation check fails a build that wires a reference into the inference + path. +- Cross-ADR: an ADR-317 benchmark scenario consumes `GroundTruth` reports as + its scored reference; ADR-304 ingests the agreement reports as evidence + records. +- Real-session validation (RF capture synchronized with a real camera/mmWave/ + pressure/wearable reference) is the phase-2 exit and requires hardware + evidence per CLAUDE.md; a synthetic run is not hardware evidence. diff --git a/docs/adr/ADR-304-evidence-engine.md b/docs/adr/ADR-304-evidence-engine.md new file mode 100644 index 00000000..3fd9ba2f --- /dev/null +++ b/docs/adr/ADR-304-evidence-engine.md @@ -0,0 +1,117 @@ +# ADR-304: Evidence engine — MLflow for physical sensing + +- **Status**: Accepted — initial implementation planned (ADR-300 phase 1) +- **Date**: 2026-08-11 +- **Deciders**: ruv +- **Tags**: evidence, provenance, ledger, accuracy, drift, benchmark, honesty, substrate + +## Context + +This ADR is primitive 4 of the perception-substrate program (ADR-300) and a +central pillar of the phase-1 certificate spine. It consumes the domain state +from out-of-distribution detection (ADR-302) and the calibration age from the +calibration certificate (ADR-301), it is the store that capability certificates +(ADR-318) are minted from, and it is the accuracy source the real benchmark +service (ADR-317) reads. In phase 2 it ingests agreement reports from the +ground-truth plane (ADR-303). + +The strategic assessment (ADR-300) judged this primitive **more commercially +important than another pose architecture**: what unblocks OEM and integrator +conversations is not a higher headline number but a defensible, auditable record +of how a model actually performs, per room, per device, per subject, over time. +MLflow made ML experiments trackable; physical sensing needs the equivalent for +deployed accuracy, drift, and evidence level — an append-only ledger, not a +dashboard that overwrites yesterday's number. + +RuView already has the constituent evidence types; what is missing is the ledger +that unifies them per deployment context: + +- RuField provenance/signature types (ADR-260/262/277/279) — the signed, + provenance-bearing record types to reuse rather than reinvent. +- The AetherArena witness-ledger pattern (ADR-149) — an append-only, + witness-anchored ledger of scored results, the structural template here. +- `frame::EvidenceLevel` L0–L5 (ADR-282) — the mandatory evidence tag every + record carries. +- ADR-302 `DomainState`, ADR-137 `QualityScore`, ADR-301 certificate version + and age — the per-inference signals to accumulate. + +## Options considered + +1. **Log accuracy to flat files / metrics dashboards.** Rejected: mutable, + un-signed, un-scoped, and not comparable over time — the exact gap. +2. **Reuse a general experiment tracker (MLflow itself).** Rejected: it is + experiment-time, not deployment-time; it has no notion of room/device/ + subject context, calibration age, evidence level, or signed provenance, and + it would add an external service dependency contrary to the substrate's + edge-first, dependency-light direction. +3. **A native append-only evidence ledger reusing RuField record types and the + AetherArena ledger pattern.** Chosen. + +## Decision + +Build an **evidence engine**: a per-`(room, device, subject)` append-only +accuracy ledger that every model automatically writes to. + +### 1. The evidence record + +- An `EvidenceRecord` keyed by context — space id (ADR-306), signed device id + (ADR-305), and subject id where consented and available — carrying: model + version; calibration certificate version and **age** (ADR-301); the ADR-302 + `DomainState` (KNOWN/DEGRADED/UNKNOWN) and its four inputs; the ADR-137 + quality signals; predictive uncertainty; and, when a reference is present + (ADR-303), the agreement result (accuracy, false-positive rate). Each record + carries exactly one `EvidenceLevel` (L0–L5, ADR-282). +- Records are **append-only** and signed with RuField signature types + (ADR-260/262/277/279); the ledger is anchored in the witness chain (ADR-319), + following the AetherArena witness-ledger pattern (ADR-149). No record is ever + mutated in place — a correction is a new record. + +### 2. Per-context accuracy accounting + +- The engine maintains, per `(room, device, subject)` context: measured + accuracy (only where an ADR-303 reference backs it — otherwise the record is + CLAIMED/SYNTHETIC, never MEASURED), false-positive rate, drift trajectory + (fingerprint distance over time from ADR-301), the fraction of inferences in + each domain state, calibration age distribution, and model-version history. +- Aggregation is a pure function over the append-only log at a queried time — + the ledger is the source of truth; summaries are derived, never authoritative + (mirroring CLAUDE.md's "source over summaries" rule). + +### 3. Honesty enforced in the record + +- The engine cannot upgrade an evidence level; a level is set by the record's + provenance at write time (synthetic input → L0/`Synthetic`; no reference → + CLAIMED; reference + reproducer → MEASURED), reusing the ADR-282/ADR-291/ + ADR-293 constructor discipline. A benchmark or certificate reading the ledger + gets the honest level, not an optimistic rollup. +- No benchmark numbers are invented by this ADR; it delivers the ledger and the + accounting. Empty contexts report "no evidence," which downstream (ADR-318) + must treat as no capability. + +## Consequences + +- RuView gains a single auditable answer to "how well does this model actually + work, here, on this device, for this subject, and how fresh is the + calibration?" — the artifact OEM/integrator diligence actually asks for. +- ADR-318 capability certificates become derivable (a certificate is a signed + attestation over a slice of the ledger) and ADR-317 gains a real accuracy + source per PR instead of self-reported numbers. +- The append-only, signed design has storage and key-management cost; bounded + by per-context retention policy and by reusing the existing RuField/witness + infrastructure rather than a new store. +- Some contexts will show sparse or unflattering evidence. Surfacing that is the + point; the engine must never paper over a thin context with a global average. + +## Validation + +- `cargo test` on the evidence-engine crate — append-only invariant (no + in-place mutation; corrections are new records); per-context aggregation math + against fixtures; evidence-level is set by provenance and cannot be upgraded; + signature round-trip and tamper rejection; witness anchoring; empty-context + queries return "no evidence" not a fabricated number. +- Cross-ADR: ingests ADR-302 `DomainState` and (phase 2) ADR-303 agreement + reports; an ADR-318 test mints a certificate from a ledger slice and an + ADR-317 test reads accuracy from the ledger. +- Real-deployment evidence (a populated ledger from live ESP32 captures with + ADR-303 references) is the maturity milestone and requires hardware evidence + per CLAUDE.md; a synthetic ledger is L0 by construction. diff --git a/docs/adr/ADR-305-authenticated-sensor-identity.md b/docs/adr/ADR-305-authenticated-sensor-identity.md new file mode 100644 index 00000000..7fec3e46 --- /dev/null +++ b/docs/adr/ADR-305-authenticated-sensor-identity.md @@ -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. diff --git a/docs/adr/ADR-306-canonical-spatial-ontology.md b/docs/adr/ADR-306-canonical-spatial-ontology.md new file mode 100644 index 00000000..4ef71d6b --- /dev/null +++ b/docs/adr/ADR-306-canonical-spatial-ontology.md @@ -0,0 +1,142 @@ +# ADR-306: Canonical spatial ontology — one Site→…→Event model for every surface + +- **Status**: Accepted — initial implementation planned (ADR-300 phase 1) +- **Date**: 2026-08-11 +- **Deciders**: ruv +- **Tags**: ontology, worldgraph, schema, mqtt, matter, rufield, phase-1 + +## Context + +This ADR is a child of **ADR-300** and owns primitive #6, *canonical spatial +ontology*. In the ADR-300 DAG it is a spine root alongside **ADR-305** +(authenticated identity) and feeds every downstream primitive that must speak +about *where* and *what*: **ADR-301** (calibration), **ADR-307** (tracking, +consumes `Track`/`Person`), **ADR-319** (witness chain), and every external +surface named in the ADR-300 consequences (MQTT, REST, WebSocket, RuField, +Matter, agents). + +RuView currently expresses "where something is" in several overlapping, +per-surface schemas: the MQTT/Home-Assistant mapper has its own node/room +shapes (**ADR-297** just introduced `NodeInference`/`RoomInference` to +disambiguate node vs. room state); the `worldgraph` crate models a spatial +graph; RuField carries `SemanticProvenance`; Matter/HomeKit has its own area +model. The same physical fact — "a person is in the kitchen" — is re-encoded +differently on each surface, and the review called for "one canonical +`NodeInference`/`RoomInference` contract" (ADR-297 consequences). Without a +single semantic model, every new surface multiplies the translation matrix and +each translation is a place where provenance and evidence level (ADR-282) can +be silently dropped. + +Substantial scaffolding already exists and must be **reused/extended, not +rebuilt**. `v2/crates/worldgraph/wifi-densepose-worldgraph` already defines: + +- `WorldNode` variants including `Room { area_id, name, bounds_enu, floor }`, + `Zone { parent_room, … }`, `Wall { rf_attenuation_db }`, and `Doorway`. +- `WorldEdge` variants including `Observes { quality, last_seen_unix_ms }`, + `LocatedIn { since_unix_ms }`, `AdjacentTo { via_doorway }`, and `Supports`. +- `WorldGraph`, `WorldGraphSnapshot`, `WorldId`, `SemanticProvenance`, + `PersonPosition`, and a HomeCore `area_id` linkage join key (ADR-127). + +The `worldgraph` crate is therefore the natural home for the canonical model. +What is missing is (a) the full `Site → Building → Floor → Space → Zone` +containment spine above `Room`, (b) first-class `Sensor`, `Object`, +`Observation`, `Track`, and `Event` node types, (c) one canonical serialization +that every surface consumes, and (d) a documented migration path from the +existing per-surface schemas. + +## Options considered + +1. **Leave each surface with its own schema; add adapters pairwise.** Rejected: + O(surfaces²) translations, and provenance/evidence loss at each hop. +2. **Invent a new top-level ontology crate.** Rejected: `worldgraph` already + models rooms, zones, walls, doorways, observation edges, and HomeCore + linkage; a parallel crate would fork the world model. +3. **Extend `worldgraph` into the canonical ontology and make every surface a + projection of it.** Chosen. + +## Decision + +Adopt **one canonical spatial ontology**, hosted in the `worldgraph` crate, +that every RuView surface reads from and writes to. + +### 1. The containment spine and entity types + +Define the full node taxonomy as an extension of the existing `WorldNode`: + +``` +Site ▸ Building ▸ Floor ▸ Space ▸ Zone + └─▸ { Sensor, Person, Object, + Observation, Track, Event } +``` + +- `Site`, `Building`, `Floor`, `Space` are new containment `WorldNode` + variants above the existing `Room` (mapped to `Space`, keeping its `area_id` + and `bounds_enu`) and `Zone`. `Wall`/`Doorway` remain as topological + elements. Containment reuses the existing `LocatedIn`/`AdjacentTo` edge + vocabulary; a new `PartOf` edge expresses the pure hierarchy + (Zone `PartOf` Space `PartOf` Floor …). +- `Sensor` is the entity **ADR-305** authenticates (`DeviceId` as its stable + identity) and **ADR-320** (HAL, phase 2) describes the hardware of. `Person`, + `Object`, `Observation`, `Track`, and `Event` are first-class nodes. + `Observes`/`LocatedIn` edges already carry quality and dwell timestamps. +- `Track` and `Person` are defined **here** as the ontology contract that + **ADR-307** (persistent tracking) produces and updates. `Observation` is what + an authenticated frame (ADR-305) becomes after calibration (ADR-301), and + `Event` is the governed output that ADR-318 certifies and ADR-319 witnesses. + +### 2. Canonical serialization + +- A single, versioned serialization (serde-based, stable field names) is the + one wire/at-rest representation. Every surface — MQTT/Home-Assistant, REST, + WebSocket, RuField observations, Matter/HomeKit, agent queries — is a + **projection** of this model, not an independent schema. `NodeInference` and + `RoomInference` (ADR-297) become projections of `Sensor→Observes` and the + `Space`-level fused inference respectively, so ADR-297's node/room separation + is preserved by construction rather than re-encoded per surface. +- Every node and edge carries `SemanticProvenance` and exactly one + `EvidenceLevel` (L0–L5, ADR-282 policy): the evidence ladder travels *with* + the fact across every projection, so no surface can silently upgrade or drop + it. + +### 3. Migration path + +- Each existing per-surface schema gets a documented, tested bidirectional + mapping to/from the canonical model, plus a migration accessor for consumers + reading the old shape (mirroring ADR-297's migration accessor). Surfaces are + cut over one at a time; a surface is "canonical" once its projection is the + only encoder it uses. Until cutover, the mapping layer is authoritative and + round-trip-tested so no fact is lost in translation. +- The `worldgraph` HomeCore `area_id` linkage (ADR-127) remains the join key + between the ontology's `Space` and external area registries. + +## Consequences + +- The translation matrix collapses from O(surfaces²) to O(surfaces): each + surface implements one projection. New surfaces (ROS 2, OpenUSD, OPC UA per + ADR-282's roadmap) plug in as additional projections. +- Provenance and evidence level are carried uniformly; a fact cannot cross a + surface boundary and lose its lineage or its L-level. +- A schema change reaching every surface; managed by the versioned + serialization and per-surface migration accessors. Single-node deployments + keep working (one `Sensor`, one `Space`). +- The ontology is a *representation*, not an inference engine: it says nothing + about *how* a `Track` or `Event` is produced — that is owned by ADR-307, + ADR-301, ADR-302, and the model layer. This ADR does not itself make any + accuracy claim to grade. +- Extending `worldgraph` grows one crate's surface rather than forking a second + world model; the geo/worldmodel sub-crates continue to build on the same node + vocabulary. + +## Validation + +- Unit tests (`cargo test -p wifi-densepose-worldgraph`): containment-spine + construction and invariants (a `Zone` is `PartOf` exactly one `Space`, a + `Space` on exactly one `Floor`, etc.); round-trip serialization of every node + and edge type; every node/edge carries exactly one `EvidenceLevel`. +- Migration tests: each per-surface schema maps to the canonical model and back + with no loss of provenance or evidence level; `NodeInference`/`RoomInference` + (ADR-297) project and re-project identically. +- Contract test: a single canonical `Event` renders correctly through the MQTT, + REST, and WebSocket projections from one source of truth. +- No accuracy numbers are claimed; this ADR delivers the shared representation + the rest of the phase-1 spine writes into. diff --git a/docs/adr/ADR-307-persistent-identity-tracking.md b/docs/adr/ADR-307-persistent-identity-tracking.md new file mode 100644 index 00000000..8aa5c75d --- /dev/null +++ b/docs/adr/ADR-307-persistent-identity-tracking.md @@ -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. diff --git a/docs/adr/ADR-308-sensor-placement-optimizer.md b/docs/adr/ADR-308-sensor-placement-optimizer.md new file mode 100644 index 00000000..2062bbed --- /dev/null +++ b/docs/adr/ADR-308-sensor-placement-optimizer.md @@ -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. diff --git a/docs/adr/ADR-309-active-sensing.md b/docs/adr/ADR-309-active-sensing.md new file mode 100644 index 00000000..c6b6aa20 --- /dev/null +++ b/docs/adr/ADR-309-active-sensing.md @@ -0,0 +1,152 @@ +# ADR-309: Active sensing — closed-loop RF experiment control + +- **Status**: Accepted — initial implementation (ADR-300 phase 3) +- **Date**: 2026-08-11 +- **Deciders**: ruv +- **Tags**: active-sensing, control-plane, closed-loop, information-gain, actuation, phase-3 + +## Context + +This ADR is a child of **ADR-300** and owns primitive #9, *active sensing*. In +the ADR-300 phasing it is a phase-3 primitive that sits on top of the fused +world state produced by **ADR-311** (real sensor fusion) and is driven by the +information budget of **ADR-314** (information-gain scheduler). It is authored +as **Proposed**: design intent and validation plan, not a phase-1 build. + +The default posture of every current RuView path is **passive**: RF traffic +happens for its own reasons (a device transmits, a beacon fires), RuView +observes whatever CSI/CIR arrives, and the pipeline extracts what it can from +that incidental signal. The strategic assessment behind ADR-300 named the next +step: move from *RF-happens → observe* to **RuView-controls-RF → observe the +response → optimize the next measurement**. That turns sensing into a +closed-loop experiment — the system chooses what to measure to resolve the +uncertainty it currently has, rather than accepting the measurements the +environment happens to offer. + +Substantial control-plane scaffolding already exists and must be +**reused/extended, not rebuilt**: + +- **ADR-280** (active sensing / programmable perception, *implemented* in + `ruview-unified/src/control.rs`) already defines the governed control surface + this ADR closes the loop over: `SensingTask` (evidence-aware, fail-closed + admission), `SensingAction` + `InformationGoal` (a deliberate act of + evidence-gathering against a stated hypothesis, bounded by a `PrivacyClass` + P0–P5 ceiling), `ActiveSensingPlanner` (age-of-information scheduler), + `CoherentSensorGroup` (coherent fusion fails closed), and `request_actuation` + → `ActuationReceipt` for governed RIS/movable/fluid-antenna actuation. +- ADR-280 explicitly recorded that **information-gain *estimation* is not + implemented** — "the planner uses staleness heuristics, not mutual + information; RIS drivers, actual multi-AP coherence measurement, and OTFS + waveform control are hardware-dependent roadmap items." ADR-309 is the ADR + that closes exactly those gaps, in coordination with ADR-314. + +The missing piece is not the actuation surface — ADR-280 built that and made it +fail closed — but the **loop**: a controller that reads the current fused-state +uncertainty, selects a *controllable measurement configuration* expected to +reduce it most, requests it through the ADR-280 governed surface, observes the +response, and updates its belief before choosing the next measurement. + +## Options considered + +1. **Stay passive; only schedule which incidental observations to keep.** This + is roughly today's `ActiveSensingPlanner` (staleness-priority over regions). + Rejected as the endpoint: it optimizes *attention* over uncontrolled RF, not + the *measurement* itself. It remains the fallback when nothing is + controllable. +2. **Open-loop measurement scripting** (a fixed sweep of channels/bandwidths). + Rejected: a fixed sweep spends the RF/energy/privacy budget the same way + regardless of what is already known; it cannot concentrate measurement where + uncertainty actually is. +3. **Closed-loop experiment control** — read uncertainty, pick the controllable + configuration with highest expected information gain per unit cost/privacy, + actuate through the ADR-280 governed surface, observe, update, repeat. + Chosen. + +## Decision + +Adopt **closed-loop RF experiment control** as a phase-3 controller layered on +the ADR-280 surface. RuView selects and drives the controllable degrees of +freedom of the RF measurement, then optimizes the next measurement from the +observed response. + +### 1. Controllable degrees of freedom + +Define an `ExperimentControl` vocabulary over the configuration axes RuView can +influence on hardware that exposes them (each axis is optional and +capability-gated by ADR-320's HAL, so an ESP32-only deployment simply has an +empty controllable set and degrades to the passive planner): + +- **Channel / band** and **bandwidth** (which spectrum to probe; reuses the + ADR-292 wideband subcarrier-agnostic metadata). +- **Packet timing / cadence** (when to solicit a sounding, and at what rate). +- **Antenna / chain selection** (which subset of a distributed aperture to + activate — bounded by the ADR-280 `CoherentSensorGroup` compatibility proof). +- **Beam / RIS configuration** (which rooms and people become observable — + governed exactly as ADR-280 §6 requires, via `request_actuation` and an + `ActuationReceipt`). +- **802.11bf measurement parameters** (TB/non-TB, reporting config) once + ADR-310 exposes standardized sensing as a native measurement type. + +### 2. The loop + +``` +fused-state uncertainty (ADR-311) + │ + ▼ +info-gain ranking of ExperimentControl options (ADR-314) + │ select argmax E[ΔI] / (cost, energy, privacy ceiling) + ▼ +governed request (ADR-280 admit_task / request_actuation, fail-closed) + │ + ▼ +observe response → update belief (ADR-311) → repeat +``` + +The controller never bypasses the ADR-280 admission and actuation gates: every +solicited measurement is a `SensingTask`/`SensingAction`, every environment +change is an `ActuationReceipt`, and every step composes with the ADR-277 +policy engine. Information gain is what **ADR-314** supplies (the mutual- +information estimate ADR-280 deferred); ADR-309 owns the *control loop* that +consumes that estimate and drives the hardware. + +### 3. Governance and honesty boundary + +- Actuation and solicitation stay fail-closed and privacy-ceilinged: a + closed-loop experiment cannot widen the P0–P5 ceiling of the task it serves, + and cannot steer a beam into a zone that does not grant the purpose (ADR-280 + `actuation_requires_policy_authorization`). +- Any accuracy or "traffic-reduction" claim from the closed loop is tagged + **MEASURED** only with a named reproducer over a stated scenario, **SYNTHETIC** + for simulated apertures, and **CLAIMED** otherwise. Real multi-AP coherent + measurement and RIS actuation remain **hardware-dependent** and require + real-silicon evidence (a captured runtime log) before any hardware claim, per + CLAUDE.md. No number is invented here. + +## Consequences + +- Sensing becomes an experiment: RuView spends its RF/energy/privacy budget on + the measurements that most reduce current uncertainty, instead of processing + whatever incidental traffic arrives. +- The loop is only as strong as its two dependencies: ADR-311 must expose a + usable uncertainty surface and ADR-314 must produce trustworthy information- + gain estimates. Where either is absent, the controller degrades to the + ADR-280 staleness planner rather than acting on a fabricated gain estimate. +- Controllability is hardware-bounded. On commodity ESP32 sensors the + controllable set may be limited to cadence; the full loop (bandwidth, antenna, + beam) needs NICs/RIS that expose those axes, surfaced through ADR-320. +- This ADR adds a controller; it does not re-open ADR-280's raw-export or + actuation-governance decisions, which remain authoritative and fail-closed. + +## Validation + +- Design-level acceptance (phase 3): a simulated closed loop over a synthetic + scene reduces terminal fused-state uncertainty faster than (a) the passive + ADR-280 staleness planner and (b) an open-loop fixed sweep, at equal + measurement budget — reported **SYNTHETIC**, with the scenario and seed named. +- Governance tests: every solicited measurement and actuation in the loop is + admitted through the ADR-280 fail-closed path; a loop step that would exceed + the task's privacy ceiling or steer into an ungranted zone is denied. +- Degradation test: with an empty controllable set (ESP32-only), the controller + falls back to the staleness planner with no error and no fabricated gain. +- Hardware validation of bandwidth/antenna/beam actuation is explicitly out of + scope until real silicon exposes those axes and produces a captured log. diff --git a/docs/adr/ADR-310-80211bf-native-architecture.md b/docs/adr/ADR-310-80211bf-native-architecture.md new file mode 100644 index 00000000..3b52ff05 --- /dev/null +++ b/docs/adr/ADR-310-80211bf-native-architecture.md @@ -0,0 +1,147 @@ +# ADR-310: 802.11bf-native architecture — standardized WLAN sensing as native measurement types + +- **Status**: Proposed (ADR-300 phase 2) +- **Date**: 2026-08-11 +- **Deciders**: ruv +- **Tags**: 80211bf, wlan-sensing, standards, measurement-types, hal, phase-2 + +## Context + +This ADR is a child of **ADR-300** and owns primitive #10, *802.11bf-native +architecture*. In the ADR-300 phasing it is a phase-2 integration primitive: it +sits on the phase-1 spine (authenticated identity ADR-305, spatial ontology +ADR-306, evidence engine ADR-304) and **feeds ADR-320** (the RuView sensor HAL), +which is the clause of the acceptance test that "identifies the hardware." It is +authored as **Proposed**. + +**IEEE 802.11bf-2025 ("WLAN Sensing") was published 2025-09-26** — verified +against the IEEE SA record in `wifi-densepose-hardware` (`ieee80211bf/mod.rs` +header, "evidence grade MEASURED", ADR-152 §1.1). Standardization is complete +for sub-7 GHz and >45 GHz (DMG) bands: formal sensing measurement setup, +measurement instances, feedback/reporting, and sensing-by-proxy (SBP). This +changes RuView's strategic frame: rather than treating every WiFi measurement as +an *opportunistic* extraction from incidental traffic, RuView can be the **open +reference sensing stack around the standard** — the day commodity silicon +exposes it. + +Substantial scaffolding already exists and must be **reused/extended, not +rebuilt**. `v2/crates/wifi-densepose-hardware/src/ieee80211bf/` already models +the standardized procedure surface as forward-compatible types (ADR-152/153): + +- `types` — `SpecProfile` version gates, `SensingRole`/`TransceiverRole`, + `MeasurementSetupParams`, `SensingCapabilities` negotiation, and required + `ConsentMode` governance metadata on every setup. +- `messages` — `SensingMeasurementSetupRequest/Response`, + `SensingMeasurementInstance`, `SensingMeasurementReport`, `CsiReportPayload`, + `SbpRequest/Response`, `SensingSessionTermination`. +- `session` — a deterministic FSM (`Idle → SetupNegotiating → Active → + Terminating → Idle`) with rejection paths, single-role enforcement, and SBP + proxy mode; `table` (responder-side setup registry); `transport` (the + `SensingTransport` seam, a `SimTransport` test double, and an + `OpportunisticCsiBridge` that maps today's opportunistic CSI onto the + standardized report path). + +The module's own honesty note is authoritative and carried forward here: it is +**not a certified 802.11bf implementation**, and **no commodity silicon — ESP32 +included — implements the standard yet**; the OTA frame binding lands when a +chipset exposes it. Wideband ingest plumbing is already in place too: **ADR-292** +(FeitCSI/AX210) carries native subcarrier dimensionality end-to-end and records +the native→pipeline mapping, and noted that "truncated CIR is a natural +extension of the same plumbing." + +What is missing is architectural, not protocol scaffolding: normalized CSI is +still treated as *the* WiFi input. The standardized sensing measurements +(TB/non-TB soundings, truncated CIR / PDP reports) are modeled as protocol +messages but are **not yet first-class native measurement types** that flow +through calibration (ADR-301), fusion (ADR-311), and the ontology (ADR-306) on +equal footing with normalized CSI. + +## Options considered + +1. **Keep 802.11bf as a protocol model only; always down-convert its reports to + normalized CSI at ingest.** Rejected: truncated CIR/PDP carry range-resolved + multipath structure that flattening to a CSI matrix discards; it also wastes + the standard's native report semantics. +2. **Fork a parallel "bf pipeline" alongside the CSI pipeline.** Rejected: + duplicates calibration, fusion, ontology, and evidence plumbing, and re-opens + the O(surfaces²) translation problem ADR-306 exists to close. +3. **Promote standardized sensing measurements to native measurement types + inside the existing pipeline**, with normalized CSI as one measurement type + among several. Chosen. + +## Decision + +Adopt an **802.11bf-native architecture**: standardized WLAN sensing +measurements become **additional native measurement types**, alongside — not +replacing — normalized CSI. + +### 1. Native measurement types + +- Define the standardized reports the `ieee80211bf` module already models + (TB and non-TB soundings; truncated CIR; PDP) as first-class + `MeasurementType` variants that the pipeline carries end-to-end, each tagged + with its `SpecProfile` and band. Normalized CSI remains one such type; the + `OpportunisticCsiBridge` remains the path for silicon that only offers + incidental CSI. +- Truncated CIR/PDP reuse the **ADR-292** subcarrier-agnostic / native- + dimensionality plumbing (truncated CIR is the stated natural extension); the + native→pipeline mapping is recorded in frame metadata so downstream stages + know the true range/spectral resolution of a bf report vs. an interpolated CSI + frame. + +### 2. Ontology and governance binding + +- Each standardized measurement becomes an ADR-306 `Observation` node from an + ADR-305-authenticated `Sensor`, carrying `SemanticProvenance` and exactly one + `EvidenceLevel` (L0–L5, ADR-282). The `ieee80211bf` `ConsentMode` metadata — + required on every setup — composes with the ADR-277 policy engine, so a + standardized session is admitted under the same governance as any other + sensing task (ADR-280). +- SBP (sensing-by-proxy) sessions attribute the report to the proxying and the + sensing entities distinctly, so provenance is not laundered through the proxy. + +### 3. HAL feed (ADR-320) + +- The capability set a device advertises — which `MeasurementType`s, bands, + bandwidths, roles, and `SpecProfile` it supports — is exactly the descriptor + **ADR-320** (HAL) needs to "identify the hardware." ADR-310 defines that + capability descriptor as the projection of `SensingCapabilities`; ADR-320 + consumes it. A device that implements no bf profile advertises only the + opportunistic-CSI capability. + +## Consequences + +- RuView is positioned as the open reference stack *around* the standard: when a + chipset exposes 802.11bf, its native reports flow through calibration, fusion, + ontology, and evidence with no bespoke pipeline — the plumbing is already + tested against `SimTransport` and synthetic fixtures. +- Normalized CSI is demoted from "the WiFi input" to "one measurement type," + which is the correct framing for a multi-measurement future and prevents the + bf path from being a second-class citizen. +- **No hardware claim is made or implied.** No commodity silicon implements + 802.11bf yet; this ADR wires the *types and flow*, tested in simulation. Any + OTA/native-report accuracy claim requires real silicon evidence (a captured + log) per CLAUDE.md, and any wideband number must be tagged with the capture + hardware (ADR-292). No benchmark number is invented here. +- This ADR does not re-open ADR-152/153's decision to avoid OTA frame binding + until silicon exists; it consumes that surface and adds the pipeline + integration. + +## Validation + +- `cargo test -p wifi-densepose-hardware` — existing `ieee80211bf` FSM, + table, and transport tests continue to pass; new tests assert that a + `SensingMeasurementReport` (TB and non-TB) and a truncated-CIR/PDP report + round-trip through the pipeline as native `MeasurementType`s. +- `cargo test -p wifi-densepose-mat` — truncated CIR ingest reuses the ADR-292 + subcarrier-agnostic path and records the native→pipeline mapping; dimension/ + version validation on standardized reports mirrors the FeitCSI parser gates. +- Ontology/governance tests: each standardized measurement becomes an ADR-306 + `Observation` from an ADR-305-authenticated `Sensor` with one `EvidenceLevel`; + `ConsentMode` composes with ADR-277 admission; SBP attributes proxy vs. sensor + provenance distinctly. +- HAL contract test: the ADR-320 capability descriptor is derivable from + `SensingCapabilities`; a bf-less device advertises only opportunistic CSI. +- All measurement-type flows are simulation-tested (`SimTransport`, synthetic + fixtures); OTA binding and any hardware accuracy claim remain out of scope + until real silicon exposes the standard. diff --git a/docs/adr/ADR-311-real-sensor-fusion.md b/docs/adr/ADR-311-real-sensor-fusion.md new file mode 100644 index 00000000..a91de3f7 --- /dev/null +++ b/docs/adr/ADR-311-real-sensor-fusion.md @@ -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. diff --git a/docs/adr/ADR-312-long-term-spatial-memory.md b/docs/adr/ADR-312-long-term-spatial-memory.md new file mode 100644 index 00000000..4e8c15bd --- /dev/null +++ b/docs/adr/ADR-312-long-term-spatial-memory.md @@ -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. diff --git a/docs/adr/ADR-313-counterfactual-inference.md b/docs/adr/ADR-313-counterfactual-inference.md new file mode 100644 index 00000000..31ee46c4 --- /dev/null +++ b/docs/adr/ADR-313-counterfactual-inference.md @@ -0,0 +1,142 @@ +# ADR-313: Counterfactual inference — generative spatial reasoning + +- **Status**: Accepted — initial implementation (ADR-300 phase 3) +- **Date**: 2026-08-11 +- **Deciders**: ruv +- **Tags**: inference, generative, counterfactual, rf-twin, fusion, uncertainty, phase-3 + +## Context + +This ADR is a child of **ADR-300** (perception substrate program) and owns +primitive #13, *counterfactual inference*. In the ADR-300 DAG it is a phase-3, +research-forward primitive that sits on top of the fused world state: it +**consumes ADR-311** (real sensor fusion) for the current fused estimate and +**ADR-315** (digital RF twin) for the twin's expected measurement +distributions. It is design intent, authored as Proposed, and is expected to be +revised as the phase-1 spine and the phase-2 fusion layer land. + +RuView today reasons discriminatively: a task head maps measurements to a label +or a pose. That answers "what does the classifier say?" but not the questions an +operator actually asks — *would these RF measurements still make sense if nobody +were present? Does one person explain the observation better than two?* Those +are counterfactual questions, and a classifier cannot answer them because it has +no model of what a measurement *should* look like under a hypothesized world +state. A discriminative head asked about an empty room simply emits its +best-effort label; it cannot say "the observation is better explained by +absence." + +The step this ADR proposes is toward a **generative spatial model**: given a +hypothesized scene state (occupancy, count, coarse positions) and the ADR-315 +twin's propagation model for the deployment, predict the *expected* measurement +distribution, then score how well each hypothesis explains the observed +measurement. The best-explaining hypothesis — including the *nobody-present* +null hypothesis — is the answer, and the margin between hypotheses is a +first-class uncertainty signal. + +Relevant existing assets to build on rather than duplicate: + +- **ADR-311** (fusion) already produces the fused world estimate and its + covariance; the counterfactual layer scores hypotheses *relative to* that + estimate rather than re-fusing raw measurements. +- **ADR-315** (RF twin) is the generative forward model — per-deployment + geometry, radio locations, and expected measurement distributions. This ADR + is a *consumer* of the twin's forward simulator, not a second simulator. +- **ADR-302** (OOD/observability) already owns the `UNKNOWN` verdict; the + null-hypothesis ("nobody present better explains this than any occupancy + hypothesis") and the "no hypothesis explains this" case route through ADR-302, + not a parallel gate. +- `frame::EvidenceLevel` L0–L5 (ADR-282) and the ADR-304 evidence engine + account for the resulting confidence. + +## Options considered + +1. **Keep only discriminative heads.** Rejected: cannot express absence, + cannot compare "one person vs. two" as competing explanations, and gives a + confident label even when no world state explains the data. +2. **A second, independently trained generative network with its own forward + model.** Rejected for the default path: duplicates the ADR-315 twin's + propagation model, invites the two models to disagree, and multiplies the + surface that must be validated. Reserved only if the twin's analytic forward + model proves insufficient for a phenomenon. +3. **A hypothesis-scoring layer that uses the ADR-315 twin as the forward model + and the ADR-311 fused state as the hypothesis prior, routing low-margin and + null-dominant cases to the ADR-302 UNKNOWN verdict.** Chosen. + +## Decision + +Define a **counterfactual inference layer** that scores a small set of scene +hypotheses against observed measurements using the digital RF twin as the +generative forward model. + +### 1. Hypothesis set + +- Hypotheses are drawn from the ADR-311 fused state and its neighbourhood: the + current estimate, the **null hypothesis** (nobody present), and a bounded set + of nearby alternatives (±1 occupant, shifted position). The fused estimate + supplies the prior so the search stays small and grounded rather than + enumerating an open world. +- The hypothesis space is expressed over the **ADR-306** canonical ontology + (`Space`/`Zone`, occupant count, coarse position), so a counterfactual result + is a governed spatial statement, not an opaque score. + +### 2. Forward model and scoring + +- For each hypothesis, query the **ADR-315 twin** for the expected measurement + distribution given that scene state and the deployment's propagation model. + Score the observed measurement's likelihood under each hypothesis's expected + distribution. +- The answer is the maximum-likelihood hypothesis; the **margin** between the + top hypotheses (and between the top hypothesis and the null) is the + confidence signal, carried into the ADR-304 evidence engine. + +### 3. Routing to UNKNOWN + +- When the null hypothesis dominates, the layer reports *absence*, not a + low-confidence occupancy label. +- When **no** hypothesis explains the observation well (all likelihoods low, or + the winning margin below threshold), the result routes to the **ADR-302** + `UNKNOWN` verdict — the observation is outside what the twin can explain, and + the honest output is "I cannot account for this," never a forced label. + +### Evidence discipline + +- Twin-predicted distributions are a **simulation** (evidence level L0 per + ADR-282) labelled `SYNTHETIC`; a counterfactual verdict inherits the evidence + level of its weakest input and is never presented as camera-grade ground + truth (CLAUDE.md honesty rule). +- Any accuracy statement about counterfactual discrimination (e.g. "distinguishes + one occupant from two") requires the mean-pose-style baseline discipline of + CLAUDE.md, a leakage-free held-out split, and a reproducer before it may be + tagged `MEASURED`. This ADR asserts **no** such number. + +## Consequences + +- RuView gains the ability to answer absence and "which explanation is better" + questions that discriminative heads structurally cannot — a step toward + generative spatial reasoning and a differentiator for security and + facility-monitoring applications where *absence* is the valuable signal. +- Quality is bounded by the fidelity of the ADR-315 twin's forward model and the + ADR-311 fused prior; the layer reports margins and defers to ADR-302 UNKNOWN + rather than overstating a coarse model. +- Hard dependency on ADR-311 (fused state and covariance) and ADR-315 (forward + model); this ADR builds neither a fusion engine nor a propagation simulator of + its own. +- Being phase 3, this is design intent sitting on the fused world state; it is + expected to be revised as ADR-311 and ADR-315 land, and it is not implemented + by the phase-1 swarm. + +## Validation + +- Unit tests: hypothesis likelihood scoring is a deterministic function of + observed measurement + hypothesis + twin parameters; the null hypothesis wins + on a synthesized empty-room measurement; a two-occupant measurement scores the + two-occupant hypothesis above the one-occupant hypothesis on a controlled + synthetic case. +- Integration test: measurements the twin cannot explain (out-of-model + scattering) drive the layer to the ADR-302 UNKNOWN verdict rather than a + forced occupancy label; margins propagate into the ADR-304 evidence engine. +- Held-out discrimination (deferred, real-silicon): one-vs-two and + presence-vs-absence discrimination on a leakage-free held-out split with a + mean-pose baseline, reported as `MEASURED` with a reproducer. Until then all + counterfactual output is `SYNTHETIC`/L0. No discrimination accuracy number is + asserted by this ADR. diff --git a/docs/adr/ADR-314-information-gain-scheduler.md b/docs/adr/ADR-314-information-gain-scheduler.md new file mode 100644 index 00000000..92c8b312 --- /dev/null +++ b/docs/adr/ADR-314-information-gain-scheduler.md @@ -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. diff --git a/docs/adr/ADR-315-digital-rf-twin.md b/docs/adr/ADR-315-digital-rf-twin.md new file mode 100644 index 00000000..26da1d4c --- /dev/null +++ b/docs/adr/ADR-315-digital-rf-twin.md @@ -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. diff --git a/docs/adr/ADR-316-fleet-control-plane.md b/docs/adr/ADR-316-fleet-control-plane.md new file mode 100644 index 00000000..32ebb93b --- /dev/null +++ b/docs/adr/ADR-316-fleet-control-plane.md @@ -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. diff --git a/docs/adr/ADR-317-benchmark-multi-domain-scorecard.md b/docs/adr/ADR-317-benchmark-multi-domain-scorecard.md new file mode 100644 index 00000000..ab7e64a6 --- /dev/null +++ b/docs/adr/ADR-317-benchmark-multi-domain-scorecard.md @@ -0,0 +1,140 @@ +# ADR-317: Multi-domain benchmark scorecard — regressions cannot hide behind pooled accuracy + +- **Status**: Accepted — initial implementation planned (ADR-300 phase 1) +- **Date**: 2026-08-11 +- **Deciders**: ruv +- **Tags**: benchmark, aetherarena, ci-gate, evidence, honesty, domain-generalization, substrate + +## Context + +This ADR is primitive 17 of the perception-substrate program (ADR-300) and the +per-PR enforcement edge of the phase-1 certificate spine. In the ADR-300 +dependency DAG it reads accuracy from the evidence engine (ADR-304), consumes +the domain state produced by out-of-distribution detection (ADR-302), scores +against calibration certificates (ADR-301), and is anchored in the witness chain +(ADR-319). It is the surface that makes the rest of the spine testable on every +change to sensing code. + +A single pooled accuracy number is the classic way a domain-generalization +regression hides. A model can raise mean PCK or mean presence accuracy while +quietly collapsing on unseen rooms, unseen devices, or stationary subjects — +exactly the conditions WiFi sensing fails in and exactly the conditions a +pooled average washes out. The strategic assessment (ADR-300) named this: what +distinguishes infrastructure from a demo is that a regression on *any* operating +domain is caught before merge, not discovered in the field. + +RuView does not need a new benchmark to do this. AetherArena is already +**v0-complete infrastructure** (ADR-149): a deterministic scoring engine +reusing `wifi-densepose-train` (`src/ruview_metrics.rs`, `src/ablation.rs`, +`src/eval.rs`, `src/proof.rs`), a `PROOF_SEED=42` determinism substrate that +SHA-256-hashes outputs against an expected hash, an append-only witness ledger, +and a live Hugging Face Space. ADR-145's ablation harness already computes +presence accuracy, localization error, FP/FN, latency percentiles, a +privacy-leakage score, and **cross-room degradation**. The board is +intentionally empty (benchmark-first). What is missing is not a scorer but a +**scorecard format** that reports per-domain rather than pooled, and a +**sensing-crate CI gate** that runs it on every PR. + +## Options considered + +1. **Keep the single pooled score / `RuViewTier`.** Rejected: it is exactly the + surface a per-domain regression hides behind; a Gold tier can coexist with a + broken unseen-room slice. +2. **Add a new benchmark repo/harness for domains.** Rejected: AetherArena's + scorer, determinism binding, and witness ledger already exist and are the + right engine; a parallel harness would fork the scoring substrate and its + anti-gaming/leakage discipline. +3. **Extend the AetherArena scorer with a per-domain scorecard and wire it as a + per-PR sensing-crate gate.** Chosen. + +## Decision + +Reuse the AetherArena scorer and witness ledger (ADR-149) and add two things: a +**multi-domain scorecard** format and a **sensing-crate PR gate** that produces +it. + +### 1. The multi-domain scorecard + +The scorecard reports each capability broken out by operating domain, never +pooled into one figure. The v0 domain axes: + +- **Presence**: `room-known`, `room-unseen`, `device-unseen`, `stationary-10m` + (a stationary subject at range — the canonical WiFi failure case). +- **Pose**: `matched`, `subject-unseen`, `room-unseen`. +- **OOD rejection**: the rate at which genuinely out-of-distribution input is + correctly returned as UNKNOWN by ADR-302 (a capability, not a failure) and + the false-UNKNOWN rate on in-distribution input. +- **Calibration drift**: fingerprint-distance trajectory against the ADR-301 + certificate over the scored window, and the fraction of inferences in each + ADR-302 `DomainState` (KNOWN / DEGRADED / UNKNOWN). + +Each cell carries exactly one `EvidenceLevel` (L0–L5, ADR-282). A slice scored +on synthetic input is L0/`Synthetic` by construction; a slice on a leakage-free +held-out real split is graded higher and only then may a per-domain number be +labelled MEASURED. Pose PCK cells additionally require the mean-pose baseline +and a leakage-free held-out split (CLAUDE.md) or they are not reported as pose +accuracy at all. + +### 2. Per-domain regression gate + +- The gate compares each scorecard cell against the merged-baseline scorecard + stored in the AetherArena witness ledger. A regression **in any single + domain** beyond its configured threshold fails the PR, even if the pooled + average improved. Improvement on `room-known` cannot buy a regression on + `room-unseen`. +- Thresholds are per-domain and per-capability; the unseen/stationary/OOD + domains carry the strictest budgets because they are the ones a pooled score + hides. The baseline is append-only and witness-anchored — a new baseline is a + new signed ledger entry, never an in-place overwrite (ADR-149 ledger pattern, + ADR-319 anchoring). + +### 3. Sensing-crate CI wiring + +- Every PR that touches a sensing crate runs the scorecard across all domains + under the ADR-011/ADR-149 determinism binding (`PROOF_SEED=42`), so the run + is reproducible and tamper-evident. The gate is added to + `.github/workflows/` as an authoritative check. +- The held-out real split remains private and is never accessible to synthetic + generation, augmentation, or calibration (ADR-149 leakage constraint, ADR-282 + rule d). Submitters/PRs provide a model, not predictions on data they hold. + +### Provenance and honesty discipline + +- No benchmark numbers are invented by this ADR. It delivers the scorecard + format, the per-domain gate, and the CI wiring; the numbers come from the + ADR-304 evidence ledger and the AetherArena scorer on real data, labelled at + the honest evidence level. Empty domains report "no evidence," which the gate + treats as no coverage — never as a pass. + +## Consequences + +- A domain-generalization regression can no longer merge behind a flattering + pooled average; the failure mode that most distinguishes fielded sensing from + a demo is caught at PR time. +- Every PR touching sensing pays a per-domain scoring cost. Bounded by reusing + the existing deterministic scorer and by tiered compute (CPU smoke vs full + score, ADR-149), but it is a deliberate cost for per-domain safety. +- The empty AetherArena board fills with honest, per-domain, evidence-labelled + results rather than a single headline tier — consistent with the + benchmark-first posture and with ADR-282's ecosystem positioning. +- Some domains will show weak or absent coverage. Surfacing that per-domain is + the point; the scorecard must never paper over a thin domain with a pooled + number. +- The program-level acceptance test (ADR-300) is encoded here as an AetherArena + scenario, closing the loop once the phase-1 spine lands. + +## Validation + +- `cargo test` on the AetherArena scorer extension — per-domain slicing math + against fixtures; per-domain regression gate fails on a single-domain + regression while pooled improves, and passes when all domains hold; empty + domains report "no evidence," not a pass; every cell carries exactly one + `EvidenceLevel`; synthetic slices are L0 by construction. +- Determinism: a scored run reproduces its SHA-256 hash under `PROOF_SEED=42` + (ADR-011/ADR-149 binding); the baseline scorecard is append-only and + witness-anchored (ADR-319), never mutated in place. +- CI: the sensing-crate gate runs on a PR touching a sensing crate and blocks a + planted single-domain regression. +- Real-data scorecards (a leakage-free held-out split with ADR-303 references) + are the maturity milestone; a synthetic scorecard is L0 and no per-domain + number is MEASURED without a reproducer per CLAUDE.md. diff --git a/docs/adr/ADR-318-capability-certificates.md b/docs/adr/ADR-318-capability-certificates.md new file mode 100644 index 00000000..62783709 --- /dev/null +++ b/docs/adr/ADR-318-capability-certificates.md @@ -0,0 +1,138 @@ +# ADR-318: Capability certificates — validated-for-this-environment claims + +- **Status**: Accepted — initial implementation planned (ADR-300 phase 1) +- **Date**: 2026-08-11 +- **Deciders**: ruv +- **Tags**: capability, certificate, evidence, provenance, signature, honesty, substrate + +## Context + +This ADR is primitive 18 of the perception-substrate program (ADR-300) and, +per the strategic assessment, among the strongest ideas in the program: it is +where the whole certificate spine becomes a consumable contract. In the ADR-300 +dependency DAG it **consumes the evidence engine (ADR-304)** — a capability +certificate is a signed attestation minted over a slice of that ledger — the +**calibration certificate (ADR-301)** for the environment it is validated +against, and the **RuField signature types (ADR-305 / ADR-260/262/277/279)** to +sign it. It reports domain state via ADR-302 and is anchored in the witness +chain (ADR-319). + +RuView must stop making unconditional capability claims. "Supports presence" is +not a true statement — presence detection works in some rooms, on some hardware, +for some subject dynamics, and fails on a stationary subject at range in an +uncalibrated room. A capability is only ever *validated for a specific +environment*, and the honest unit of that claim is a signed, expiring +certificate, not a feature flag in a README. + +The ingredients now exist across the phase-1 spine: ADR-304 accumulates +per-`(room, device, subject)` accuracy, false-positive rate, drift, and domain +state; ADR-301 produces the signed room fingerprint the environment is keyed to; +ADR-305 provides the authenticated device identity and `CapabilityAttestation` +(BFLD, ADR-141) that bounds *what a device is even attested to sense*; ADR-282 +provides the mandatory `EvidenceLevel`. What is missing is the artifact that +binds them into a single, verifiable "validated here, until then" claim and the +consumer-side rule that refuses capabilities lacking one. + +## Options considered + +1. **Static capability flags / a `supports_presence` boolean.** Rejected: it is + the exact dishonest claim — environment-independent, unsigned, non-expiring, + and false the moment the room, device, or subject dynamics differ. +2. **Report raw ledger accuracy to consumers directly.** Rejected: the ledger + (ADR-304) is the source of truth but not a portable, signed, bounded contract; + handing consumers raw records pushes evidence-weighting and expiry logic into + every consumer and drops the single verifiable object. +3. **Mint a signed, expiring `CapabilityCertificate` over an ADR-304 ledger + slice, and make consumers refuse capabilities without a valid one.** Chosen. + +## Decision + +Introduce a signed **`CapabilityCertificate`**: a bounded attestation that a +specific capability has been validated for a specific environment, for a bounded +time. + +### 1. The certificate + +A serializable `CapabilityCertificate` binding: + +- `capability` — the phenomenon (e.g. `presence`, `pose`), which must be within + the device's ADR-305/ADR-141 `CapabilityAttestation` (a device cannot be + certified for something it is not even attested to sense). +- `room` — the ADR-306 space identifier, tied to the ADR-301 calibration + certificate version the validation was performed against. +- `hardware` — the ADR-305 authenticated `DeviceId` (and, in phase 2, the + ADR-320 HAL descriptor of the sensor). +- `model` — the model version scored. +- `calibrated_date` — the calibration certificate age at validation time. +- `moving_recall`, `stationary_recall`, `false_presence_per_24h` — the measured + operating metrics, sliced from the ADR-304 ledger for this exact context (not + a global average), each honestly labelled. These are per-capability; a pose + certificate carries pose metrics with the mean-pose baseline and a + leakage-free split (CLAUDE.md) or it is not issued. +- `valid_until` — an explicit expiry; a certificate is never open-ended. +- `evidence_level` — exactly one L0–L5 (ADR-282). A certificate minted from a + synthetic ledger slice is L0/`Synthetic`; a MEASURED metric requires an + ADR-303 reference and a reproducer. The certificate cannot upgrade the level + of the ledger it is minted from (ADR-304 honesty rule). +- `signature` — a RuField `SignatureBlock` (ADR-305 / ADR-260/262/277/279) over + the canonical serialization; an unsigned certificate is not a valid + certificate. The certificate is anchored in the witness chain (ADR-319). + +### 2. Minting + +- A certificate is minted from a slice of the ADR-304 evidence ledger for one + `(room, device, subject-class, model)` context. If the ledger reports "no + evidence" for that context, **no certificate is issued** — absence of evidence + is never a capability. Minting is a pure function over the append-only ledger + at mint time; the metrics are frozen into the signed object. +- Expiry (`valid_until`) is derived from calibration validity (ADR-301) and an + evidence-freshness policy: a certificate cannot outlive the calibration it was + validated against, and drift beyond the ADR-301 envelope invalidates both. + +### 3. Consumer refusal rule + +- Applications and surfaces **refuse to consume a capability that lacks a valid + certificate for the current environment**. "Valid" means: signature verifies, + `room`/`hardware`/`model` match the running context, `valid_until` is in the + future, and the referenced calibration certificate is itself still valid + (ADR-301 not invalidated). A failed check yields UNKNOWN via ADR-302, not a + best-effort guess. +- This makes the ADR-300 acceptance clause "quantify whether it can reliably + sense the requested phenomenon → generate a signed capability certificate" + a hard gate rather than a hope. + +## Consequences + +- RuView can no longer claim a capability it has not validated for the caller's + environment; the honest failure — "not certified here" → UNKNOWN — is + surfaced by construction rather than by discipline. +- OEM/integrator diligence gets a single verifiable artifact ("presence, + validated in *this* room, on *this* device, with *these* recall/false-alarm + numbers, until *this* date, at *this* evidence level, signed") — the strongest + commercial output of the spine. +- Certificates expire and get refused; some environments will have no + certificate and therefore no capability until validated. That refusal is the + intended honest behavior, not a regression. +- Key management and expiry policy are operational responsibilities, reusing the + ADR-305 enrollment/rotation and ADR-301 validity machinery rather than new + infrastructure; fleet distribution of certificates is owned by ADR-316. +- No capability number is invented here; every metric on a certificate is sliced + from the ADR-304 ledger at its honest evidence level. + +## Validation + +- `cargo test` on the certificate crate — mint from a ledger slice produces the + frozen metrics; "no evidence" context yields no certificate; signature + round-trip and tamper rejection; `valid_until` and calibration-linked expiry + enforced; consumer refusal on room/hardware/model mismatch, expiry, or + invalidated calibration resolves to UNKNOWN (ADR-302), not a guess; evidence + level is inherited from the ledger and cannot be upgraded; a certificate + cannot be issued for a capability outside the device's ADR-305/ADR-141 + attestation. +- Cross-ADR: an ADR-304 ledger fixture mints a certificate; an ADR-302 test + asserts an expired/mismatched certificate gates to UNKNOWN; the ADR-300 + acceptance test consumes a minted certificate end-to-end. +- Real-deployment certificates (minted from a populated ledger with ADR-303 + references on live ESP32 captures) are the maturity milestone and require + hardware evidence per CLAUDE.md; a certificate minted from a synthetic ledger + is L0 by construction. diff --git a/docs/adr/ADR-319-witness-chain.md b/docs/adr/ADR-319-witness-chain.md new file mode 100644 index 00000000..cff5bbc3 --- /dev/null +++ b/docs/adr/ADR-319-witness-chain.md @@ -0,0 +1,146 @@ +# ADR-319: Witness chain — epistemic infrastructure for physical AI + +- **Status**: Accepted — initial implementation planned (ADR-300 phase 1) +- **Date**: 2026-08-11 +- **Deciders**: ruv +- **Tags**: provenance, witness, evidence, signature, epistemics, ontology, substrate + +## Context + +This ADR is primitive 19 of the perception-substrate program (ADR-300) and a +spine root of its phase-1 certificate stack. In the ADR-300 dependency DAG it +**extends the source-provenance state machine (ADR-295)** and the RuField +provenance types, **ties to the signature machinery (ADR-305 / +ADR-260/262/277/279)**, and anchors the artifacts produced by ADR-301 +(calibration certificates), ADR-304 (evidence records), ADR-317 (benchmark +scorecards), and ADR-318 (capability certificates). In phase 2 it carries the +independent-corroboration link from ADR-303. + +The strategic assessment (ADR-300) framed RuView's real product as **epistemic +infrastructure for physical AI**: the value is not the claim "a person is +present" but the *auditable reasoning* behind it. A bare boolean output discards +everything a downstream system needs to trust or contest it — which radio +observed it, what DSP evidence supported it, which model inferred it, whether an +independent sensor agreed, what spatial state it updated, and what policy acted +on it. Once the answer is a boolean, "why do you believe that?" has no answer. + +RuView already has the pieces of a chain but not the chain itself. ADR-295 +defines a canonical `SourceState` (`Synthetic` / `LiveVerified` / +`LiveUnverified` / `Stale` / `Disconnected`) with `Unknown` structurally +forbidden from collapsing to live. ADR-305 defines the signed +`device → measurement → sequence → timestamp → … → signed event` chain of +custody. RuField carries `FrameProvenance`, `SemanticProvenance`, and signature +types; the AetherArena witness ledger (ADR-149) demonstrates an append-only, +witness-anchored ledger. What is missing is a single **staged, signed envelope** +that travels the whole pipeline and records, at each stage, the confidence and +provenance of that stage. + +## Options considered + +1. **Keep provenance as scattered per-stage fields (status quo).** Rejected: + `FrameProvenance`, `SourceState`, calibration state, and model uncertainty + live in different structures and are re-encoded per surface; there is no + single object a consumer can re-verify offline to answer "why." +2. **Log a free-form audit trail alongside the output.** Rejected: mutable, + unsigned, and not structurally tied to the output — the classic + dashboard-that-overwrites-yesterday failure the evidence engine (ADR-304) + already rejects. +3. **A staged, signed witness envelope carried through the pipeline, each stage + appended and signed, anchored in an append-only ledger.** Chosen. + +## Decision + +Define the **witness chain**: a staged, append-only, signed envelope that +accompanies an observation from radio to policy decision. Instead of emitting +"person present," RuView emits a chain whose stages are: + +``` +RF observation ▸ DSP evidence ▸ model inference ▸ independent corroboration + ▸ spatial state ▸ policy decision +``` + +### 1. The staged envelope + +- Each stage is a signed record carrying its **confidence** and its + **provenance**: + - **RF observation** — the ADR-305 authenticated frame envelope + (`DeviceId`, sequence, timestamp, measurement hash) and its ADR-295 + `SourceState`. This is the root link; a `Synthetic` root can never present + as a `LiveVerified` one (ADR-295 invariant). + - **DSP evidence** — the deterministic signal features and the ADR-137 + quality signals that support (or fail to support) an inference. + - **model inference** — the model version, its raw output, and its predictive + uncertainty; the ADR-302 `DomainState` (KNOWN / DEGRADED / UNKNOWN) gate + result, so a low-confidence or out-of-distribution inference is recorded as + such, not silently promoted. + - **independent corroboration** — the phase-2 ADR-303 agreement link + (a reference/second modality that agreed or disagreed); absent in phase 1, + the stage records "no corroboration," never a fabricated one. + - **spatial state** — the ADR-306 ontology `Observation`/`Track`/`Event` the + inference updated, carrying `SemanticProvenance` and its `EvidenceLevel`. + - **policy decision** — the governed action taken (or withheld), with the + certificate (ADR-318) it relied on. +- Each stage carries exactly one `EvidenceLevel` (L0–L5, ADR-282); the envelope's + effective level is the **minimum** across its stages — a synthetic root or an + unreferenced inference caps the whole chain, so the chain cannot claim more + than its weakest link. + +### 2. Signing and anchoring + +- Each stage is signed with RuField signature types (ADR-305 / + ADR-260/262/277/279) over the canonical serialization of that stage plus the + hash of the prior stage, so the chain is tamper-evident end to end and any + broken link is detectable. The completed chain is anchored in an append-only, + witness-anchored ledger following the AetherArena pattern (ADR-149); it is the + same anchoring ADR-301/ADR-304/ADR-317/ADR-318 write into. +- The chain is **append-only**: a correction is a new chain referencing the + prior one, never an in-place edit (mirroring ADR-304 and CLAUDE.md's "source + over summaries"). + +### 3. Offline re-verification + +- A consumer with the enrolled public keys (ADR-305) can re-verify a chain + offline: check each stage signature, check each prior-stage hash, and read the + per-stage confidence and evidence level — answering "why do you believe this?" + without trusting the emitting host. This is the property store-and-forward + channel authentication (rejected in ADR-305) cannot provide. + +### Provenance and honesty discipline + +- The witness chain never manufactures confidence: a stage that lacks evidence + records the absence. A `Synthetic` root, a missing corroboration, or an + UNKNOWN gate is carried faithfully and caps the chain's evidence level. No + accuracy number is invented here; the chain records the numbers the other + primitives produce at their honest level. + +## Consequences + +- Every RuView output becomes contestable and auditable: a downstream physical-AI + system can inspect the reasoning, weight it by per-stage confidence, and reject + a chain whose weakest link is too weak — the defining property of epistemic + infrastructure the strategic assessment asked for. +- The certificate spine (ADR-301/301/314/315) gains a single anchoring substrate; + each of those artifacts is a specialization of a witness record rather than a + bespoke signed blob. +- Carrying and signing a staged envelope adds per-observation size and CPU cost; + bounded by reusing RuField signatures and the existing ledger, and by the + minimum-level rule keeping the object honest rather than exhaustive. +- The chain will frequently reveal weak links (synthetic root, no corroboration, + DEGRADED gate). Surfacing that is the point; the envelope must never smooth a + weak stage into a confident summary. + +## Validation + +- `cargo test` on the witness-chain crate — stage-by-stage signature round-trip + and tamper rejection (a mutated stage or a broken prior-stage hash fails + verification); effective evidence level equals the minimum across stages; a + `Synthetic` root caps the chain and cannot present as `LiveVerified` + (ADR-295 invariant); an UNKNOWN gate (ADR-302) and a "no corroboration" stage + are recorded faithfully; append-only correction produces a new chain + referencing the prior one. +- Cross-ADR: an ADR-305 signed frame lineage serializes into a chain that + re-verifies offline with only the enrolled public keys; ADR-301/301/314/315 + artifacts anchor into the same ledger. +- Real-deployment chains (from live ESP32 captures with ADR-303 corroboration) + are the maturity milestone and require hardware evidence per CLAUDE.md; a + chain rooted in synthetic input is L0 by construction. diff --git a/docs/adr/ADR-320-sensor-hal.md b/docs/adr/ADR-320-sensor-hal.md new file mode 100644 index 00000000..ae3ea153 --- /dev/null +++ b/docs/adr/ADR-320-sensor-hal.md @@ -0,0 +1,150 @@ +# ADR-320: RuView sensor HAL — abstract all sensing hardware to one Observation type + +- **Status**: Accepted — initial implementation (ADR-300 phase 2) +- **Date**: 2026-08-11 +- **Deciders**: ruv +- **Tags**: hal, sensor-abstraction, ontology, fusion, adapters, category, phase-2 + +## Context + +This ADR is primitive 20 of the perception-substrate program (ADR-300) and a +phase-2 integration primitive; it is authored as **Proposed**. In the ADR-300 +DAG it **consumes the canonical spatial ontology (ADR-306)** — its output is an +ontology `Observation` bound to a `Sensor` entity — and **feeds real sensor +fusion (ADR-311)**, which resolves many observations into one world state. It +closes the "identify the hardware" clause of the ADR-300 acceptance test that +phase 1 leaves open. + +RuView's strategic ceiling is set by how tightly it is coupled to WiFi CSI. +Every new modality today lands as a bespoke ingest path with its own frame +shape, its own provenance handling, and its own place in the pipeline. That is +the difference between "a WiFi-DensePose project" and "an open +spatial-intelligence operating layer": the category changes the moment *any* +sensing hardware — {CSI, 802.11bf, BLE, UWB, mmWave, acoustic, camera, lidar, +IMU, custom} — enters through one abstraction and becomes one `Observation` +feeding one world model. + +Crucially this is a *unification*, not a green field. Adapters already exist and +must be reused, not rebuilt: + +- ADR-279's native RF frame contract (`RfFrameV2`) already unifies ESP32, + Intel, Atheros, PicoScenes, Realtek radar, and 320 MHz 802.11bk producers as + `RfFrameV2` producers into a shared latent — "lightweight per-device adapters + into a shared latent, not a shared tensor." The HAL generalizes that lesson + beyond RF. +- Existing CSI adapters (ESP32/Nexmon/FeitCSI paths), the mmWave fusion path + (ADR-063), and the multistatic WiFi path (ADR-029) are concrete producers to + bring under one trait. +- ADR-305 already authenticates a `Sensor`/`DeviceId`; ADR-306 already defines + `Sensor`, `Observation`, `Track`, and `Event` as first-class node types. The + HAL is the trait that turns a heterogeneous device into that authenticated + `Sensor` emitting those `Observation`s. + +The gap is a single **`SensorHal` trait and one `Observation` type** that every +modality implements, so the world model never sees a modality-specific frame — +only a provenance-bearing, evidence-labelled `Observation`. + +## Options considered + +1. **Continue adding per-modality ingest paths.** Rejected: O(modalities) bespoke + pipelines, each re-encoding provenance and evidence, each a place the ladder + can be dropped — and it keeps RuView categorically a WiFi project. +2. **Force every modality into the ADR-274/279 RF tensor/frame.** Rejected: the + ADR-279 lesson is precisely that premature canonicalization discards + information (bandwidth, antenna structure, phase). A camera, lidar, or IMU + has no meaningful `RfFrameV2` projection; forcing one is the same mistake at a + larger scale. +3. **Define a `SensorHal` trait producing one `Observation` type, with existing + adapters as implementations feeding a shared latent and the ADR-306 + ontology.** Chosen. + +## Decision + +Introduce a **`SensorHal` trait** and a single **`Observation`** type. Every +sensing modality is an implementation of the trait; the world model consumes +only `Observation`s. + +### 1. The `SensorHal` trait + +- A `SensorHal` describes a device's **capabilities** (which phenomena it can + sense — reusing the ADR-305/ADR-141 `CapabilityAttestation`), its **native + frame** (kept native, not canonicalized, per the ADR-279 shared-latent + lesson), and a method that lifts a native frame into an `Observation`. +- Implementations wrap the existing producers: CSI (ESP32/Nexmon/FeitCSI via the + ADR-279 `RfFrameV2` path), 802.11bf (ADR-310, phase 2), BLE, UWB, mmWave + (ADR-063), acoustic, camera, lidar, IMU, and `custom`. RF modalities reuse the + ADR-279 per-device latent adapters wholesale; the HAL adds the non-RF and + ranging modalities under the same trait. +- The trait is the boundary where untrusted hardware input is validated + (CLAUDE.md: validate at every hardware/FFI boundary; default to least + authority). A device is authenticated as an ADR-305 `Sensor` before its + observations are trusted. + +### 2. The `Observation` type + +- One provenance-bearing `Observation`: a measurement plus its `SensorHal` + source descriptor, its ADR-305 authenticated `DeviceId`, its ADR-295 + `SourceState`, its native-frame reference (not a lossy projection), and + exactly one `EvidenceLevel` (L0–L5, ADR-282). A camera-derived `Observation` + and a CSI-derived `Observation` are the same type with different provenance — + and a camera observation never lifts WiFi output to camera-grade; each carries + its own honest evidence level (CLAUDE.md: never present WiFi sensing as + camera-grade). +- The `Observation` maps directly onto the ADR-306 ontology `Observation` node + attached to its `Sensor`, so the ontology is the one representation and the + HAL is its ingest funnel. + +### 3. Feeding fusion + +- Observations from any set of modalities flow into ADR-311 fusion, which + resolves them into one probabilistic world state. The HAL guarantees fusion + never sees a modality-specific frame — only `Observation`s with uniform + provenance and evidence — which is what makes ADR-311's "many observations → + one world state" invariant implementable across heterogeneous hardware. + +### Category and honesty discipline + +- This ADR changes RuView's category from a WiFi-DensePose pipeline to an open + spatial-intelligence operating layer, but it makes **no accuracy claim**: the + HAL delivers a uniform ingest boundary, not a detector. Any capability of a + newly-connected sensor is still gated by ADR-302 and certified by ADR-318 for + its specific environment — connecting a camera does not grant a validated + capability by itself. +- Hardware support for a given modality is CLAIMED until demonstrated on real + silicon with captured evidence per CLAUDE.md; a passing trait test proves the + abstraction, not a fielded device. + +## Consequences + +- New sensing hardware lands as one `SensorHal` implementation instead of a + bespoke pipeline; the translation matrix stays O(modalities), mirroring how + ADR-306 collapsed the surface matrix. +- The ADR-300 acceptance clause "identify the hardware" becomes implementable: + a new sensor type is described by its HAL, authenticated as an ADR-305 + `Sensor`, calibrated (ADR-301), gated (ADR-302), and certified (ADR-318) + through the same phase-1 spine, closing the last open clause. +- A trait boundary and an `Observation` type are added; existing RF adapters + are re-expressed as implementations rather than rewritten, preserving the + ADR-279 native-frame/shared-latent design. +- Non-RF modalities (camera, lidar, acoustic) enter the governed plane with the + same provenance and privacy discipline as RF; a camera is not a privacy-free + shortcut — it inherits the ADR-277 governance and its own evidence level. +- As a phase-2 Proposed ADR, the trait shape may be revised as ADR-311 fusion + and ADR-310 802.11bf land; that revision is expected for a phased program. + +## Validation + +- `cargo test` on the HAL crate (design-time, Proposed) — a fixture `SensorHal` + for each of at least two modalities (CSI via ADR-279, plus one non-RF) + produces uniform `Observation`s; every `Observation` carries a `DeviceId`, + `SourceState`, native-frame reference, and exactly one `EvidenceLevel`; a + synthetic source yields L0/`Synthetic` and cannot alias to measured + (ADR-279 invariant 6); an unauthenticated device's observations are rejected + at the trait boundary (ADR-305). +- Cross-ADR: an `Observation` maps round-trip to an ADR-306 ontology + `Observation` node with no provenance loss, and a set of `Observation`s from + distinct modalities is accepted by an ADR-311 fusion fixture. +- Real-silicon evidence is required before any modality's hardware support is + claimed beyond CLAIMED: a captured boot/runtime log from the real device + emitting `Observation`s. A successful build or simulator run is not hardware + evidence (CLAUDE.md). diff --git a/docs/adr/ADR-321-decision-policy-action-authorization.md b/docs/adr/ADR-321-decision-policy-action-authorization.md new file mode 100644 index 00000000..8b707723 --- /dev/null +++ b/docs/adr/ADR-321-decision-policy-action-authorization.md @@ -0,0 +1,101 @@ +# ADR-321: Decision policy — action authorization conditioned on certificate class, freshness, uncertainty, and evidence + +- **Status**: Accepted — initial implementation planned (ADR-300 phase 1) +- **Date**: 2026-08-11 +- **Deciders**: ruv +- **Tags**: policy, authorization, safety, certificates, governed-action, phase-1 + +## Context + +The perception substrate (ADR-300) makes RuView state *what it knows* and +*how well* — the capability certificate (ADR-318) binds hardware, environment, +model, calibration, metrics, expiry, and evidence level. But a certificate is a +statement of knowledge, not a grant of action. The same certificate that is +adequate to dim a light is wholly inadequate to release a door lock or clear an +industrial stop condition. + +Without an explicit authorization layer, every consumer re-implements its own +(inconsistent, usually optimistic) rule for "is this good enough to act on," +and a confident-but-out-of-domain inference can reach an actuator. That is the +exact failure the substrate exists to prevent. Decision policy therefore +belongs in **phase 1**, alongside the certificate it gates, not later. + +This ADR realizes program invariant #1 (UNKNOWN is a first-class output, never +an error) and the action-side of the refined acceptance test: a drift- +invalidated capability must be *denied at the actuator* before a false +confident inference is acted upon. + +## Decision + +Introduce a `ruview-policy` crate providing an **action authorization gate** +that sits between governed spatial state and any actuator. + +### 1. Assurance requirements per action + +An `ActionClass` declares the assurance an action demands: + +- `min_certificate_class` — the required `CapabilityCertificate` class (ADR-318). +- `max_certificate_age` / `min_domain_freshness` — the certificate must be + currently valid **and** the live domain signature (ADR-302) must not be in a + DEGRADED/UNKNOWN state (this is the staleness guard, program invariant on + certificate conditionality — see ADR-300). +- `max_uncertainty` — inference uncertainty ceiling. +- `min_evidence_level` — the L0–L5 floor (ADR-282/ADR-304); e.g. a safety + action may require ≥ L3 (held-out room+subject validation). + +Reference action classes (illustrative, configurable): + +| Class | Example | Typical floor | +|---|---|---| +| `Convenience` | lighting, scenes | tolerant: L1+, higher uncertainty ok | +| `Security` | alerts, arming | stricter: valid cert, L2+, bounded uncertainty | +| `SafetyCritical` | door lock, machine stop | strict: fresh cert, L3+, low uncertainty, KNOWN domain only | + +### 2. The authorization decision + +`authorize(action, capability_certificate, live_state) -> Authorization` where +`live_state` carries the current `SourceState` (ADR-295), OOD/domain state +(ADR-302), and inference uncertainty. Rules: + +- **Fail-closed.** Any unmet condition → `Deny { failed_condition }`. The denial + names the *specific* condition (expired cert, domain DEGRADED, uncertainty + over ceiling, evidence below floor, certificate class too low). +- **UNKNOWN denies high-assurance actions.** A domain in UNKNOWN (ADR-302) + cannot authorize `Security`/`SafetyCritical` actions; it may still authorize + `Convenience` if that class's policy permits, but the authorization records + that it proceeded under UNKNOWN. +- The decision is a **pure function** of (action class, certificate, live + state) — deterministic and unit-testable without a clock or actuator. +- Every authorization (allow or deny) is emitted as the terminal stage of the + witness chain (ADR-319), so "why was this actuator allowed/denied" is + auditable end-to-end. + +### 3. No silent optimism + +A missing certificate, an expired certificate, or an unrecognized action class +all deny by default. Absence of a policy is not permission. + +## Consequences + +- Action authorization becomes uniform and centrally reasoned instead of + per-consumer and optimistic; this is the "RuView Certify → constrains action" + boundary that is hard to commoditize. +- A behavior change for existing automations that acted directly on presence: + they now pass through the gate. Convenience-class defaults keep low-stakes + automations working; high-stakes actions must opt into stricter classes. +- Depends on ADR-318 (certificate), ADR-302 (domain/OOD state), ADR-295 + (source state), ADR-304 (evidence). Built in the phase-1 dependent wave after + those types land. + +## Validation + +- Unit tests: each action class authorizes/denies correctly across the matrix + of (valid/expired/degraded cert × KNOWN/DEGRADED/UNKNOWN domain × uncertainty + above/below ceiling × evidence above/below floor); UNKNOWN denies + safety-critical; every deny names its failed condition; absence-of-policy + denies; determinism. +- Integration: the acceptance-test scenario (ADR-300) — post-certification room + change drives domain to DEGRADED→UNKNOWN, and a `SafetyCritical` authorization + is denied with `failed_condition = domain_not_known` *before* the inference + reaches the actuator, witness chain preserved. +- `cargo test -p ruview-policy`. diff --git a/docs/adr/README.md b/docs/adr/README.md index 284888ee..30b240f6 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -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-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-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) | --- diff --git a/docs/user-guide.md b/docs/user-guide.md index 61d27411..de364df2 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -38,6 +38,7 @@ WiFi DensePose turns commodity WiFi signals into real-time human pose estimation 14. [Training a Model](#training-a-model) - [CRV Signal-Line Protocol](#crv-signal-line-protocol) 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) - [ESP32-S3 Mesh](#esp32-s3-mesh) - [Intel 5300 / Atheros NIC](#intel-5300--atheros-nic) @@ -1493,6 +1494,78 @@ An RVF file contains: model weights, HNSW vector index, quantization codebooks, --- +## Perception Certificate Spine (Developer Preview, ADR-300) + +RuView's perception substrate program (ADR-300) is building a `signal → observation → +calibration → inference → uncertainty → evidence → certificate → policy → governed +action` pipeline, where a downstream consumer either gets a calibrated, provenance-backed +answer or an explicit `UNKNOWN` — never a confident-looking guess outside the sensor's +proven operating envelope. + +**Status: developer preview.** Phase 1 shipped nine new crates with their own test +suites, and each one works correctly in isolation. **They are not yet wired together or +into the live `sensing-server` request path** — there is currently no code path where a +real drift signal from a running sensor flows through calibration → certificate +invalidation → policy denial. Treat everything below as a library you can compose +yourself today, not a safety guarantee the server enforces for you yet. + +### The crates + +| Crate | Role | +|---|---| +| `ruview-ontology` | Canonical `Site → … → Event` types | +| `ruview-attest` | Signed measurement / RF chain-of-custody | +| `ruview-evidence` | Append-only per-context ledger (no pooling, no evidence upgrade) | +| `wifi-densepose-calibration` | Signed, drift-invalidatable calibration certificate | +| `ruview-ood` | `Known` / `Degraded` / `Unknown` staleness-guard domain gating | +| `ruview-witness` | Hash-linked staged provenance chain | +| `ruview-certify` | Capability certificate, conditional on a live domain signature | +| `ruview-scorecard` | Multi-domain scorecard, worst-domain promotion gate | +| `ruview-policy` | Fail-closed action authorization gate | + +### Minting and checking a certificate + +```rust +use ruview_certify::{mint, CapabilityCertificate, DomainState}; + +// `signer`, `request`, and `evidence_slice` come from your own calibration run — +// see each crate's README for how to build them. +let cert = mint(&signer, request, &evidence_slice)?; + +// A certificate is only valid at a given instant AND domain state — the same +// signed certificate is rejected the moment the live domain degrades: +assert!(cert.is_valid(now_ms, DomainState::Known)); +assert!(!cert.is_valid(now_ms, DomainState::Degraded)); +assert!(!cert.is_valid(now_ms, DomainState::Unknown)); +``` + +### Gating an action + +```rust +use ruview_policy::{authorize, ActionClass, DomainState}; + +let decision = authorize(ActionClass::SafetyCritical, &inputs); +// Deny with a named FailedCondition (e.g. `domain_not_known`) rather than a +// silent false-positive, whenever the domain isn't KNOWN. +``` + +**Important:** `ruview_certify::DomainState` and `ruview_policy::DomainState` (and +`ruview_ood`'s) are currently three separate enum types — `ruview-ood`'s `Degraded` +variant even carries different data. There is no automatic conversion between them. +If you compose these crates yourself today, you own writing that bridge; don't assume +one crate's domain read automatically reaches another's gate. + +### What's genuinely enforced today, for comparison + +Not every ADR-295–296 remediation item is preview-only. Two are live now: + +- **UDP data-plane bind hardening (ADR-296)** — `sensing-server`'s `UdpSourceAllowlist` + is checked on every incoming packet (`main.rs`), not just defined. +- **CSI data-incident repo controls (ADR-299)** — `scripts/csi-data-policy-check.sh` + runs in CI on every push/PR and fails the build on a policy violation. + +--- + ## Hardware Setup ### Supported targets diff --git a/scripts/csi-data-policy-check.sh b/scripts/csi-data-policy-check.sh new file mode 100755 index 00000000..168c9569 --- /dev/null +++ b/scripts/csi-data-policy-check.sh @@ -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 "$@" diff --git a/scripts/csi-data-policy.allow b/scripts/csi-data-policy.allow new file mode 100644 index 00000000..99559bbc --- /dev/null +++ b/scripts/csi-data-policy.allow @@ -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 diff --git a/ui/pose-fusion/js/csi-simulator.js b/ui/pose-fusion/js/csi-simulator.js index f234d593..57914c85 100644 --- a/ui/pose-fusion/js/csi-simulator.js +++ b/ui/pose-fusion/js/csi-simulator.js @@ -55,11 +55,15 @@ export class CsiSimulator { this.ws = new WebSocket(url); this.ws.binaryType = 'arraybuffer'; 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.onclose = () => { this.mode = 'demo'; }; + this.ws.onclose = () => { this.mode = 'demo'; this.verifiedFrame = false; this.socketOpen = false; }; // Timeout after 3s - setTimeout(() => { if (this.mode !== 'live') resolve(false); }, 3000); + setTimeout(() => { if (!this.socketOpen) resolve(false); }, 3000); } catch { resolve(false); } @@ -69,9 +73,12 @@ export class CsiSimulator { disconnect() { if (this.ws) { this.ws.close(); this.ws = null; } 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). @@ -292,6 +299,15 @@ export class CsiSimulator { this._liveAmplitude[i] = Math.sqrt(real * real + imag * imag) / 2048; 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) { @@ -311,6 +327,8 @@ export class CsiSimulator { for (let i = 0; i < n; i++) { 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) diff --git a/ui/pose-fusion/js/main.js b/ui/pose-fusion/js/main.js index 7b7eb4af..f9dface3 100644 --- a/ui/pose-fusion/js/main.js +++ b/ui/pose-fusion/js/main.js @@ -151,12 +151,21 @@ function init() { if (wsUrlInput) wsUrlInput.value = defaultWsUrl; // ADR-272: exchange the stored bearer for a single-use ?ticket= before the // upgrade — a browser cannot set an Authorization header on a WebSocket. - withWsTicket(defaultWsUrl).then(u => csiSimulator.connectLive(u)).then(ok => { - if (ok && connectWsBtn) { + // ADR-295 (issue #1557): opening the socket does NOT mean live — the + // 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.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'; } }); diff --git a/ui/services/sensing.service.js b/ui/services/sensing.service.js index e07a09cd..5eca2d93 100644 --- a/ui/services/sensing.service.js +++ b/ui/services/sensing.service.js @@ -304,25 +304,41 @@ class SensingService { * hardware or simulation. Called once on WebSocket open. */ 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 { const resp = await fetch('/api/v1/status'); if (resp.ok) { const json = await resp.json(); - this._applyServerSource(json.source); + this._applyServerSource(json.source, json.source_state); } else { - // Can't reach status endpoint — assume live until first frame tells us - this._setDataSource('live'); + this._setDataSource('server-simulated'); } } 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; + // 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') { this._setDataSource('live'); } else if (rawSource === 'simulated' || rawSource === 'simulate') { diff --git a/v2/Cargo.lock b/v2/Cargo.lock index a3a400fc..71e668d1 100644 --- a/v2/Cargo.lock +++ b/v2/Cargo.lock @@ -7869,6 +7869,27 @@ version = "2.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "ruview-auth" version = "0.1.0" @@ -7889,6 +7910,146 @@ dependencies = [ "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]] name = "ruview-swarm" version = "0.1.0" @@ -7913,6 +8074,26 @@ dependencies = [ "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]] name = "ruview-unified" version = "0.3.1" @@ -7929,6 +8110,16 @@ dependencies = [ "wifi-densepose-hardware", ] +[[package]] +name = "ruview-witness" +version = "0.3.1" +dependencies = [ + "ruview-attest", + "serde", + "serde_json", + "thiserror 2.0.18", +] + [[package]] name = "ryu" version = "1.0.23" @@ -11365,6 +11556,7 @@ dependencies = [ "serde_json", "static_assertions", "thiserror 2.0.18", + "wifi-veil", ] [[package]] @@ -11375,6 +11567,7 @@ dependencies = [ "num-complex", "serde", "serde_json", + "sha2 0.10.9", "thiserror 2.0.18", "uuid", "wifi-densepose-core", @@ -11804,6 +11997,11 @@ dependencies = [ "wifi-densepose-geo", ] +[[package]] +name = "wifi-veil" +version = "0.1.0" +source = "git+https://github.com/ruvnet/wifi-veil?rev=018468b5d2bf41f35c552910f35659830af0eb91#018468b5d2bf41f35c552910f35659830af0eb91" + [[package]] name = "winapi" version = "0.3.9" diff --git a/v2/Cargo.toml b/v2/Cargo.toml index 22ba4e03..9f553f32 100644 --- a/v2/Cargo.toml +++ b/v2/Cargo.toml @@ -21,7 +21,7 @@ members = [ "crates/wifi-densepose-train", "crates/wifi-densepose-sensing-server", "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-vitals", "crates/wifi-densepose-ruvector", @@ -95,6 +95,28 @@ members = [ # hardware coupling, every number SYNTHETIC/L0 until real wideband RF # hardware exists. "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), # excluded from workspace to avoid breaking `cargo test --workspace`. @@ -122,6 +144,10 @@ categories = ["science", "computer-vision", "wasm"] [workspace.dependencies] # Core utilities 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" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" diff --git a/v2/crates/ruview-active/Cargo.toml b/v2/crates/ruview-active/Cargo.toml new file mode 100644 index 00000000..22edc207 --- /dev/null +++ b/v2/crates/ruview-active/Cargo.toml @@ -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 diff --git a/v2/crates/ruview-active/src/control.rs b/v2/crates/ruview-active/src/control.rs new file mode 100644 index 00000000..01010049 --- /dev/null +++ b/v2/crates/ruview-active/src/control.rs @@ -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 { + 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 { + 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 { + 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 { + 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, +} + +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, num_chains: u8) -> Result { + if num_chains == 0 || num_chains > MAX_CHAINS { + return Err(ControlError::AntennaChainOutOfRange { + index: 0, + num_chains, + max: MAX_CHAINS, + }); + } + let mut chains: Vec = 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, + /// Which channel width to probe, if bandwidth is controllable. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub bandwidth: Option, + /// How often to solicit a sounding, if cadence is controllable. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cadence: Option, + /// Which antenna chains to activate, if antenna selection is controllable. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub antenna: Option, +} + +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, + /// Controllable bandwidths, sorted ascending (narrowest first). + pub bandwidths: Vec, + /// Controllable cadences, sorted least-exploratory-first (slowest first). + pub cadences: Vec, + /// Controllable antenna selections, sorted least-exploratory-first + /// (fewest chains first). + pub antennas: Vec, +} + +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, + mut bandwidths: Vec, + mut cadences: Vec, + mut antennas: Vec, + ) -> Result { + // 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(()) + } +} diff --git a/v2/crates/ruview-active/src/lib.rs b/v2/crates/ruview-active/src/lib.rs new file mode 100644 index 00000000..14863dea --- /dev/null +++ b/v2/crates/ruview-active/src/lib.rs @@ -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::::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 = (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()); + } +} diff --git a/v2/crates/ruview-active/src/policy.rs b/v2/crates/ruview-active/src/policy.rs new file mode 100644 index 00000000..f7bed403 --- /dev/null +++ b/v2/crates/ruview-active/src/policy.rs @@ -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, + /// Zones that degraded to the passive planner. + pub passive: Vec, +} + +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(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)) +} diff --git a/v2/crates/ruview-attest/Cargo.toml b/v2/crates/ruview-attest/Cargo.toml new file mode 100644 index 00000000..c16d785e --- /dev/null +++ b/v2/crates/ruview-attest/Cargo.toml @@ -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 diff --git a/v2/crates/ruview-attest/src/lib.rs b/v2/crates/ruview-attest/src/lib.rs new file mode 100644 index 00000000..b21cb51d --- /dev/null +++ b/v2/crates/ruview-attest/src/lib.rs @@ -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) -> Result { + 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) -> Result { + 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, +} + +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 { + 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( + signer: &S, + device: DeviceId, + sequence: u64, + timestamp: Timestamp, + payload: &[u8], + calibration_ref: Option, + ) -> 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, +} + +// --------------------------------------------------------------------------- +// 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 { + verifier: V, + last_sequence: Option, +} + +/// 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 { + enrolled: BTreeMap>, + freshness: FreshnessPolicy, +} + +impl AttestationVerifier { + /// 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 { + 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 { + 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, 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 { + 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)); + } +} diff --git a/v2/crates/ruview-certify/Cargo.toml b/v2/crates/ruview-certify/Cargo.toml new file mode 100644 index 00000000..c539e9c1 --- /dev/null +++ b/v2/crates/ruview-certify/Cargo.toml @@ -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 diff --git a/v2/crates/ruview-certify/src/lib.rs b/v2/crates/ruview-certify/src/lib.rs new file mode 100644 index 00000000..1f4d387b --- /dev/null +++ b/v2/crates/ruview-certify/src/lib.rs @@ -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 { + 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, +} + +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(&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( + signer: &S, + request: MintRequest<'_>, + slice: &EvidenceSlice<'_>, +) -> Result { + // 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, 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; diff --git a/v2/crates/ruview-certify/src/tests.rs b/v2/crates/ruview-certify/src/tests.rs new file mode 100644 index 00000000..df844130 --- /dev/null +++ b/v2/crates/ruview-certify/src/tests.rs @@ -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)); +} diff --git a/v2/crates/ruview-counterfactual/Cargo.toml b/v2/crates/ruview-counterfactual/Cargo.toml new file mode 100644 index 00000000..8e236f1b --- /dev/null +++ b/v2/crates/ruview-counterfactual/Cargo.toml @@ -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 diff --git a/v2/crates/ruview-counterfactual/src/hypothesis.rs b/v2/crates/ruview-counterfactual/src/hypothesis.rs new file mode 100644 index 00000000..972b873c --- /dev/null +++ b/v2/crates/ruview-counterfactual/src/hypothesis.rs @@ -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, +} + +impl Hypothesis { + /// The **null hypothesis**: nobody present in `space`. + #[must_use] + pub fn empty(id: impl Into, 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, + space: SpaceId, + occupants: Vec, + ) -> Result { + 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, + }, +} diff --git a/v2/crates/ruview-counterfactual/src/infer.rs b/v2/crates/ruview-counterfactual/src/infer.rs new file mode 100644 index 00000000..8511ea66 --- /dev/null +++ b/v2/crates/ruview-counterfactual/src/infer.rs @@ -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 { + (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, + /// 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 { + 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 = 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 +} diff --git a/v2/crates/ruview-counterfactual/src/lib.rs b/v2/crates/ruview-counterfactual/src/lib.rs new file mode 100644 index 00000000..e485d3b0 --- /dev/null +++ b/v2/crates/ruview-counterfactual/src/lib.rs @@ -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 = 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); + } +} diff --git a/v2/crates/ruview-evidence/Cargo.toml b/v2/crates/ruview-evidence/Cargo.toml new file mode 100644 index 00000000..26c8df77 --- /dev/null +++ b/v2/crates/ruview-evidence/Cargo.toml @@ -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 diff --git a/v2/crates/ruview-evidence/src/lib.rs b/v2/crates/ruview-evidence/src/lib.rs new file mode 100644 index 00000000..1d09664d --- /dev/null +++ b/v2/crates/ruview-evidence/src/lib.rs @@ -0,0 +1,977 @@ +//! # `ruview-evidence` — the append-only accuracy ledger (ADR-304, ADR-300 §4) +//! +//! "MLflow for physical sensing." Where an experiment tracker overwrites +//! yesterday's number, this crate is an **append-only** record of how a model +//! actually performs, keyed per deployment context +//! `(room, device, subject-class, model-version)` and carrying, per record, +//! the ADR-304 metrics (moving/stationary recall, false-positive rate, drift, +//! predictive uncertainty, calibration age, sample count) plus exactly one +//! [`EvidenceLevel`] (L0–L5, mirroring ADR-282 semantics). +//! +//! ## Leaf, deterministic, honest +//! +//! - **Leaf**: this crate depends only on `serde`/`thiserror`. The +//! [`EvidenceLevel`] ladder mirrors ADR-282 (`frame::EvidenceLevel`) but is +//! defined locally so the ledger never pulls in the frame crate. +//! - **Deterministic**: no wall-clock and no randomness. Record time is +//! injected by the caller; the ledger assigns a monotonic append sequence. +//! - **Honest by construction**: +//! - A record's [`EvidenceLevel`] is fixed by its *provenance* at write time +//! ([`EvidenceRecord::synthetic`] is `L0` and cannot be raised — there is +//! no `set_level`). This is the ADR-282/288/290 "no upgrade" rule. +//! - Records are **append-only**: [`EvidenceLedger::append`] consumes a +//! record by value and nothing hands back a mutable reference. A correction +//! is a *new* record, never an in-place edit (ADR-304 §1). +//! - Aggregation **never pools across contexts** (ADR-304 §2/§Consequences): +//! an [`EvidenceSlice`] is minted by [`EvidenceLedger::query`] for exactly +//! one context and there is no API that averages two contexts into one +//! number. A summary's evidence level is the **floor** (minimum) of the +//! levels present in the slice — a slice can never report a level above the +//! weakest record it contains. +//! - An empty context returns [`SummaryEvidence::NoEvidence`], distinct from a +//! present-but-zero-accuracy summary — downstream (ADR-318) must treat +//! "no evidence" as "no capability", not as a `0.0` score. + +#![forbid(unsafe_code)] + +use serde::{Deserialize, Serialize}; + +/// Maximum byte length accepted for any context identifier string. Bounds +/// allocation at the untrusted-input boundary (CLAUDE.md). +pub const MAX_ID_LEN: usize = 256; + +/// Default upper bound on records held by a single ledger. Bounds allocation; +/// [`EvidenceLedger::with_capacity`] can raise or lower it. +pub const DEFAULT_MAX_RECORDS: usize = 1_000_000; + +/// The ADR-282 evidence ladder, L0–L5, mirrored locally to keep this crate a +/// leaf (no dependency on the frame crate). Exactly one level travels with each +/// [`EvidenceRecord`]. Ordering is meaningful and load-bearing: the summary +/// floor rule takes `min` over these, so `L0 < L1 < … < L5`. +/// +/// Semantics mirror `frame::EvidenceLevel` (ADR-282 §4): L0 simulation-only, +/// rising to L5 production/witnessed evidence. See ADR-282 for the canonical +/// ladder; this enum is a faithful local copy, not an independent scale. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum EvidenceLevel { + /// L0 — simulation / synthetic only, no signal evidence (ADR-282). + L0, + /// L1 — captured replay / heuristic evidence. + L1, + /// L2 — controlled single-surface signal evidence. + L2, + /// L3 — corroborated / held-out room-and-subject validation. + L3, + /// L4 — calibrated multi-site field evidence. + L4, + /// L5 — production, witnessed / certified (ADR-319). + L5, +} + +/// Accuracy tag for a record (CLAUDE.md honesty rule). The class is fixed by +/// the constructor used and cannot alias: synthetic input can never be minted +/// as `Measured`. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum ProvenanceClass { + /// Produced by a simulator/generator — L0 by construction (ADR-276/301). + Synthetic, + /// Real inference but no ground-truth reference backs the accuracy. + Claimed, + /// Backed by an ADR-303 reference plus a reproducer handle. + Measured, +} + +/// The deployment context a record is keyed by: `(room, device, subject-class, +/// model-version)`. Identity is caller-supplied (ADR-306 space id, ADR-305 +/// signed device id); this crate treats the fields as opaque bounded handles +/// and never invents them. +#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct EvidenceContext { + /// Space / room id (ADR-306). + pub room: String, + /// Signed device id (ADR-305). + pub device: String, + /// Subject class where consented/available; empty means "no subject" + /// (ADR-304 §1 — subject id only where consented). + pub subject_class: String, + /// Model version that produced the inferences (ADR-136). + pub model_version: String, +} + +impl EvidenceContext { + /// Construct a context, validating every field at the boundary. `room`, + /// `device`, and `model_version` must be non-empty; every field is bounded + /// to [`MAX_ID_LEN`] bytes. `subject_class` may be empty (no consented + /// subject) but is still length-bounded. + /// + /// # Errors + /// Returns [`EvidenceError::EmptyField`] for a missing required field and + /// [`EvidenceError::IdTooLong`] for any over-length field. + pub fn new( + room: impl Into, + device: impl Into, + subject_class: impl Into, + model_version: impl Into, + ) -> Result { + 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, +} + +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 { + 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 { + 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, + timestamp_ns: u64, + ) -> Result { + 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 { + 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, + 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 { + 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 { + let mut out: Vec = 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 { + 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 = 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 } + ); + } +} diff --git a/v2/crates/ruview-fusion/Cargo.toml b/v2/crates/ruview-fusion/Cargo.toml new file mode 100644 index 00000000..d5b25d1c --- /dev/null +++ b/v2/crates/ruview-fusion/Cargo.toml @@ -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 diff --git a/v2/crates/ruview-fusion/src/engine.rs b/v2/crates/ruview-fusion/src/engine.rs new file mode 100644 index 00000000..2d29d968 --- /dev/null +++ b/v2/crates/ruview-fusion/src/engine.rs @@ -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> = 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 = 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 = 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()), + } +} diff --git a/v2/crates/ruview-fusion/src/estimate.rs b/v2/crates/ruview-fusion/src/estimate.rs new file mode 100644 index 00000000..fe56f81b --- /dev/null +++ b/v2/crates/ruview-fusion/src/estimate.rs @@ -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 { + 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 { + 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::() + / 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, + }) +} diff --git a/v2/crates/ruview-fusion/src/lib.rs b/v2/crates/ruview-fusion/src/lib.rs new file mode 100644 index 00000000..8e50fad3 --- /dev/null +++ b/v2/crates/ruview-fusion/src/lib.rs @@ -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); + } +} diff --git a/v2/crates/ruview-fusion/src/observation.rs b/v2/crates/ruview-fusion/src/observation.rs new file mode 100644 index 00000000..3dd5049a --- /dev/null +++ b/v2/crates/ruview-fusion/src/observation.rs @@ -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 { + 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 { + if self.hal.uncertainty.degraded { + return None; + } + self.claim.estimate() + } +} diff --git a/v2/crates/ruview-fusion/src/world.rs b/v2/crates/ruview-fusion/src/world.rs new file mode 100644 index 00000000..12c19b58 --- /dev/null +++ b/v2/crates/ruview-fusion/src/world.rs @@ -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, + /// 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, +} + +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 { + 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, +} + +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) + } +} diff --git a/v2/crates/ruview-groundtruth/Cargo.toml b/v2/crates/ruview-groundtruth/Cargo.toml new file mode 100644 index 00000000..850f487e --- /dev/null +++ b/v2/crates/ruview-groundtruth/Cargo.toml @@ -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 diff --git a/v2/crates/ruview-groundtruth/src/agreement.rs b/v2/crates/ruview-groundtruth/src/agreement.rs new file mode 100644 index 00000000..0d3ac1a3 --- /dev/null +++ b/v2/crates/ruview-groundtruth/src/agreement.rs @@ -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, +} + +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, + ) -> Result { + 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, + /// 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 { + 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 { + 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 } + } +} diff --git a/v2/crates/ruview-groundtruth/src/align.rs b/v2/crates/ruview-groundtruth/src/align.rs new file mode 100644 index 00000000..33fc5624 --- /dev/null +++ b/v2/crates/ruview-groundtruth/src/align.rs @@ -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, + /// 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> { + 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 { + if pairs.is_empty() { + return None; + } + match &pairs[0].0 { + Reading::Scalar(_) => { + let xs: Vec = pairs.iter().filter_map(|(e, _)| e.as_scalar()).collect(); + let ys: Vec = 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 { + let n = xs.len() as f64; + let mx = xs.iter().sum::() / n; + let my = ys.iter().sum::() / 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 { + // 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 = est.iter().map(|o| o.at_unix_ms).collect(); + let ref_times: Vec = 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 = 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 { + 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 = 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(), + }) +} diff --git a/v2/crates/ruview-groundtruth/src/error.rs b/v2/crates/ruview-groundtruth/src/error.rs new file mode 100644 index 00000000..1abf28f4 --- /dev/null +++ b/v2/crates/ruview-groundtruth/src/error.rs @@ -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(()) +} diff --git a/v2/crates/ruview-groundtruth/src/lib.rs b/v2/crates/ruview-groundtruth/src/lib.rs new file mode 100644 index 00000000..8388acc9 --- /dev/null +++ b/v2/crates/ruview-groundtruth/src/lib.rs @@ -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 { .. } + )); + } +} diff --git a/v2/crates/ruview-groundtruth/src/model.rs b/v2/crates/ruview-groundtruth/src/model.rs new file mode 100644 index 00000000..ae13c133 --- /dev/null +++ b/v2/crates/ruview-groundtruth/src/model.rs @@ -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 { + 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, +} diff --git a/v2/crates/ruview-groundtruth/src/scope.rs b/v2/crates/ruview-groundtruth/src/scope.rs new file mode 100644 index 00000000..3e1acc0f --- /dev/null +++ b/v2/crates/ruview-groundtruth/src/scope.rs @@ -0,0 +1,93 @@ +//! Mandatory session scope (ADR-303 §3, mirroring ADR-293). +//! +//! An agreement report without scope cannot be constructed: WiFi-sensing +//! numbers without stated scope (subject count, motion, line-of-sight, +//! distance) are systematically misleading (ADR-293 Context). [`SessionScope`] +//! is a required argument to [`crate::AgreementReport::build`], so the type +//! system enforces the rule. + +use serde::{Deserialize, Serialize}; + +use crate::error::GroundTruthError; + +/// The largest subject count accepted, bounding untrusted input. +pub const MAX_SUBJECTS: u16 = 4096; + +/// Whether subjects were static or moving during the session. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MotionState { + /// Subject(s) static / at rest. + Static, + /// Subject(s) moving. + Moving, + /// A mix of static and moving intervals. + Mixed, +} + +/// The propagation condition between sensor and subject. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LineOfSight { + /// Line-of-sight. + Los, + /// Non-line-of-sight (obstructed, same room). + Nlos, + /// Through-wall. + ThroughWall, +} + +/// A coarse distance band between sensor and subject. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum DistanceBand { + /// Near (roughly < 2 m). + Near, + /// Mid (roughly 2–5 m). + Mid, + /// Far (roughly > 5 m). + Far, +} + +/// Mandatory metadata attached to every [`crate::AgreementReport`]. A report +/// cannot exist without it, so an agreement number always states the conditions +/// it was measured under. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct SessionScope { + /// Number of subjects present (0 is valid for an empty-room session). + pub subject_count: u16, + /// Motion state during the session. + pub motion: MotionState, + /// Line-of-sight condition. + pub line_of_sight: LineOfSight, + /// Distance band. + pub distance: DistanceBand, +} + +impl SessionScope { + /// Construct a validated session scope. `subject_count` is bounded to + /// [`MAX_SUBJECTS`] so untrusted metadata cannot claim an absurd count. + /// + /// # Errors + /// [`GroundTruthError::SubjectCountTooLarge`] if `subject_count` exceeds + /// [`MAX_SUBJECTS`]. + pub fn new( + subject_count: u16, + motion: MotionState, + line_of_sight: LineOfSight, + distance: DistanceBand, + ) -> Result { + 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, + }) + } +} diff --git a/v2/crates/ruview-groundtruth/src/series.rs b/v2/crates/ruview-groundtruth/src/series.rs new file mode 100644 index 00000000..035451a9 --- /dev/null +++ b/v2/crates/ruview-groundtruth/src/series.rs @@ -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) -> 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 = 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, +} + +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, + ) -> Result { + 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, +} + +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, + provenance: DataProvenance, + samples: Vec, + ) -> Result { + 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() + } +} diff --git a/v2/crates/ruview-groundtruth/src/source.rs b/v2/crates/ruview-groundtruth/src/source.rs new file mode 100644 index 00000000..e710db2f --- /dev/null +++ b/v2/crates/ruview-groundtruth/src/source.rs @@ -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, + device: impl Into, + principle: impl Into, + ) -> Result { + 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, + }) + } +} diff --git a/v2/crates/ruview-hal/Cargo.toml b/v2/crates/ruview-hal/Cargo.toml new file mode 100644 index 00000000..115866ae --- /dev/null +++ b/v2/crates/ruview-hal/Cargo.toml @@ -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 diff --git a/v2/crates/ruview-hal/src/adapter.rs b/v2/crates/ruview-hal/src/adapter.rs new file mode 100644 index 00000000..3401f0c6 --- /dev/null +++ b/v2/crates/ruview-hal/src/adapter.rs @@ -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) -> 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, + /// Per-subcarrier phases (radians). + pub phases: Vec, +} + +/// 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::() + .sqrt(); + let closeness = 1.0 - ((g - 9.81).abs() / 9.81); + Uncertainty::known(closeness) + }; + + HalObservation { + modality: Modality::Imu, + uncertainty, + observation, + } + } +} diff --git a/v2/crates/ruview-hal/src/descriptor.rs b/v2/crates/ruview-hal/src/descriptor.rs new file mode 100644 index 00000000..a63178da --- /dev/null +++ b/v2/crates/ruview-hal/src/descriptor.rs @@ -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, + /// 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, + /// 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() + } +} diff --git a/v2/crates/ruview-hal/src/label.rs b/v2/crates/ruview-hal/src/label.rs new file mode 100644 index 00000000..25fe6e13 --- /dev/null +++ b/v2/crates/ruview-hal/src/label.rs @@ -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) -> Result { + 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) + } +} diff --git a/v2/crates/ruview-hal/src/lib.rs b/v2/crates/ruview-hal/src/lib.rs new file mode 100644 index 00000000..cc634e47 --- /dev/null +++ b/v2/crates/ruview-hal/src/lib.rs @@ -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); + } +} diff --git a/v2/crates/ruview-hal/src/modality.rs b/v2/crates/ruview-hal/src/modality.rs new file mode 100644 index 00000000..3304c740 --- /dev/null +++ b/v2/crates/ruview-hal/src/modality.rs @@ -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) -> Result { + 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 + ) + } +} diff --git a/v2/crates/ruview-hal/src/observation.rs b/v2/crates/ruview-hal/src/observation.rs new file mode 100644 index 00000000..7209c055 --- /dev/null +++ b/v2/crates/ruview-hal/src/observation.rs @@ -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 + } +} diff --git a/v2/crates/ruview-infogain/Cargo.toml b/v2/crates/ruview-infogain/Cargo.toml new file mode 100644 index 00000000..2fc3165d --- /dev/null +++ b/v2/crates/ruview-infogain/Cargo.toml @@ -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 diff --git a/v2/crates/ruview-infogain/src/candidate.rs b/v2/crates/ruview-infogain/src/candidate.rs new file mode 100644 index 00000000..5fb75143 --- /dev/null +++ b/v2/crates/ruview-infogain/src/candidate.rs @@ -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 { + 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, + } + } +} diff --git a/v2/crates/ruview-infogain/src/cost.rs b/v2/crates/ruview-infogain/src/cost.rs new file mode 100644 index 00000000..af92e65a --- /dev/null +++ b/v2/crates/ruview-infogain/src/cost.rs @@ -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 + } +} diff --git a/v2/crates/ruview-infogain/src/lib.rs b/v2/crates/ruview-infogain/src/lib.rs new file mode 100644 index 00000000..dcdf3638 --- /dev/null +++ b/v2/crates/ruview-infogain/src/lib.rs @@ -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); + } +} diff --git a/v2/crates/ruview-infogain/src/scheduler.rs b/v2/crates/ruview-infogain/src/scheduler.rs new file mode 100644 index 00000000..c25c4dc6 --- /dev/null +++ b/v2/crates/ruview-infogain/src/scheduler.rs @@ -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, +} + +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, + /// 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, + /// Actions skipped this cycle, each with its reason, sorted by sensor id. + pub deferred: Vec, + /// 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, +} + +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> = Vec::new(); + let mut ranked: Vec> = Vec::new(); + let mut deferred: Vec = 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 = 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, Option, 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, + } +} diff --git a/v2/crates/ruview-memory/Cargo.toml b/v2/crates/ruview-memory/Cargo.toml new file mode 100644 index 00000000..bd56fb4e --- /dev/null +++ b/v2/crates/ruview-memory/Cargo.toml @@ -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 diff --git a/v2/crates/ruview-memory/src/anomaly.rs b/v2/crates/ruview-memory/src/anomaly.rs new file mode 100644 index 00000000..ab6d7384 --- /dev/null +++ b/v2/crates/ruview-memory/src/anomaly.rs @@ -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 { + 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:", 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 { + 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) + } +} diff --git a/v2/crates/ruview-memory/src/baseline.rs b/v2/crates/ruview-memory/src/baseline.rs new file mode 100644 index 00000000..f89e91f6 --- /dev/null +++ b/v2/crates/ruview-memory/src/baseline.rs @@ -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, + /// 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, 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, + /// Learned per-channel modality normal. + modality: BTreeMap, + /// 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, + /// 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 { + 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); + } +} diff --git a/v2/crates/ruview-memory/src/error.rs b/v2/crates/ruview-memory/src/error.rs new file mode 100644 index 00000000..c90fdd4d --- /dev/null +++ b/v2/crates/ruview-memory/src/error.rs @@ -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, + }, +} diff --git a/v2/crates/ruview-memory/src/lib.rs b/v2/crates/ruview-memory/src/lib.rs new file mode 100644 index 00000000..f553ef7c --- /dev/null +++ b/v2/crates/ruview-memory/src/lib.rs @@ -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, +} + +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 { + 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 { + 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 { + 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, 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, + 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, +} + +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 { + 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); + } +} diff --git a/v2/crates/ruview-memory/src/stat.rs b/v2/crates/ruview-memory/src/stat.rs new file mode 100644 index 00000000..34bc4923 --- /dev/null +++ b/v2/crates/ruview-memory/src/stat.rs @@ -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); + } +} diff --git a/v2/crates/ruview-ontology/Cargo.toml b/v2/crates/ruview-ontology/Cargo.toml new file mode 100644 index 00000000..1446f951 --- /dev/null +++ b/v2/crates/ruview-ontology/Cargo.toml @@ -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 diff --git a/v2/crates/ruview-ontology/src/entity.rs b/v2/crates/ruview-ontology/src/entity.rs new file mode 100644 index 00000000..0b222956 --- /dev/null +++ b/v2/crates/ruview-ontology/src/entity.rs @@ -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, + /// 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, + /// 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); diff --git a/v2/crates/ruview-ontology/src/graph.rs b/v2/crates/ruview-ontology/src/graph.rs new file mode 100644 index 00000000..afb39d6d --- /dev/null +++ b/v2/crates/ruview-ontology/src/graph.rs @@ -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, + /// Buildings, keyed by id. + pub buildings: BTreeMap, + /// Floors, keyed by id. + pub floors: BTreeMap, + /// Spaces, keyed by id. + pub spaces: BTreeMap, + /// Zones, keyed by id. + pub zones: BTreeMap, + /// Sensors, keyed by id. + pub sensors: BTreeMap, + /// Persons, keyed by id. + pub persons: BTreeMap, + /// Objects, keyed by id. + pub objects: BTreeMap, + /// Observations, keyed by id. + pub observations: BTreeMap, + /// Tracks, keyed by id. + pub tracks: BTreeMap, + /// Events, keyed by id. + pub events: BTreeMap, +} + +/// 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) + } +} diff --git a/v2/crates/ruview-ontology/src/id.rs b/v2/crates/ruview-ontology/src/id.rs new file mode 100644 index 00000000..261b8816 --- /dev/null +++ b/v2/crates/ruview-ontology/src/id.rs @@ -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) -> Result { + 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"); + } +} diff --git a/v2/crates/ruview-ontology/src/lib.rs b/v2/crates/ruview-ontology/src/lib.rs new file mode 100644 index 00000000..06673b2a --- /dev/null +++ b/v2/crates/ruview-ontology/src/lib.rs @@ -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>(()) +//! ``` +//! +//! ## 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 `...//` 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", .. })); + } +} diff --git a/v2/crates/ruview-ontology/src/provenance.rs b/v2/crates/ruview-ontology/src/provenance.rs new file mode 100644 index 00000000..59bd141b --- /dev/null +++ b/v2/crates/ruview-ontology/src/provenance.rs @@ -0,0 +1,68 @@ +//! Evidence ladder and provenance carried by every fact (ADR-306 §2, ADR-282). +//! +//! The ontology mandates that a fact cannot cross a surface boundary and lose +//! its lineage: every leaf entity carries exactly one [`EvidenceLevel`] plus a +//! [`SemanticProvenance`] record, so no projection can silently upgrade or drop +//! the evidence level. + +use serde::{Deserialize, Serialize}; + +/// The ADR-282 evidence ladder, L0–L5. Exactly one level travels with each +/// fact. Ordering is meaningful: `L0 < L1 < … < L5`. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum EvidenceLevel { + /// L0 — declared/assumed, no signal evidence. + L0, + /// L1 — heuristic/synthetic. + L1, + /// L2 — single-surface signal evidence. + L2, + /// L3 — corroborated across surfaces. + L3, + /// L4 — calibrated and held-out validated. + L4, + /// L5 — witnessed / certified (ADR-319). + L5, +} + +/// Mandatory provenance for every fact (mirrors the `worldgraph` +/// `SemanticProvenance` house rule so the two can map losslessly). Every field +/// is a bounded string handle, not embedded data. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SemanticProvenance { + /// Evidence content-address handle(s) (ADR-137 `EvidenceRef`). + #[serde(default)] + pub evidence: Vec, + /// 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) -> 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); + } +} diff --git a/v2/crates/ruview-ood/Cargo.toml b/v2/crates/ruview-ood/Cargo.toml new file mode 100644 index 00000000..d9410d7a --- /dev/null +++ b/v2/crates/ruview-ood/Cargo.toml @@ -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 diff --git a/v2/crates/ruview-ood/src/certificate.rs b/v2/crates/ruview-ood/src/certificate.rs new file mode 100644 index 00000000..29ce0efa --- /dev/null +++ b/v2/crates/ruview-ood/src/certificate.rs @@ -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( + 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 +} diff --git a/v2/crates/ruview-ood/src/domain.rs b/v2/crates/ruview-ood/src/domain.rs new file mode 100644 index 00000000..faf7915a --- /dev/null +++ b/v2/crates/ruview-ood/src/domain.rs @@ -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 { + 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 { + 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 { + 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 { + 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 + } +} diff --git a/v2/crates/ruview-ood/src/error.rs b/v2/crates/ruview-ood/src/error.rs new file mode 100644 index 00000000..75c3a580 --- /dev/null +++ b/v2/crates/ruview-ood/src/error.rs @@ -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 = core::result::Result; + +/// 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 { + 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) +} diff --git a/v2/crates/ruview-ood/src/gate.rs b/v2/crates/ruview-ood/src/gate.rs new file mode 100644 index 00000000..07d89685 --- /dev/null +++ b/v2/crates/ruview-ood/src/gate.rs @@ -0,0 +1,197 @@ +//! The inference gate (ADR-302 §2, ADR-300 rule 1). +//! +//! Every inference passes through the gate. It: +//! +//! 1. classifies the domain from distance + envelope + signal quality + +//! calibration compatibility; +//! 2. attaches the model's own predictive **uncertainty** (the fourth ADR-302 +//! input), escalating a KNOWN domain to DEGRADED when uncertainty is +//! elevated; +//! 3. **suppresses the confident class** when the domain is not KNOWN — an +//! UNKNOWN domain returns no class, a first-class value rather than a +//! confidently-wrong label (ADR-300 rule 1); +//! 4. emits a [`RecalibrationRequest`] whenever the state is DEGRADED or +//! UNKNOWN — a *signal*, never an action; recalibration itself is out of +//! scope for this crate (ADR-300 staleness guard). + +use serde::{Deserialize, Serialize}; +use wifi_densepose_calibration::certificate::{CompatibilityEnvelope, FingerprintDistance}; + +use crate::domain::{clamp_unit, CalibrationCompat, DomainCause, DomainState, DomainThresholds, SignalQuality}; +use crate::error::{require_unit_interval, Result}; + +/// A model head's proposed inference, before gating. `class` is the model's +/// candidate label of any type; `confidence`/`uncertainty` are its own scores. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Inference { + /// The model's candidate class/label. + pub class: C, + /// The model's reported confidence in `[0, 1]` (sanitized at the gate). + pub confidence: f32, + /// The model's predictive uncertainty in `[0, 1]` (sanitized at the gate). + pub uncertainty: f32, +} + +impl Inference { + /// Construct an inference. Confidence/uncertainty are stored as given and + /// sanitized (clamped, NaN → worst) when the gate consumes them, so a + /// hostile model score cannot escape `[0, 1]` downstream. + pub fn new(class: C, confidence: f32, uncertainty: f32) -> Self { + Self { + class, + confidence, + uncertainty, + } + } +} + +/// How urgently recalibration is needed. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum RecalibrationUrgency { + /// DEGRADED: recommended — the domain still supports flagged inferences. + Recommended, + /// UNKNOWN: required — confident inference is suspended until re-cal. + Required, +} + +/// A signal that recalibration should be triggered (ADR-302 §2 / ADR-300 +/// staleness guard). This crate **emits** the request; it never performs +/// recalibration (that is ADR-301's job). Carries the triggering cause so the +/// caller can route it. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct RecalibrationRequest { + /// Why recalibration is being requested. + pub reason: DomainCause, + /// How urgent the request is. + pub urgency: RecalibrationUrgency, +} + +/// The fully-contextualized result of gating one inference. Carries the domain +/// state, all four input measurements, and either a (flagged) class or none — +/// so downstream consumers (ADR-304 evidence engine) get the whole decision, +/// never a bare label. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct GatedInference { + /// The domain state (KNOWN / DEGRADED / UNKNOWN + cause). + pub state: DomainState, + /// Live-vs-certified domain distance (ADR-302 input 1). + pub distance: FingerprintDistance, + /// Signal quality (ADR-302 input 2). + pub signal_quality: SignalQuality, + /// Calibration compatibility (ADR-302 input 3). + pub calibration_compat: CalibrationCompat, + /// Model predictive uncertainty, sanitized to `[0, 1]` (ADR-302 input 4). + pub uncertainty: f32, + /// The returned class. `None` in UNKNOWN — the confident label is + /// suppressed (ADR-300 rule 1). `Some` in KNOWN and DEGRADED (flagged). + pub class: Option, + /// Sanitized confidence, present iff a class is returned. + pub confidence: Option, + /// A recalibration signal, present iff the state is DEGRADED or UNKNOWN. + pub recalibration: Option, +} + +impl GatedInference { + /// `true` iff a confident class survived the gate (only in KNOWN). + pub fn is_confident(&self) -> bool { + self.state.is_known() && self.class.is_some() + } +} + +/// The shared OOD gate every inference routes through (ADR-302 §2). +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct InferenceGate { + thresholds: DomainThresholds, + /// Max uncertainty tolerated while KNOWN; above it, a KNOWN domain is + /// escalated to DEGRADED (the fourth ADR-302 input acting on the state). + max_uncertainty_known: f32, +} + +impl Default for InferenceGate { + fn default() -> Self { + Self { + thresholds: DomainThresholds::default(), + max_uncertainty_known: 0.5, + } + } +} + +impl InferenceGate { + /// Validated constructor. `max_uncertainty_known` must be finite in `[0, 1]`. + pub fn new(thresholds: DomainThresholds, max_uncertainty_known: f32) -> Result { + let max_uncertainty_known = require_unit_interval("max_uncertainty_known", max_uncertainty_known)?; + Ok(Self { + thresholds, + max_uncertainty_known, + }) + } + + /// The thresholds in effect (reported with each decision per ADR-302 §2). + pub fn thresholds(&self) -> DomainThresholds { + self.thresholds + } + + /// Gate one inference. Pure: deterministic in its inputs, no clock, no + /// randomness, bounded allocation. Consumes `inference` (the class is moved + /// into the result or dropped when suppressed). + /// + /// Behavior: + /// - KNOWN → class + confidence returned, no recalibration signal; + /// - DEGRADED → class + confidence returned **flagged**, recalibration + /// *recommended*; + /// - UNKNOWN → class suppressed (`None`), recalibration *required*. + pub fn evaluate( + &self, + inference: Inference, + distance: FingerprintDistance, + envelope: CompatibilityEnvelope, + signal_quality: SignalQuality, + calibration_compat: CalibrationCompat, + ) -> GatedInference { + let uncertainty = clamp_unit(inference.uncertainty); + + let mut state = self + .thresholds + .classify(distance, envelope, signal_quality, calibration_compat); + + // Fourth input: elevated uncertainty escalates a KNOWN domain to + // DEGRADED. It never *upgrades* a state — worst signal always wins. + if state.is_known() && uncertainty > self.max_uncertainty_known { + state = DomainState::Degraded(DomainCause::ElevatedUncertainty); + } + + let confidence = clamp_unit(inference.confidence); + let (class, confidence, recalibration) = match state { + DomainState::Known => (Some(inference.class), Some(confidence), None), + DomainState::Degraded(reason) => ( + Some(inference.class), + Some(confidence), + Some(RecalibrationRequest { + reason, + urgency: RecalibrationUrgency::Recommended, + }), + ), + // ADR-300 rule 1: no confident class in UNKNOWN. The class is + // dropped, not returned with lowered confidence. + DomainState::Unknown(reason) => ( + None, + None, + Some(RecalibrationRequest { + reason, + urgency: RecalibrationUrgency::Required, + }), + ), + }; + + GatedInference { + state, + distance, + signal_quality, + calibration_compat, + uncertainty, + class, + confidence, + recalibration, + } + } +} diff --git a/v2/crates/ruview-ood/src/lib.rs b/v2/crates/ruview-ood/src/lib.rs new file mode 100644 index 00000000..2d658222 --- /dev/null +++ b/v2/crates/ruview-ood/src/lib.rs @@ -0,0 +1,546 @@ +//! # ruview-ood — out-of-distribution detection (ADR-302) +//! +//! Primitive 2 of the ADR-300 perception substrate: the gate that attaches a +//! [`DomainState`] — `KNOWN` / `DEGRADED` / `UNKNOWN` — to **every** inference, +//! so RuView can say *"I do not recognize this situation"* instead of returning +//! a confidently-wrong label when it leaves its calibrated domain. +//! +//! It fuses four measured inputs (ADR-302 §1) against the ADR-301 +//! [`CalibrationCertificate`](wifi_densepose_calibration::certificate::CalibrationCertificate): +//! +//! 1. **domain distance** — [`domain_distance`] over live vs certified +//! fingerprints (reusing ADR-301's [`FingerprintDistance`]); +//! 2. **signal quality** — [`SignalQuality`] (ADR-137); +//! 3. **calibration compatibility** — [`CalibrationCompat`], derived from a +//! certificate via [`assess_certificate`] / [`no_certificate`]; +//! 4. **uncertainty** — the model head's own predictive uncertainty, attached +//! at the [`InferenceGate`]. +//! +//! ## The four non-negotiable rules (ADR-300) +//! +//! - **UNKNOWN is a first-class value, never an error.** [`DomainState::Unknown`] +//! is returned, not thrown; the gate suppresses the confident class rather +//! than defaulting to one or silently holding a stale value. +//! - **Staleness guard `VALID → DEGRADED → UNKNOWN`.** [`DomainThresholds::classify`] +//! escalates monotonically: crossing the envelope's inner threshold → +//! DEGRADED, the outer threshold (or a missing/stale/mismatched certificate) +//! → UNKNOWN. DEGRADED/UNKNOWN both raise a [`RecalibrationRequest`] — a +//! *signal*, not an action. +//! - **Honesty.** No accuracy is claimed here; this crate ships the gating +//! machinery only. Synthetic test fixtures are labelled as such; no MEASURED +//! or hardware claim is made. +//! +//! All logic is pure and deterministic: time is injected, there is no +//! randomness, allocation is bounded, and malformed runtime input yields +//! UNKNOWN rather than a panic. + +#![forbid(unsafe_code)] + +pub mod certificate; +pub mod domain; +pub mod error; +pub mod gate; + +pub use certificate::{assess_certificate, no_certificate, ExpectedIdentity}; +pub use domain::{ + domain_distance, CalibrationCompat, DomainCause, DomainState, DomainThresholds, SignalQuality, +}; +pub use error::{OodError, Result}; +pub use gate::{ + GatedInference, Inference, InferenceGate, RecalibrationRequest, RecalibrationUrgency, +}; + +// Re-export the calibration primitives this crate gates against, so consumers +// have one import surface. +pub use wifi_densepose_calibration::certificate::{ + CompatibilityEnvelope, FingerprintDistance, RoomFingerprint, +}; + +#[cfg(test)] +mod tests { + use super::*; + use wifi_densepose_calibration::certificate::{ + CalibrationCertificate, CalibrationTier, CharacterizationSource, CompatibilityEnvelope, + EvidenceLevel, FingerprintDistance, KeyedHashSigner, MintParams, RoomFingerprint, + }; + use wifi_densepose_calibration::{ + anchor::AnchorLabel, + bank::SpecialistBank, + extract::{AnchorFeature, Features}, + }; + + // --- synthetic fixtures (SYNTHETIC / L0) ------------------------------- + + /// A synthetic fingerprint with a tunable empty-baseline mean, so drift is + /// deterministic and monotone. SYNTHETIC — no measured/hardware claim. + fn fingerprint(empty_mean: f32) -> RoomFingerprint { + RoomFingerprint { + schema_version: 1, + empty_mean, + empty_variance: 1.0, + occupied_variance: 10.0, + presence_threshold: 5.0, + occupancy_mean_shift: 2.0, + geometry: Default::default(), + } + } + + fn envelope() -> CompatibilityEnvelope { + // outer = 0.15; with default inner_drift_fraction 0.6, inner = 0.09. + CompatibilityEnvelope::default() + } + + fn good_quality() -> SignalQuality { + SignalQuality::new(0.9, false, true).unwrap() + } + + /// Distance producing exactly `total` (bypassing fingerprint math when a + /// precise drift value is needed for a boundary test). Fields are public in + /// the calibration crate, so this is a legitimate synthetic construction. + fn dist(total: f32) -> FingerprintDistance { + FingerprintDistance { + baseline_drift: total, + occupancy_drift: 0.0, + total, + } + } + + // --- (1) domain distance ---------------------------------------------- + + #[test] + fn domain_distance_reuses_fingerprint_metric() { + let certified = fingerprint(1.0); + let identical = fingerprint(1.0); + let drifted = fingerprint(50.0); + + let d0 = domain_distance(&certified, &identical); + assert_eq!(d0.total, 0.0, "identical fingerprints have zero drift"); + + let d1 = domain_distance(&certified, &drifted); + assert!(d1.total > 0.0, "a moved empty-baseline registers drift"); + // Matches the calibration crate's own metric (no second definition). + assert_eq!(d1, certified.distance(&drifted)); + } + + // --- (2) classify: KNOWN / DEGRADED / UNKNOWN -------------------------- + + #[test] + fn known_within_envelope() { + let state = DomainState::classify(dist(0.02), envelope(), good_quality(), CalibrationCompat::Valid); + assert_eq!(state, DomainState::Known); + assert!(state.is_known()); + assert_eq!(state.cause(), None); + } + + #[test] + fn degraded_at_inner_threshold_crossing() { + // inner = 0.15 * 0.6 = 0.09; just above it, still within the outer 0.15. + let state = DomainState::classify(dist(0.10), envelope(), good_quality(), CalibrationCompat::Valid); + assert_eq!(state, DomainState::Degraded(DomainCause::ModerateDrift)); + assert!(state.is_degraded()); + } + + #[test] + fn unknown_past_outer_threshold() { + let state = DomainState::classify(dist(0.20), envelope(), good_quality(), CalibrationCompat::Valid); + assert_eq!(state, DomainState::Unknown(DomainCause::DriftBeyondEnvelope)); + assert!(state.is_unknown()); + } + + #[test] + fn unknown_missing_certificate_defaults_unknown() { + // Absent certificate → UNKNOWN even with zero drift and perfect quality. + let state = DomainState::classify(dist(0.0), envelope(), good_quality(), no_certificate()); + assert_eq!(state, DomainState::Unknown(DomainCause::NoCertificate)); + } + + #[test] + fn unknown_stale_and_mismatched_certificates() { + for (compat, cause) in [ + (CalibrationCompat::Expired, DomainCause::CertificateExpired), + (CalibrationCompat::Tampered, DomainCause::CertificateTampered), + (CalibrationCompat::DeviceMismatch, DomainCause::DeviceMismatch), + (CalibrationCompat::SpaceMismatch, DomainCause::SpaceMismatch), + (CalibrationCompat::DriftedBeyondEnvelope, DomainCause::DriftBeyondEnvelope), + ] { + let state = DomainState::classify(dist(0.0), envelope(), good_quality(), compat); + assert_eq!(state, DomainState::Unknown(cause), "compat {compat:?} → UNKNOWN"); + } + } + + #[test] + fn certificate_check_precedes_drift_in_staleness_guard() { + // Absent certificate wins over an otherwise-in-envelope distance. + let state = DomainState::classify(dist(0.01), envelope(), good_quality(), CalibrationCompat::Absent); + assert_eq!(state, DomainState::Unknown(DomainCause::NoCertificate)); + } + + #[test] + fn degraded_on_contradiction_and_low_quality() { + let contra = SignalQuality::new(0.9, true, true).unwrap(); + assert_eq!( + DomainState::classify(dist(0.0), envelope(), contra, CalibrationCompat::Valid), + DomainState::Degraded(DomainCause::Contradiction) + ); + + let lowish = SignalQuality::new(0.45, false, true).unwrap(); // floor 0.3 < 0.45 < 0.6 + assert_eq!( + DomainState::classify(dist(0.0), envelope(), lowish, CalibrationCompat::Valid), + DomainState::Degraded(DomainCause::LowSignalQuality) + ); + } + + #[test] + fn unknown_on_unusable_signal() { + let below_floor = SignalQuality::new(0.1, false, true).unwrap(); + assert_eq!( + DomainState::classify(dist(0.0), envelope(), below_floor, CalibrationCompat::Valid), + DomainState::Unknown(DomainCause::SignalUnusable) + ); + let invalid = SignalQuality::new(0.9, false, false).unwrap(); + assert_eq!( + DomainState::classify(dist(0.0), envelope(), invalid, CalibrationCompat::Valid), + DomainState::Unknown(DomainCause::SignalUnusable) + ); + } + + #[test] + fn hysteresis_inner_below_outer() { + // The inner (DEGRADED) line is strictly below the outer (UNKNOWN) line, + // so drift straddling one boundary cannot flap KNOWN⇄UNKNOWN directly. + let t = DomainThresholds::default(); + let outer = envelope().max_total_drift; + let inner = outer * t.inner_drift_fraction; + assert!(inner < outer); + // A value between the two lines is DEGRADED, not KNOWN and not UNKNOWN. + let mid = 0.5 * (inner + outer); + assert_eq!( + DomainState::classify(dist(mid), envelope(), good_quality(), CalibrationCompat::Valid), + DomainState::Degraded(DomainCause::ModerateDrift) + ); + } + + // --- (3) gate suppresses confident class under DEGRADED / UNKNOWN ------ + + #[test] + fn gate_returns_confident_class_when_known() { + let gate = InferenceGate::default(); + let out = gate.evaluate( + Inference::new("standing", 0.95, 0.1), + dist(0.02), + envelope(), + good_quality(), + CalibrationCompat::Valid, + ); + assert_eq!(out.state, DomainState::Known); + assert_eq!(out.class, Some("standing")); + assert_eq!(out.confidence, Some(0.95)); + assert!(out.recalibration.is_none()); + assert!(out.is_confident()); + } + + #[test] + fn gate_flags_but_keeps_class_when_degraded() { + let gate = InferenceGate::default(); + let out = gate.evaluate( + Inference::new("sitting", 0.9, 0.1), + dist(0.10), // inner-crossing drift + envelope(), + good_quality(), + CalibrationCompat::Valid, + ); + assert!(out.state.is_degraded()); + // DEGRADED still returns the class, but flagged + recalibration recommended. + assert_eq!(out.class, Some("sitting")); + assert!(!out.is_confident(), "a degraded class is not a confident class"); + let rec = out.recalibration.expect("degraded requests recalibration"); + assert_eq!(rec.urgency, RecalibrationUrgency::Recommended); + assert_eq!(rec.reason, DomainCause::ModerateDrift); + } + + #[test] + fn gate_suppresses_class_when_unknown() { + let gate = InferenceGate::default(); + let out = gate.evaluate( + Inference::new("lying_down", 0.99, 0.05), // model is very "confident" + dist(0.30), // past the outer envelope + envelope(), + good_quality(), + CalibrationCompat::Valid, + ); + assert!(out.state.is_unknown()); + // ADR-300 rule 1: no confident class survives an UNKNOWN domain. + assert_eq!(out.class, None); + assert_eq!(out.confidence, None); + assert!(!out.is_confident()); + let rec = out.recalibration.expect("unknown requires recalibration"); + assert_eq!(rec.urgency, RecalibrationUrgency::Required); + } + + #[test] + fn gate_suppresses_class_when_certificate_absent() { + let gate = InferenceGate::default(); + let out = gate.evaluate( + Inference::new("standing", 0.99, 0.01), + dist(0.0), + envelope(), + good_quality(), + no_certificate(), + ); + assert_eq!(out.state, DomainState::Unknown(DomainCause::NoCertificate)); + assert_eq!(out.class, None); + } + + #[test] + fn gate_escalates_known_to_degraded_on_uncertainty() { + let gate = InferenceGate::default(); + // In-envelope + good quality would be KNOWN, but high uncertainty (>0.5). + let out = gate.evaluate( + Inference::new("standing", 0.8, 0.9), + dist(0.02), + envelope(), + good_quality(), + CalibrationCompat::Valid, + ); + assert_eq!(out.state, DomainState::Degraded(DomainCause::ElevatedUncertainty)); + assert_eq!(out.class, Some("standing")); // degraded keeps the flagged class + assert!(out.recalibration.is_some()); + } + + #[test] + fn uncertainty_never_upgrades_a_worse_state() { + // Even zero uncertainty cannot rescue an UNKNOWN domain. + let gate = InferenceGate::default(); + let out = gate.evaluate( + Inference::new("x", 1.0, 0.0), + dist(0.5), + envelope(), + good_quality(), + CalibrationCompat::Valid, + ); + assert!(out.state.is_unknown()); + assert_eq!(out.class, None); + } + + // --- (4) recalibration signalled on DEGRADED and UNKNOWN -------------- + + #[test] + fn recalibration_signalled_only_when_not_known() { + let gate = InferenceGate::default(); + + let known = gate.evaluate( + Inference::new(1u8, 0.9, 0.1), + dist(0.0), + envelope(), + good_quality(), + CalibrationCompat::Valid, + ); + assert!(known.recalibration.is_none()); + + let degraded = gate.evaluate( + Inference::new(1u8, 0.9, 0.1), + dist(0.10), + envelope(), + good_quality(), + CalibrationCompat::Valid, + ); + assert!(degraded.recalibration.is_some()); + + let unknown = gate.evaluate( + Inference::new(1u8, 0.9, 0.1), + dist(0.0), + envelope(), + good_quality(), + no_certificate(), + ); + assert!(unknown.recalibration.is_some()); + } + + // --- determinism ------------------------------------------------------- + + #[test] + fn classification_is_deterministic() { + let inputs = (dist(0.10), envelope(), good_quality(), CalibrationCompat::Valid); + let first = DomainState::classify(inputs.0, inputs.1, inputs.2, inputs.3); + for _ in 0..1000 { + assert_eq!(DomainState::classify(inputs.0, inputs.1, inputs.2, inputs.3), first); + } + } + + #[test] + fn gated_inference_serializes_stably() { + let gate = InferenceGate::default(); + let out = gate.evaluate( + Inference::new("standing".to_string(), 0.9, 0.1), + dist(0.10), + envelope(), + good_quality(), + CalibrationCompat::Valid, + ); + let a = serde_json::to_string(&out).unwrap(); + let b = serde_json::to_string(&out).unwrap(); + assert_eq!(a, b, "serialization is deterministic"); + assert!(a.contains("Degraded"), "state is present on the record"); + } + + // --- boundary validation ---------------------------------------------- + + #[test] + fn malformed_config_is_rejected_not_panicked() { + assert!(SignalQuality::new(f32::NAN, false, true).is_err()); + assert!(SignalQuality::new(1.5, false, true).is_err()); + assert!(SignalQuality::new(-0.1, false, true).is_err()); + + assert!(DomainThresholds::new(1.0, 0.6, 0.3).is_err()); // fraction not < 1 + assert!(DomainThresholds::new(f32::INFINITY, 0.6, 0.3).is_err()); + assert!(DomainThresholds::new(0.6, 0.3, 0.6).is_err()); // floor > known_min + assert!(DomainThresholds::new(0.6, 0.6, 0.3).is_ok()); + + assert!(InferenceGate::new(DomainThresholds::default(), 2.0).is_err()); + assert!(InferenceGate::new(DomainThresholds::default(), 0.5).is_ok()); + } + + #[test] + fn malformed_runtime_input_yields_unknown_not_panic() { + // A non-finite live distance is treated as maximal drift → UNKNOWN. + let state = DomainState::classify(dist(f32::NAN), envelope(), good_quality(), CalibrationCompat::Valid); + assert_eq!(state, DomainState::Unknown(DomainCause::DriftBeyondEnvelope)); + + // A hostile model uncertainty (NaN) is sanitized (→ worst), never panics. + let gate = InferenceGate::default(); + let out = gate.evaluate( + Inference::new("x", f32::NAN, f32::NAN), + dist(0.02), + envelope(), + good_quality(), + CalibrationCompat::Valid, + ); + assert!(out.uncertainty.is_finite()); + // NaN uncertainty clamps to 0.0 here (worst-for-unit maps low); the + // point is no panic and a finite, bounded value. + assert!((0.0..=1.0).contains(&out.uncertainty)); + } + + #[test] + fn signal_quality_from_signals_is_bounded_under_hostile_input() { + let q = SignalQuality::from_signals(f32::NAN, f32::INFINITY, false, true); + assert!((0.0..=1.0).contains(&q.score)); + let q2 = SignalQuality::from_signals(2.0, 100.0, false, true); // out-of-range clamps + assert!((0.0..=1.0).contains(&q2.score)); + } + + // --- cross-ADR: consume a real ADR-301 certificate -------------------- + + fn af(label: AnchorLabel, mean: f32, variance: f32, motion: f32) -> AnchorFeature { + AnchorFeature { + room_id: "living-room".into(), + label, + features: Features { + mean, + variance, + motion, + breathing_score: 0.0, + breathing_hz: 0.0, + heart_score: 0.0, + heart_hz: 0.0, + }, + } + } + + fn synthetic_bank() -> SpecialistBank { + let anchors = vec![ + af(AnchorLabel::Empty, 1.0, 1.0, 0.1), + af(AnchorLabel::StandStill, 3.0, 10.0, 0.2), + af(AnchorLabel::Sit, 1.0, 6.0, 0.2), + af(AnchorLabel::LieDown, 1.0, 3.0, 0.2), + ]; + SpecialistBank::train("living-room", "base-1", &anchors, 1000).unwrap() + } + + /// Mint a SYNTHETIC / L0 certificate — honest labelling (CLAUDE.md). + fn synthetic_certificate() -> (CalibrationCertificate, KeyedHashSigner) { + let signer = KeyedHashSigner::new("sensor-42", b"secret".to_vec()); + let params = MintParams { + space_id: "home/living-room".into(), + sensor_id: "sensor-42".into(), + captured_at_unix_s: 1_000_000, + validity_secs: 3600, + version: 1, + tier: CalibrationTier::Auto, + evidence: EvidenceLevel::L0Synthetic, + source: CharacterizationSource::Synthetic, + envelope: CompatibilityEnvelope::default(), + }; + let cert = CalibrationCertificate::mint(params, &synthetic_bank(), &signer).unwrap(); + (cert, signer) + } + + #[test] + fn cross_adr_valid_certificate_drives_known() { + let (cert, signer) = synthetic_certificate(); + let live = cert.fingerprint.clone(); // no drift + let now = cert.captured_at_unix_s + 10; + let expected = ExpectedIdentity { + space_id: "home/living-room", + device_id: "sensor-42", + }; + let (distance, compat) = assess_certificate(&cert, &live, expected, now, &signer); + assert_eq!(compat, CalibrationCompat::Valid); + + let gate = InferenceGate::default(); + let out = gate.evaluate( + Inference::new("standing", 0.9, 0.1), + distance, + cert.envelope, + good_quality(), + compat, + ); + assert_eq!(out.state, DomainState::Known); + assert_eq!(out.class, Some("standing")); + } + + #[test] + fn cross_adr_expired_certificate_drives_unknown() { + let (cert, signer) = synthetic_certificate(); + let live = cert.fingerprint.clone(); + let now = cert.expires_at_unix_s + 1; // stale + let expected = ExpectedIdentity { + space_id: "home/living-room", + device_id: "sensor-42", + }; + let (distance, compat) = assess_certificate(&cert, &live, expected, now, &signer); + assert_eq!(compat, CalibrationCompat::Expired); + + let gate = InferenceGate::default(); + let out = gate.evaluate( + Inference::new("standing", 0.99, 0.01), + distance, + cert.envelope, + good_quality(), + compat, + ); + assert_eq!(out.state, DomainState::Unknown(DomainCause::CertificateExpired)); + assert_eq!(out.class, None, "no confident class from a stale certificate"); + } + + #[test] + fn cross_adr_device_and_space_mismatch_drive_unknown() { + let (cert, signer) = synthetic_certificate(); + let live = cert.fingerprint.clone(); + let now = cert.captured_at_unix_s + 10; + + let wrong_device = ExpectedIdentity { + space_id: "home/living-room", + device_id: "sensor-99", + }; + let (_d, compat) = assess_certificate(&cert, &live, wrong_device, now, &signer); + assert_eq!(compat, CalibrationCompat::DeviceMismatch); + + let wrong_space = ExpectedIdentity { + space_id: "office/lab", + device_id: "sensor-42", + }; + let (_d2, compat2) = assess_certificate(&cert, &live, wrong_space, now, &signer); + assert_eq!(compat2, CalibrationCompat::SpaceMismatch); + } +} diff --git a/v2/crates/ruview-placement/Cargo.toml b/v2/crates/ruview-placement/Cargo.toml new file mode 100644 index 00000000..74435b6d --- /dev/null +++ b/v2/crates/ruview-placement/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "ruview-placement" +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" } + +[dev-dependencies] +serde_json.workspace = true diff --git a/v2/crates/ruview-placement/src/compare.rs b/v2/crates/ruview-placement/src/compare.rs new file mode 100644 index 00000000..0b81a84f --- /dev/null +++ b/v2/crates/ruview-placement/src/compare.rs @@ -0,0 +1,317 @@ +//! Post-install loop: predicted vs. measured observability → adjustments +//! (ADR-308 §3). +//! +//! **This crate never measures.** The `measured` observability values are +//! supplied by the caller — the ADR-302 runtime observability signal from freshly +//! enrolled, calibrated sensors — and this module only *compares* them against the +//! optimizer's own SYNTHETIC/L0 prediction. The predicted side stays labelled +//! `L0`; a `MEASURED` statement, if any, belongs to the caller's measured input +//! together with its reproducer (CLAUDE.md hardware rule). Where measurement +//! disagrees with prediction, the module recommends an adjustment and a coarse +//! twin-parameter residual to feed back into the ADR-315 twin. Following ADR-300 +//! rule 1, a target with no measured value yields a first-class UNKNOWN verdict, +//! never an error. + +use serde::{Deserialize, Serialize}; + +use ruview_ontology::{Container, EvidenceLevel}; + +use crate::coverage::{Observability, PlacementScore}; + +/// Default tolerance (in observability units) inside which predicted and measured +/// are treated as matching. +pub const DEFAULT_COMPARE_TOLERANCE: f64 = 0.15; + +/// Coarse dB of implied effective attenuation per unit of observability shortfall, +/// used only to suggest a twin-parameter residual to feed back into ADR-315. A +/// rough SYNTHETIC heuristic, not a calibrated figure. +const RESIDUAL_DB_PER_UNIT: f64 = 30.0; + +/// A single caller-supplied measured observability for a target. +/// +/// The value's evidence level is the *caller's* to assert (with a reproducer, per +/// the hardware rule); this crate carries it through unchanged. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct MeasuredTarget { + /// The target region measured. + pub target: Container, + /// The caller-supplied observed observability score, `[0, 1]`. + pub observed_score: f64, +} + +/// A set of caller-supplied measured observability values. +#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)] +pub struct MeasuredObservability { + /// Per-target measurements. + pub per_target: Vec, +} + +impl MeasuredObservability { + /// An empty measurement set. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Add one measured target and return `self` for chaining. + #[must_use] + pub fn with(mut self, target: Container, observed_score: f64) -> Self { + self.per_target.push(MeasuredTarget { target, observed_score }); + self + } + + /// The measured score for a target, if present. + #[must_use] + fn score_for(&self, target: &Container) -> Option { + self.per_target + .iter() + .find(|m| &m.target == target) + .map(|m| m.observed_score) + } +} + +/// The verdict comparing predicted and measured observability for one target. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CompareVerdict { + /// Measured is within tolerance of predicted. + Match, + /// Measured is materially below predicted (reality is worse than the model). + Underperforming, + /// Measured is materially above predicted (the model was pessimistic). + Overperforming, + /// Cannot compare (no measured value, or prediction was UNKNOWN). First-class + /// UNKNOWN (ADR-300 rule 1). + Unknown, +} + +/// The recommended action for a target. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AdjustmentAction { + /// Nothing to change; prediction and measurement agree. + NoActionNeeded, + /// Re-aim / reposition an existing node (cheap first move — favoured when the + /// prediction itself was uncertain). + ReAim, + /// Move a node materially, or accept a larger geometry change. + MoveNode, + /// Add another node to recover the objective. + AddNode, + /// Not enough information to recommend anything (measurement missing/UNKNOWN). + InsufficientData, +} + +/// Direction of a suggested twin-parameter residual. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ResidualKind { + /// Reality attenuates more than the twin modelled (measured < predicted). + EffectiveAttenuationHigher, + /// Reality attenuates less than the twin modelled (measured > predicted). + EffectiveAttenuationLower, +} + +/// A coarse twin-parameter residual to feed back into the ADR-315 twin. +/// +/// **SYNTHETIC / L0.** A rough model-improvement hint, not a calibrated value. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct TwinResidual { + /// Which way the twin's effective attenuation should move. + pub kind: ResidualKind, + /// Rough magnitude of the suggested effective-attenuation change, dB. + pub magnitude_db: f64, +} + +/// A recommended adjustment for one target. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Adjustment { + /// The target this adjustment concerns. + pub target: Container, + /// The recommended action. + pub action: AdjustmentAction, + /// Human-readable rationale. + pub rationale: String, + /// Coarse twin-parameter residual to feed back into ADR-315, if any. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub twin_residual: Option, +} + +/// The predicted-vs-measured comparison for one target. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct TargetComparison { + /// The target region. + pub target: Container, + /// The optimizer's predicted observability (SYNTHETIC/L0). + pub predicted: Observability, + /// The caller-supplied measured score, if present. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub measured: Option, + /// `measured - predicted_score`, when both are available. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub delta: Option, + /// The verdict. + pub verdict: CompareVerdict, +} + +/// The full post-install adjustment report. +/// +/// The predicted side is `L0` (SYNTHETIC); the measured side is caller-supplied. +/// This report is a set of recommendations, never a sensing claim. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct AdjustmentReport { + /// Per-target comparisons. + pub comparisons: Vec, + /// Recommended adjustments (targets needing action). + pub adjustments: Vec, + /// Evidence level of the *predicted* side. Always `L0` (SYNTHETIC). + pub predicted_evidence_level: EvidenceLevel, +} + +impl AdjustmentReport { + /// True when at least one target needs a corrective action. + #[must_use] + pub fn needs_adjustment(&self) -> bool { + self.adjustments.iter().any(|a| { + !matches!( + a.action, + AdjustmentAction::NoActionNeeded | AdjustmentAction::InsufficientData + ) + }) + } +} + +/// Compare predicted against measured observability using the default tolerance. +#[must_use] +pub fn compare_post_install( + predicted: &PlacementScore, + measured: &MeasuredObservability, +) -> AdjustmentReport { + compare_post_install_with_tolerance(predicted, measured, DEFAULT_COMPARE_TOLERANCE) +} + +/// Compare predicted against measured observability with an explicit tolerance. +/// +/// Deterministic and never panics. A target whose prediction is UNKNOWN, or that +/// has no measured value, yields a [`CompareVerdict::Unknown`] and an +/// [`AdjustmentAction::InsufficientData`] recommendation (ADR-300 rule 1). +#[must_use] +pub fn compare_post_install_with_tolerance( + predicted: &PlacementScore, + measured: &MeasuredObservability, + tolerance: f64, +) -> AdjustmentReport { + let tol = if tolerance.is_finite() && tolerance >= 0.0 { + tolerance + } else { + DEFAULT_COMPARE_TOLERANCE + }; + + let mut comparisons = Vec::with_capacity(predicted.per_target.len()); + let mut adjustments = Vec::new(); + + for cov in &predicted.per_target { + let target = cov.target.clone(); + let measured_score = measured.score_for(&target).filter(|v| v.is_finite()); + + let (predicted_score, predicted_uncertainty) = match cov.observability.known() { + Some((s, u)) => (Some(s), u), + None => (None, 1.0), + }; + + match (predicted_score, measured_score) { + (Some(pred), Some(meas)) => { + let delta = meas - pred; + let (verdict, action, residual) = classify(delta, tol, predicted_uncertainty); + let rationale = rationale_for(action, delta, pred, meas); + comparisons.push(TargetComparison { + target: target.clone(), + predicted: cov.observability, + measured: Some(meas), + delta: Some(delta), + verdict, + }); + adjustments.push(Adjustment { target, action, rationale, twin_residual: residual }); + } + _ => { + // Missing measurement or UNKNOWN prediction: first-class UNKNOWN. + comparisons.push(TargetComparison { + target: target.clone(), + predicted: cov.observability, + measured: measured_score, + delta: None, + verdict: CompareVerdict::Unknown, + }); + adjustments.push(Adjustment { + target, + action: AdjustmentAction::InsufficientData, + rationale: "no measured observability to compare against prediction".to_string(), + twin_residual: None, + }); + } + } + } + + AdjustmentReport { + comparisons, + adjustments, + predicted_evidence_level: EvidenceLevel::L0, + } +} + +/// Classify a predicted-vs-measured delta into a verdict, action, and residual. +fn classify( + delta: f64, + tolerance: f64, + predicted_uncertainty: f64, +) -> (CompareVerdict, AdjustmentAction, Option) { + if delta < -tolerance { + // Reality worse than modelled: recommend a corrective move. + // Favour the cheap re-aim when the prediction itself was uncertain. + let action = if predicted_uncertainty > 0.5 { + AdjustmentAction::ReAim + } else if delta < -2.0 * tolerance { + AdjustmentAction::AddNode + } else { + AdjustmentAction::MoveNode + }; + let residual = TwinResidual { + kind: ResidualKind::EffectiveAttenuationHigher, + magnitude_db: (delta.abs() * RESIDUAL_DB_PER_UNIT).min(120.0), + }; + (CompareVerdict::Underperforming, action, Some(residual)) + } else if delta > tolerance { + // Reality better than modelled: no action, but the twin was pessimistic. + let residual = TwinResidual { + kind: ResidualKind::EffectiveAttenuationLower, + magnitude_db: (delta.abs() * RESIDUAL_DB_PER_UNIT).min(120.0), + }; + (CompareVerdict::Overperforming, AdjustmentAction::NoActionNeeded, Some(residual)) + } else { + (CompareVerdict::Match, AdjustmentAction::NoActionNeeded, None) + } +} + +/// Build a human-readable rationale string. +fn rationale_for(action: AdjustmentAction, delta: f64, predicted: f64, measured: f64) -> String { + match action { + AdjustmentAction::NoActionNeeded => format!( + "measured {measured:.2} matches predicted {predicted:.2} within tolerance" + ), + AdjustmentAction::ReAim => format!( + "measured {measured:.2} below predicted {predicted:.2} (Δ {delta:.2}); prediction was \ + uncertain, so re-aim an existing node first" + ), + AdjustmentAction::MoveNode => format!( + "measured {measured:.2} below predicted {predicted:.2} (Δ {delta:.2}); move a node to \ + recover coverage" + ), + AdjustmentAction::AddNode => format!( + "measured {measured:.2} far below predicted {predicted:.2} (Δ {delta:.2}); add a node \ + to recover the objective" + ), + AdjustmentAction::InsufficientData => { + "no measured observability to compare against prediction".to_string() + } + } +} diff --git a/v2/crates/ruview-placement/src/coverage.rs b/v2/crates/ruview-placement/src/coverage.rs new file mode 100644 index 00000000..f2797a62 --- /dev/null +++ b/v2/crates/ruview-placement/src/coverage.rs @@ -0,0 +1,570 @@ +//! Coverage / observability scoring of a candidate placement (ADR-308 §2). +//! +//! **SYNTHETIC / L0.** Everything here is a *recommendation derived from a +//! simulation*, never a sensing claim. A candidate placement is scored by +//! consuming the ADR-315 [`RfTwin`] forward model: for a grid of sample points in +//! a target region, a point is "observable" when it lies inside the first Fresnel +//! zone of a well-predicted link ([`crate::fresnel`]). Per target we report an +//! [`Observability`] carrying **both** a modelled score and its uncertainty — +//! never a single confident number for a simulated result (ADR-308 §2). Following +//! ADR-300 rule 1, a target the model cannot evaluate is [`Observability::Unknown`], +//! a first-class value, not an error. + +use serde::{Deserialize, Serialize}; + +use ruview_ontology::{Container, EvidenceLevel, SemanticProvenance, SensorId}; +use ruview_twin::{ + predict_link, Container as TwinContainer, DeploymentDescription, ExpectedDistribution, Point3, + PropagationParams, RadioNode, RfTwin, +}; + +use crate::fresnel::link_clearance; +use crate::geometry::{sample_points, FloorPlan, PlacementError}; + +/// A single placed radio in a candidate placement. Position is reused twin +/// geometry ([`Point3`]); identity is assigned when the twin is built. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct PlacedRadio { + /// Metric position (metres) in the plan frame. + pub position: Point3, + /// Modelled transmit power, dBm. + pub tx_power_dbm: f64, +} + +impl PlacedRadio { + /// Construct a placed radio. + #[must_use] + pub const fn new(position: Point3, tx_power_dbm: f64) -> Self { + Self { position, tx_power_dbm } + } +} + +/// A candidate set of radio positions to score. +#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)] +pub struct Placement { + /// The placed radios. + pub radios: Vec, +} + +impl Placement { + /// An empty placement. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Number of placed radios. + #[must_use] + pub fn len(&self) -> usize { + self.radios.len() + } + + /// True when no radios are placed. + #[must_use] + pub fn is_empty(&self) -> bool { + self.radios.is_empty() + } +} + +/// The phenomenon a sensing objective requires. Higher-order phenomena demand +/// stronger Fresnel clearance to count a point as observable ([`Self::demand`]). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Phenomenon { + /// Coarse presence / occupancy. + Presence, + /// Vital-sign sensing (stricter clearance demand). + Vitals, + /// Pose estimation (strictest clearance demand). + Pose, +} + +impl Phenomenon { + /// Multiplier applied to the base coverage threshold: a stricter phenomenon + /// needs a higher modelled sensing value at a point to count it as covered. + #[must_use] + pub fn demand(self) -> f64 { + match self { + Phenomenon::Presence => 1.0, + Phenomenon::Vitals => 1.6, + Phenomenon::Pose => 2.2, + } + } +} + +/// A sensing objective: a phenomenon that must be observable in a target region. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Objective { + /// The space or zone that must be observable. + pub target: Container, + /// The phenomenon required there. + pub phenomenon: Phenomenon, +} + +impl Objective { + /// Construct an objective. + #[must_use] + pub fn new(target: Container, phenomenon: Phenomenon) -> Self { + Self { target, phenomenon } + } +} + +/// Parameters of the SYNTHETIC coverage model. All defaults are didactic; they +/// assert nothing about any real environment. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct PlacementParams { + /// Modelled wavelength, metres (default ≈ 2.4 GHz). + pub wavelength_m: f64, + /// Modelled RSSI (dBm) at/above which a link has full sensing quality. + pub good_rssi_dbm: f64, + /// Modelled RSSI (dBm) at/below which a link has zero sensing quality. + pub floor_rssi_dbm: f64, + /// Point sensing value (`clearance × quality`) at/above which a sample point + /// counts as covered, before the per-phenomenon demand multiplier. + pub coverage_threshold: f64, + /// Covered-fraction below which a target is flagged as a blind spot. + pub blind_spot_fraction: f64, + /// Sample-grid step, metres. + pub grid_step_m: f64, + /// Candidate-grid step, metres (used by the search). + pub candidate_step_m: f64, + /// Reference variance (dB²) mapping modelled link variance to `[0, 1]` + /// uncertainty. + pub uncertainty_variance_ref_db2: f64, + /// Cap on sample points per target (bounded allocation). + pub max_sample_points: usize, + /// Cap on generated candidate positions (bounded search). + pub max_candidates: usize, + /// Explicit seed for deterministic candidate generation. No RNG anywhere. + pub seed: u64, + /// Twin propagation-model parameters. + pub propagation: PropagationParams, +} + +impl PlacementParams { + /// A neutral SYNTHETIC default set. + #[must_use] + pub fn default_synthetic() -> Self { + Self { + wavelength_m: 0.1249, + good_rssi_dbm: -50.0, + floor_rssi_dbm: -85.0, + coverage_threshold: 0.10, + blind_spot_fraction: 0.15, + grid_step_m: 0.5, + candidate_step_m: 1.0, + uncertainty_variance_ref_db2: 64.0, + max_sample_points: 4096, + max_candidates: 512, + seed: 0, + propagation: PropagationParams::default_indoor(), + } + } + + /// Validate the parameters at the boundary. + pub fn validate(&self) -> Result<(), PlacementError> { + let finite_pos = |v: f64| v.is_finite() && v > 0.0; + if !finite_pos(self.wavelength_m) { + return Err(PlacementError::InvalidParameter { what: "wavelength_m must be > 0" }); + } + if !finite_pos(self.grid_step_m) { + return Err(PlacementError::InvalidParameter { what: "grid_step_m must be > 0" }); + } + if !finite_pos(self.candidate_step_m) { + return Err(PlacementError::InvalidParameter { what: "candidate_step_m must be > 0" }); + } + if !(self.good_rssi_dbm.is_finite() + && self.floor_rssi_dbm.is_finite() + && self.good_rssi_dbm > self.floor_rssi_dbm) + { + return Err(PlacementError::InvalidParameter { + what: "good_rssi_dbm must be finite and > floor_rssi_dbm", + }); + } + if !(self.coverage_threshold.is_finite() && self.coverage_threshold > 0.0) { + return Err(PlacementError::InvalidParameter { what: "coverage_threshold must be > 0" }); + } + if !(self.uncertainty_variance_ref_db2.is_finite() && self.uncertainty_variance_ref_db2 > 0.0) + { + return Err(PlacementError::InvalidParameter { + what: "uncertainty_variance_ref_db2 must be > 0", + }); + } + if self.max_sample_points == 0 || self.max_candidates == 0 { + return Err(PlacementError::InvalidParameter { + what: "sample/candidate caps must be > 0", + }); + } + // Mirror the twin's propagation-parameter domain (its own validator is + // private); the twin re-checks these on build regardless. + let p = &self.propagation; + if !(p.path_loss_exponent.is_finite() && p.path_loss_exponent > 0.0) + || !(p.reference_distance_m.is_finite() && p.reference_distance_m > 0.0) + || !p.reference_loss_db.is_finite() + || !(p.shadowing_sigma_db.is_finite() && p.shadowing_sigma_db >= 0.0) + { + return Err(PlacementError::InvalidParameter { what: "invalid propagation params" }); + } + Ok(()) + } +} + +/// Why a target's observability is unknown. UNKNOWN is a first-class output +/// (ADR-300 rule 1), not an error. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ObservabilityUnknown { + /// The objective's target is not present in this floor plan. + TargetNotInPlan, + /// The target region produced no sample points (degenerate geometry). + EmptyRegion, + /// Fewer than two placed radios, so the twin has no links to evaluate. + NoLinks, + /// The modelled computation produced a non-finite value. + NonFinite, +} + +/// Modelled observability of a target region. +/// +/// **SYNTHETIC / L0.** A model-relative statement carrying its own uncertainty, +/// never evidence that a region *is* being sensed. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "status", rename_all = "snake_case")] +pub enum Observability { + /// A modelled observability `score` in `[0, 1]` and its `uncertainty` in + /// `[0, 1]`. Both are reported; neither is a confident single number. + Known { + /// Mean modelled sensing value over the region, `[0, 1]`. + score: f64, + /// Modelled uncertainty of that score, `[0, 1]` (higher = less certain). + uncertainty: f64, + }, + /// The model cannot evaluate this target; carries a first-class reason. + Unknown { + /// Why it is unknown. + reason: ObservabilityUnknown, + }, +} + +impl Observability { + /// Borrow `(score, uncertainty)` when known. + #[must_use] + pub fn known(&self) -> Option<(f64, f64)> { + match self { + Observability::Known { score, uncertainty } => Some((*score, *uncertainty)), + Observability::Unknown { .. } => None, + } + } +} + +/// Per-target coverage detail. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct TargetCoverage { + /// The target region. + pub target: Container, + /// The phenomenon required there. + pub phenomenon: Phenomenon, + /// Modelled observability (score + uncertainty, or UNKNOWN). + pub observability: Observability, + /// Fraction of sample points that met the (phenomenon-scaled) coverage + /// threshold, `[0, 1]`. + pub covered_fraction: f64, + /// Number of sample points evaluated. + pub sample_count: usize, + /// True when this target is flagged as a blind spot. + pub blind_spot: bool, +} + +/// The score of a candidate placement across all objectives. +/// +/// **SYNTHETIC / L0.** A recommendation, never a sensing claim. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct PlacementScore { + /// Sample-weighted mean of the per-target modelled scores (Unknown targets + /// contribute nothing), `[0, 1]`. + pub total_score: f64, + /// Per-target coverage detail. + pub per_target: Vec, + /// Targets flagged as blind spots (a subset of `per_target`, by container). + pub blind_spots: Vec, + /// Number of radios in the scored placement. + pub node_count: usize, + /// Evidence level of this score. Always `L0` (SYNTHETIC). + pub evidence_level: EvidenceLevel, + /// Provenance travelling with the score. + pub provenance: SemanticProvenance, +} + +impl PlacementScore { + /// True when at least one target is flagged as a blind spot. + #[must_use] + pub fn has_blind_spot(&self) -> bool { + !self.blind_spots.is_empty() + } +} + +/// A precomputed link with the geometry and modelled quality the coverage model +/// needs. Internal. +struct LinkGeom { + a: (f64, f64), + b: (f64, f64), + quality: f64, + variance: f64, +} + +/// Map a modelled RSSI mean to a sensing quality in `[0, 1]`. +fn quality_from_mean(mean: f64, params: &PlacementParams) -> f64 { + if !mean.is_finite() { + return 0.0; + } + let span = params.good_rssi_dbm - params.floor_rssi_dbm; + ((mean - params.floor_rssi_dbm) / span).clamp(0.0, 1.0) +} + +/// Build the twin from a placement and derive per-link geometry + quality. An +/// empty result means "no evaluable links" (fewer than two finite nodes, or the +/// twin rejected the scene) — surfaced as UNKNOWN by callers, never a panic. +fn build_links(plan: &FloorPlan, placement: &Placement, params: &PlacementParams) -> Vec { + let space = plan.space.clone(); + let mut nodes: Vec = Vec::new(); + for (i, radio) in placement.radios.iter().enumerate() { + if !radio.position.is_finite() || !radio.tx_power_dbm.is_finite() { + continue; + } + let id = match SensorId::new(format!("place-{i}")) { + Ok(id) => id, + Err(_) => continue, + }; + nodes.push(RadioNode { + id, + position: radio.position, + located_in: TwinContainer::Space { id: space.clone() }, + tx_power_dbm: radio.tx_power_dbm, + }); + } + if nodes.len() < 2 { + return Vec::new(); + } + let desc = DeploymentDescription { + space, + nodes, + walls: plan.walls.clone(), + params: params.propagation, + multipath: Vec::new(), + calibration_version: "synthetic-placement".to_string(), + seed: 0, + }; + let twin = match RfTwin::build(desc) { + Ok(twin) => twin, + Err(_) => return Vec::new(), + }; + let mut links = Vec::new(); + for link in twin.links() { + if let ExpectedDistribution::Known { mean, variance, .. } = predict_link(&twin, &link) { + let (Some(na), Some(nb)) = (twin.node(&link.a), twin.node(&link.b)) else { + continue; + }; + links.push(LinkGeom { + a: na.position.xy(), + b: nb.position.xy(), + quality: quality_from_mean(mean, params), + variance, + }); + } + } + links +} + +/// The best modelled sensing value at a point and the variance of the link that +/// achieved it. Sensing is `max over links of clearance × quality`. +fn point_sensing(links: &[LinkGeom], p: (f64, f64), params: &PlacementParams) -> (f64, f64) { + let mut best = 0.0_f64; + let mut best_var = params.uncertainty_variance_ref_db2; + for link in links { + let s = link_clearance(link.a, link.b, p, params.wavelength_m) * link.quality; + if s > best { + best = s; + best_var = link.variance; + } + } + (best, best_var) +} + +/// Score one target region against the precomputed links. +fn score_target( + plan: &FloorPlan, + links: &[LinkGeom], + objective: &Objective, + params: &PlacementParams, +) -> TargetCoverage { + let phenomenon = objective.phenomenon; + let target = objective.target.clone(); + + let region = match plan.region_for(&target) { + Some(r) => r, + None => { + return TargetCoverage { + target, + phenomenon, + observability: Observability::Unknown { + reason: ObservabilityUnknown::TargetNotInPlan, + }, + covered_fraction: 0.0, + sample_count: 0, + blind_spot: false, + }; + } + }; + + let samples = sample_points(®ion, params.grid_step_m, params.max_sample_points); + if samples.is_empty() { + return TargetCoverage { + target, + phenomenon, + observability: Observability::Unknown { reason: ObservabilityUnknown::EmptyRegion }, + covered_fraction: 0.0, + sample_count: 0, + blind_spot: false, + }; + } + + if links.is_empty() { + // No links to evaluate: genuinely unknown, and a blind spot by definition. + return TargetCoverage { + target, + phenomenon, + observability: Observability::Unknown { reason: ObservabilityUnknown::NoLinks }, + covered_fraction: 0.0, + sample_count: samples.len(), + blind_spot: true, + }; + } + + let threshold = (params.coverage_threshold * phenomenon.demand()).clamp(f64::MIN_POSITIVE, 1.0); + let n = samples.len() as f64; + let mut score_sum = 0.0_f64; + let mut unc_sum = 0.0_f64; + let mut covered = 0usize; + + for &p in &samples { + let (sensing, var) = point_sensing(links, p, params); + score_sum += sensing; + if sensing >= threshold { + covered += 1; + unc_sum += (var / params.uncertainty_variance_ref_db2).clamp(0.0, 1.0); + } else { + // An uncovered point is maximally uncertain. + unc_sum += 1.0; + } + } + + let score = (score_sum / n).clamp(0.0, 1.0); + let uncertainty = (unc_sum / n).clamp(0.0, 1.0); + let covered_fraction = covered as f64 / n; + let blind_spot = covered_fraction < params.blind_spot_fraction; + + let observability = if score.is_finite() && uncertainty.is_finite() { + Observability::Known { score, uncertainty } + } else { + Observability::Unknown { reason: ObservabilityUnknown::NonFinite } + }; + + TargetCoverage { + target, + phenomenon, + observability, + covered_fraction, + sample_count: samples.len(), + blind_spot, + } +} + +/// Provenance stamped on every placement result (SYNTHETIC/L0). +fn placement_provenance() -> SemanticProvenance { + SemanticProvenance::declared("ruview-placement@0 (SYNTHETIC/L0)") +} + +/// Score a candidate placement against a set of objectives over a floor plan. +/// +/// **SYNTHETIC / L0.** Never panics: malformed geometry or an out-of-plan target +/// yields [`Observability::Unknown`] for that target, not an error. The returned +/// score is `EvidenceLevel::L0` — a recommendation, never a sensing claim. +#[must_use] +pub fn score_placement( + plan: &FloorPlan, + placement: &Placement, + objectives: &[Objective], + params: &PlacementParams, +) -> PlacementScore { + let links = build_links(plan, placement, params); + + let mut per_target = Vec::with_capacity(objectives.len()); + let mut blind_spots = Vec::new(); + let mut weighted_score = 0.0_f64; + let mut weight = 0.0_f64; + + for objective in objectives { + let cov = score_target(plan, &links, objective, params); + if let Observability::Known { score, .. } = cov.observability { + let w = cov.sample_count as f64; + weighted_score += score * w; + weight += w; + } + if cov.blind_spot { + blind_spots.push(cov.target.clone()); + } + per_target.push(cov); + } + + let total_score = if weight > 0.0 { weighted_score / weight } else { 0.0 }; + + PlacementScore { + total_score, + per_target, + blind_spots, + node_count: placement.radios.len(), + evidence_level: EvidenceLevel::L0, + provenance: placement_provenance(), + } +} + +/// A placement paired with its computed score and its position in the input list. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RankedPlacement { + /// Index of this placement in the input slice. + pub index: usize, + /// The placement. + pub placement: Placement, + /// Its score. + pub score: PlacementScore, +} + +/// Rank candidate placements by modelled total score, highest first. +/// +/// Deterministic: ties break by ascending input index, so the same inputs always +/// yield the same ranking. SYNTHETIC/L0. +#[must_use] +pub fn rank_placements( + plan: &FloorPlan, + placements: &[Placement], + objectives: &[Objective], + params: &PlacementParams, +) -> Vec { + let mut ranked: Vec = placements + .iter() + .enumerate() + .map(|(index, placement)| RankedPlacement { + index, + placement: placement.clone(), + score: score_placement(plan, placement, objectives, params), + }) + .collect(); + ranked.sort_by(|x, y| { + y.score + .total_score + .partial_cmp(&x.score.total_score) + .unwrap_or(std::cmp::Ordering::Equal) + .then(x.index.cmp(&y.index)) + }); + ranked +} diff --git a/v2/crates/ruview-placement/src/fresnel.rs b/v2/crates/ruview-placement/src/fresnel.rs new file mode 100644 index 00000000..c7b89f78 --- /dev/null +++ b/v2/crates/ruview-placement/src/fresnel.rs @@ -0,0 +1,118 @@ +//! Fresnel-zone geometry for link observability (ADR-308 §2). +//! +//! **SYNTHETIC / L0.** WiFi sensing perturbs a link when the target sits inside +//! the link's first Fresnel zone. This module implements that geometry as a +//! deliberately simple, documented analytic model — the first Fresnel radius and +//! a clearance factor for a point relative to a link line — not real RF and not a +//! measurement. Everything is deterministic and allocation-free. + +/// First Fresnel-zone radius (metres) at a point that splits the path into +/// longitudinal legs `d1` and `d2`: +/// +/// `F1 = sqrt(λ · d1 · d2 / (d1 + d2))`. +/// +/// Returns `0.0` for non-finite or non-physical inputs (never `NaN`/`inf`); the +/// caller treats a zero radius as "no clearance information", not a divide-by-zero. +/// At the midpoint (`d1 == d2 == L/2`) this reduces to `0.5·sqrt(λ·L)`, the +/// known analytic maximum used in tests. +#[must_use] +pub fn fresnel_radius(wavelength_m: f64, d1: f64, d2: f64) -> f64 { + if !(wavelength_m.is_finite() && d1.is_finite() && d2.is_finite()) { + return 0.0; + } + let sum = d1 + d2; + if wavelength_m <= 0.0 || d1 < 0.0 || d2 < 0.0 || sum <= 0.0 { + return 0.0; + } + let r = wavelength_m * d1 * d2 / sum; + if r.is_finite() && r >= 0.0 { + r.sqrt() + } else { + 0.0 + } +} + +/// Clearance factor in `[0, 1]` for point `p` relative to the link line `a → b`, +/// at wavelength `λ`. +/// +/// - `1.0` on the link line, falling linearly to `0.0` at the first Fresnel-zone +/// boundary and `0.0` beyond it. +/// - `0.0` when `p` does not project *between* the endpoints (a target off the +/// ends of a link is not in its sensing corridor). +/// - `0.0` for a degenerate (coincident-endpoint) link. +/// +/// SYNTHETIC geometry, not an RF measurement. Deterministic. +#[must_use] +pub fn link_clearance(a: (f64, f64), b: (f64, f64), p: (f64, f64), wavelength_m: f64) -> f64 { + let abx = b.0 - a.0; + let aby = b.1 - a.1; + let len2 = abx * abx + aby * aby; + if !len2.is_finite() || len2 <= 1e-12 { + return 0.0; + } + let apx = p.0 - a.0; + let apy = p.1 - a.1; + let t = (apx * abx + apy * aby) / len2; + if !t.is_finite() || !(0.0..=1.0).contains(&t) { + return 0.0; + } + let len = len2.sqrt(); + let d1 = t * len; + let d2 = (1.0 - t) * len; + + // Perpendicular distance from p to the foot of the projection on the line. + let foot_x = a.0 + t * abx; + let foot_y = a.1 + t * aby; + let h = ((p.0 - foot_x).powi(2) + (p.1 - foot_y).powi(2)).sqrt(); + + let f1 = fresnel_radius(wavelength_m, d1, d2); + if !f1.is_finite() || f1 <= 0.0 || !h.is_finite() { + return 0.0; + } + let clearance = 1.0 - h / f1; + if clearance.is_finite() { + clearance.clamp(0.0, 1.0) + } else { + 0.0 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fresnel_radius_matches_analytic_midpoint() { + // Midpoint of a path of length L: F1 = 0.5·sqrt(λ·L). + let wavelength = 0.125_f64; // ~2.4 GHz + let l = 4.0_f64; + let (d1, d2) = (l / 2.0, l / 2.0); + let expected = 0.5 * (wavelength * l).sqrt(); + assert!((fresnel_radius(wavelength, d1, d2) - expected).abs() < 1e-12); + } + + #[test] + fn fresnel_radius_is_zero_for_non_physical_input() { + assert_eq!(fresnel_radius(f64::NAN, 1.0, 1.0), 0.0); + assert_eq!(fresnel_radius(-1.0, 1.0, 1.0), 0.0); + assert_eq!(fresnel_radius(0.125, 0.0, 0.0), 0.0); + assert_eq!(fresnel_radius(0.125, -1.0, 2.0), 0.0); + } + + #[test] + fn clearance_is_one_on_the_line_and_zero_off_the_ends() { + let a = (0.0, 0.0); + let b = (4.0, 0.0); + // On the line at the midpoint. + assert!((link_clearance(a, b, (2.0, 0.0), 0.125) - 1.0).abs() < 1e-12); + // Off the end of the segment: no clearance. + assert_eq!(link_clearance(a, b, (5.0, 0.0), 0.125), 0.0); + // Far off the line (perpendicular ≫ Fresnel radius): no clearance. + assert_eq!(link_clearance(a, b, (2.0, 3.0), 0.125), 0.0); + } + + #[test] + fn degenerate_link_has_zero_clearance() { + assert_eq!(link_clearance((1.0, 1.0), (1.0, 1.0), (1.0, 1.0), 0.125), 0.0); + } +} diff --git a/v2/crates/ruview-placement/src/geometry.rs b/v2/crates/ruview-placement/src/geometry.rs new file mode 100644 index 00000000..f2192cf8 --- /dev/null +++ b/v2/crates/ruview-placement/src/geometry.rs @@ -0,0 +1,225 @@ +//! Coarse 2D floor-plan geometry the optimizer plans over (ADR-308 §1). +//! +//! **SYNTHETIC / L0.** This is a deliberately coarse stand-in for the ADR-306 +//! scene: axis-aligned rectangular [`Rect`] bounds for a [`Space`](ruview_ontology::Space) +//! and its [`Zone`](ruview_ontology::Zone)s, plus attenuating [`Wall`] segments +//! (reused from the twin). It is a *model* of a room, never a surveyed floor +//! plan, and it makes no measurement or accuracy claim. Geometry references the +//! canonical ontology vocabulary ([`SpaceId`], [`ZoneId`], [`Container`]); it +//! does not invent a second identity scheme (ADR-300 rule 3). + +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use ruview_ontology::{Container, SpaceId, ZoneId}; +use ruview_twin::Wall; + +/// Upper bound on wall/reflector segments accepted in one floor plan. Bounds +/// allocation on untrusted input. +pub const MAX_PLAN_WALLS: usize = 4096; + +/// Upper bound on zones accepted in one floor plan. +pub const MAX_ZONES: usize = 1024; + +/// An axis-aligned rectangle in the deployment's local metric frame (metres). +/// A coarse abstraction of a room/zone footprint, not a surveyed boundary. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct Rect { + /// Minimum x (east), metres. + pub min_x: f64, + /// Minimum y (north), metres. + pub min_y: f64, + /// Maximum x (east), metres. + pub max_x: f64, + /// Maximum y (north), metres. + pub max_y: f64, +} + +impl Rect { + /// Construct a rectangle. Validity is checked separately with [`Self::is_valid`]. + #[must_use] + pub const fn new(min_x: f64, min_y: f64, max_x: f64, max_y: f64) -> Self { + Self { min_x, min_y, max_x, max_y } + } + + /// True when every coordinate is finite and the rectangle is non-degenerate + /// (`min < max` on both axes). Rejects `NaN`/`inf`/inverted rectangles at the + /// boundary. + #[must_use] + pub fn is_valid(&self) -> bool { + self.min_x.is_finite() + && self.min_y.is_finite() + && self.max_x.is_finite() + && self.max_y.is_finite() + && self.max_x > self.min_x + && self.max_y > self.min_y + } + + /// Width (x extent), metres. + #[must_use] + pub fn width(&self) -> f64 { + self.max_x - self.min_x + } + + /// Height (y extent), metres. + #[must_use] + pub fn height(&self) -> f64 { + self.max_y - self.min_y + } + + /// Centre point `(x, y)`. + #[must_use] + pub fn center(&self) -> (f64, f64) { + ((self.min_x + self.max_x) / 2.0, (self.min_y + self.max_y) / 2.0) + } +} + +/// A zone footprint within a floor plan's space. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct ZoneGeometry { + /// Ontology zone id (ADR-306). + pub id: ZoneId, + /// The zone's rectangular footprint. + pub bounds: Rect, +} + +/// A coarse floor plan: one space footprint, its zones, and attenuating walls. +/// +/// **SYNTHETIC / L0.** A model of the physical scene the optimizer plans over; +/// not a measurement. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct FloorPlan { + /// Ontology space this plan describes (geometry reference, not a copy). + pub space: SpaceId, + /// The space's rectangular footprint. + pub bounds: Rect, + /// Attenuating wall/reflector segments (reused twin geometry). + pub walls: Vec, + /// Zone footprints within the space. + pub zones: Vec, +} + +impl FloorPlan { + /// Validate the plan at the boundary. Never panics; returns a typed error for + /// non-finite/inverted rectangles, non-finite walls, or over-limit counts. + pub fn validate(&self) -> Result<(), PlacementError> { + if !self.bounds.is_valid() { + return Err(PlacementError::InvalidRect { what: "space bounds" }); + } + if self.walls.len() > MAX_PLAN_WALLS { + return Err(PlacementError::TooManyWalls { + len: self.walls.len(), + max: MAX_PLAN_WALLS, + }); + } + if self.zones.len() > MAX_ZONES { + return Err(PlacementError::TooManyZones { + len: self.zones.len(), + max: MAX_ZONES, + }); + } + for wall in &self.walls { + if !wall.is_finite() { + return Err(PlacementError::NonFinite { what: "wall coordinate" }); + } + } + for zone in &self.zones { + if !zone.bounds.is_valid() { + return Err(PlacementError::InvalidRect { what: "zone bounds" }); + } + } + Ok(()) + } + + /// Resolve the rectangular region a [`Container`] targets, if present in this + /// plan. A first-class `None` (never an error) when the target is not in the + /// plan (ADR-300 rule 1 — the caller surfaces it as UNKNOWN). + #[must_use] + pub fn region_for(&self, target: &Container) -> Option { + match target { + Container::Space { id } if id == &self.space => Some(self.bounds), + Container::Space { .. } => None, + Container::Zone { id } => self + .zones + .iter() + .find(|z| &z.id == id) + .map(|z| z.bounds), + } + } +} + +/// Deterministic grid of sample points inside `rect`, at `step` metres, capped at +/// `max_points`. Points are cell centres; a rectangle smaller than one step still +/// yields its centre. No randomness; identical inputs give identical points. +#[must_use] +pub fn sample_points(rect: &Rect, step: f64, max_points: usize) -> Vec<(f64, f64)> { + if !rect.is_valid() || !(step.is_finite() && step > 0.0) || max_points == 0 { + return Vec::new(); + } + let mut out = Vec::new(); + let mut y = rect.min_y + step / 2.0; + while y < rect.max_y { + let mut x = rect.min_x + step / 2.0; + while x < rect.max_x { + if out.len() >= max_points { + return out; + } + out.push((x, y)); + x += step; + } + y += step; + } + if out.is_empty() { + // Rectangle narrower than one step on an axis: fall back to its centre. + out.push(rect.center()); + } + out +} + +/// Boundary errors from validating placement inputs. Malformed input yields one of +/// these; it never panics. +#[derive(Clone, Debug, PartialEq, Eq, Error)] +pub enum PlacementError { + /// A coordinate or parameter was non-finite (`NaN`/`inf`). + #[error("non-finite value: {what}")] + NonFinite { + /// What was non-finite. + what: &'static str, + }, + /// A rectangle was degenerate or inverted (`min >= max`). + #[error("invalid rectangle: {what}")] + InvalidRect { + /// Which rectangle. + what: &'static str, + }, + /// More walls than [`MAX_PLAN_WALLS`]. + #[error("too many walls: {len} exceeds maximum {max}")] + TooManyWalls { + /// Actual count. + len: usize, + /// The enforced maximum. + max: usize, + }, + /// More zones than [`MAX_ZONES`]. + #[error("too many zones: {len} exceeds maximum {max}")] + TooManyZones { + /// Actual count. + len: usize, + /// The enforced maximum. + max: usize, + }, + /// More radios than the inventory limit. + #[error("too many radios: {len} exceeds maximum {max}")] + TooManyRadios { + /// Actual count. + len: usize, + /// The enforced maximum. + max: usize, + }, + /// A model parameter was out of its valid domain. + #[error("invalid parameter: {what}")] + InvalidParameter { + /// Human-readable reason. + what: &'static str, + }, +} diff --git a/v2/crates/ruview-placement/src/inventory.rs b/v2/crates/ruview-placement/src/inventory.rs new file mode 100644 index 00000000..e66c3ca6 --- /dev/null +++ b/v2/crates/ruview-placement/src/inventory.rs @@ -0,0 +1,87 @@ +//! Hardware inventory: the radios available to place (ADR-308 §1). +//! +//! **SYNTHETIC / L0.** A coarse description of available hardware — each entry is +//! one physical radio the installer can place, with a modelled transmit power and +//! a capability-envelope label. It bounds the *count* of nodes the optimizer may +//! recommend; the scene bounds their geometry. No measurement claim. + +use serde::{Deserialize, Serialize}; + +use crate::geometry::PlacementError; + +/// Upper bound on radios accepted in one inventory. Bounds allocation and the +/// search space on untrusted input. +pub const MAX_INVENTORY: usize = 64; + +/// One available radio. The `model` is a coarse capability-envelope label +/// (e.g. `"esp32-s3"`, `"mmwave"`); this crate does not interpret it beyond +/// carrying it through so a recommendation names the hardware it plans for. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RadioSpec { + /// Coarse hardware/capability label (ADR-318/ADR-320 descriptor handle). + pub model: String, + /// Modelled transmit power, dBm. A SYNTHETIC parameter of the forward model. + pub tx_power_dbm: f64, +} + +impl RadioSpec { + /// Construct a radio spec. + #[must_use] + pub fn new(model: impl Into, tx_power_dbm: f64) -> Self { + Self { model: model.into(), tx_power_dbm } + } +} + +/// The set of radios available to place. Its length bounds the recommended node +/// count. +#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)] +pub struct Inventory { + /// Available radios, one entry per placeable unit. + pub radios: Vec, +} + +impl Inventory { + /// An empty inventory. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// A homogeneous inventory of `count` radios of one `model`/power. Deterministic. + #[must_use] + pub fn homogeneous(model: impl Into, tx_power_dbm: f64, count: usize) -> Self { + let model = model.into(); + let radios = (0..count) + .map(|_| RadioSpec::new(model.clone(), tx_power_dbm)) + .collect(); + Self { radios } + } + + /// Number of placeable radios. + #[must_use] + pub fn len(&self) -> usize { + self.radios.len() + } + + /// True when there is nothing to place. + #[must_use] + pub fn is_empty(&self) -> bool { + self.radios.is_empty() + } + + /// Validate the inventory at the boundary: bounded count, finite powers. + pub fn validate(&self) -> Result<(), PlacementError> { + if self.radios.len() > MAX_INVENTORY { + return Err(PlacementError::TooManyRadios { + len: self.radios.len(), + max: MAX_INVENTORY, + }); + } + for r in &self.radios { + if !r.tx_power_dbm.is_finite() { + return Err(PlacementError::NonFinite { what: "tx_power_dbm" }); + } + } + Ok(()) + } +} diff --git a/v2/crates/ruview-placement/src/lib.rs b/v2/crates/ruview-placement/src/lib.rs new file mode 100644 index 00000000..be1a5bc8 --- /dev/null +++ b/v2/crates/ruview-placement/src/lib.rs @@ -0,0 +1,554 @@ +//! # `ruview-placement` — sensor placement optimizer (ADR-308, ADR-300 phase 3) +//! +//! **SYNTHETIC / L0 — a planning scaffold, not a measurement system.** +//! +//! This crate is a *research-forward primitive*: given a coarse floor plan +//! ([`FloorPlan`], an ADR-306 scene abstraction) and a hardware [`Inventory`], it +//! recommends radio-node positions by scoring candidate placements against the +//! ADR-315 RF twin's **SYNTHETIC** propagation model. Every coverage and +//! observability value it produces is a *simulation* at evidence level `L0` +//! (ADR-282), labelled `SYNTHETIC`: a **recommendation**, never a sensing claim. +//! Nothing here is a hardware, `MEASURED`, or accuracy claim, and the crate +//! asserts **no** coverage or accuracy number — a twin/optimizer *predicts*, it +//! does not *measure* (ADR-308 evidence discipline). +//! +//! Consistent with ADR-300 rule 1, *insufficient information* is a first-class +//! value ([`Observability::Unknown`]), never an error and never a confident +//! default. Consistent with rule 3, the crate reuses the canonical ontology +//! vocabulary ([`SpaceId`], [`ZoneId`], [`SensorId`], [`Container`], +//! [`EvidenceLevel`], [`SemanticProvenance`]) rather than inventing its own. +//! +//! ## What the optimizer does +//! +//! - **Predict** ([`score_placement`]): for each objective ([`Objective`]) it +//! samples the target region and scores modelled observability from +//! Fresnel-zone clearance ([`crate::fresnel`]) over well-predicted twin links, +//! reporting both a score **and** its uncertainty per target, and flagging +//! blind spots. +//! - **Search** ([`optimize`]): greedy forward selection over a **seeded** +//! candidate grid recommends a [`PlacementPlan`]; adding a radio can only +//! maintain or raise the modelled score, so the plan's score trace is +//! monotonically non-decreasing and plateaus at saturation. +//! - **Rank** ([`rank_placements`]): score and order supplied candidate +//! placements, highest modelled observability first. +//! - **Post-install compare** ([`compare_post_install`]): compare the optimizer's +//! `L0` prediction against **caller-supplied** measured observability (this +//! crate never measures) and recommend adjustments plus a coarse twin-parameter +//! residual to feed back into ADR-315. +//! +//! ## Determinism +//! +//! Everything is deterministic. Synthetic scenes are varied by an explicit +//! [`seed`](PlacementParams::seed); there is no wall-clock, no unseeded +//! randomness, and no I/O anywhere in the crate. Allocation is bounded at every +//! boundary ([`MAX_PLAN_WALLS`], [`MAX_ZONES`], [`MAX_INVENTORY`], +//! [`PlacementParams::max_sample_points`], [`PlacementParams::max_candidates`]), +//! and malformed input yields a typed [`PlacementError`] or a first-class +//! `Unknown`, never a panic. +//! +//! ``` +//! use ruview_placement::*; +//! +//! // A reproducible SYNTHETIC scene, an inventory, and one objective. +//! let plan = synthetic_floorplan(7); +//! let inventory = Inventory::homogeneous("esp32-s3", 20.0, 4); +//! let objectives = vec![synthetic_objective(&plan)]; +//! let params = PlacementParams::default_synthetic(); +//! +//! plan.validate().unwrap(); +//! inventory.validate().unwrap(); +//! +//! let recommended = optimize(&plan, &inventory, &objectives, ¶ms); +//! assert_eq!(recommended.evidence_level, EvidenceLevel::L0); // SYNTHETIC +//! // The greedy score trace never decreases. +//! for w in recommended.score_trace.windows(2) { +//! assert!(w[1] + 1e-9 >= w[0]); +//! } +//! ``` + +#![forbid(unsafe_code)] +#![warn(missing_docs)] + +mod compare; +mod coverage; +mod fresnel; +mod geometry; +mod inventory; +mod plan; + +pub use compare::{ + compare_post_install, compare_post_install_with_tolerance, Adjustment, AdjustmentAction, + AdjustmentReport, CompareVerdict, MeasuredObservability, MeasuredTarget, ResidualKind, + TargetComparison, TwinResidual, DEFAULT_COMPARE_TOLERANCE, +}; +pub use coverage::{ + rank_placements, score_placement, Objective, Observability, ObservabilityUnknown, Phenomenon, + PlacedRadio, Placement, PlacementParams, PlacementScore, RankedPlacement, TargetCoverage, +}; +pub use fresnel::{fresnel_radius, link_clearance}; +pub use geometry::{ + sample_points, FloorPlan, PlacementError, Rect, ZoneGeometry, MAX_PLAN_WALLS, MAX_ZONES, +}; +pub use inventory::{Inventory, RadioSpec, MAX_INVENTORY}; +pub use plan::{candidate_positions, optimize, PlacementPlan}; + +// Re-export the canonical ontology and twin vocabulary consumers need, so they +// speak one semantics (ADR-300 rule 3). +pub use ruview_ontology::{ + Container, EvidenceLevel, SemanticProvenance, SensorId, SpaceId, ZoneId, +}; +pub use ruview_twin::{Point3, PropagationParams, Wall}; + +/// Build a deterministic **SYNTHETIC** floor plan from an explicit `seed`. +/// +/// A `5 m × 4 m` space with one interior wall and two zones (a central "core" +/// zone straddling the room and a "corner" zone in the far top-right). Zone +/// footprints are jittered by a seeded `splitmix64` stream so distinct seeds give +/// distinct-but-reproducible scenes; the same seed always yields the same scene. +/// This is a simulation fixture, not a model of any real room. +#[must_use] +pub fn synthetic_floorplan(seed: u64) -> FloorPlan { + let mut state = seed; + // Deterministic jitter helper in [-0.25, 0.25] metres. + let mut jitter = || (splitmix64_unit(&mut state) - 0.5) * 0.5; + + let jx = jitter(); + let jy = jitter(); + + let space = SpaceId::new(format!("space-{seed}")).expect("static id is valid"); + let bounds = Rect::new(0.0, 0.0, 5.0, 4.0); + + let walls = vec![Wall { + id: "interior-wall".to_string(), + a: (2.5, 3.0), + b: (2.5, 4.0), + attenuation_db: 6.0, + }]; + + let core = ZoneGeometry { + id: ZoneId::new(format!("core-{seed}")).expect("static id is valid"), + // A band across the middle of the room where links crisscross. + bounds: Rect::new( + (1.5 + jx).clamp(0.5, 2.0), + (1.5 + jy).clamp(0.5, 2.0), + 3.5, + 2.5, + ), + }; + let corner = ZoneGeometry { + id: ZoneId::new(format!("corner-{seed}")).expect("static id is valid"), + // A far top-right pocket, easy to leave as a blind spot. + bounds: Rect::new(4.0, 3.2, 4.9, 3.9), + }; + + FloorPlan { + space, + bounds, + walls, + zones: vec![core, corner], + } +} + +/// A default presence objective on the synthetic plan's central "core" zone. +#[must_use] +pub fn synthetic_objective(plan: &FloorPlan) -> Objective { + let target = plan + .zones + .first() + .map(|z| Container::Zone { id: z.id.clone() }) + .unwrap_or(Container::Space { id: plan.space.clone() }); + Objective::new(target, Phenomenon::Presence) +} + +/// One `splitmix64` step mapped to a unit `f64` in `[0, 1)`. Deterministic; the +/// only source of scene variation in the fixtures (varied by explicit seed). +fn splitmix64_unit(state: &mut u64) -> f64 { + *state = state.wrapping_add(0x9E37_79B9_7F4A_7C15); + let mut z = *state; + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + z ^= z >> 31; + ((z >> 11) as f64) / ((1u64 << 53) as f64) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn params() -> PlacementParams { + PlacementParams::default_synthetic() + } + + /// Two radios placed on the bottom wall of a 4×4 room; links run along `y≈0`. + fn bottom_pair_plan() -> FloorPlan { + FloorPlan { + space: SpaceId::new("room").unwrap(), + bounds: Rect::new(0.0, 0.0, 4.0, 4.0), + walls: Vec::new(), + zones: vec![ + // A zone straddling the link line — should be covered. + ZoneGeometry { + id: ZoneId::new("on-line").unwrap(), + bounds: Rect::new(1.0, 0.0, 3.0, 0.3), + }, + // A far top zone away from the link line — a blind spot. + ZoneGeometry { + id: ZoneId::new("far-top").unwrap(), + bounds: Rect::new(1.0, 3.0, 3.0, 4.0), + }, + ], + } + } + + fn bottom_pair_placement() -> Placement { + Placement { + radios: vec![ + PlacedRadio::new(Point3::new(0.2, 0.1, 1.0), 20.0), + PlacedRadio::new(Point3::new(3.8, 0.1, 1.0), 20.0), + ], + } + } + + #[test] + fn better_covering_placement_ranks_higher() { + let plan = bottom_pair_plan(); + let objectives = vec![Objective::new( + Container::Zone { id: ZoneId::new("on-line").unwrap() }, + Phenomenon::Presence, + )]; + let p = params(); + + // Good: radios straddle the zone so the link's Fresnel zone crosses it. + let good = bottom_pair_placement(); + // Bad: both radios clustered in the far corner, link far from the zone. + let bad = Placement { + radios: vec![ + PlacedRadio::new(Point3::new(0.1, 3.8, 1.0), 20.0), + PlacedRadio::new(Point3::new(0.4, 3.9, 1.0), 20.0), + ], + }; + + let ranked = rank_placements(&plan, &[bad.clone(), good.clone()], &objectives, &p); + // The good placement (input index 1) ranks first. + assert_eq!(ranked[0].index, 1); + assert!(ranked[0].score.total_score > ranked[1].score.total_score); + + // And its observability is genuinely higher. + let sg = score_placement(&plan, &good, &objectives, &p); + let sb = score_placement(&plan, &bad, &objectives, &p); + assert!(sg.total_score > sb.total_score); + } + + #[test] + fn blind_spot_zone_is_flagged() { + let plan = bottom_pair_plan(); + let placement = bottom_pair_placement(); + let objectives = vec![ + Objective::new( + Container::Zone { id: ZoneId::new("on-line").unwrap() }, + Phenomenon::Presence, + ), + Objective::new( + Container::Zone { id: ZoneId::new("far-top").unwrap() }, + Phenomenon::Presence, + ), + ]; + let score = score_placement(&plan, &placement, &objectives, ¶ms()); + + assert!(score.has_blind_spot()); + let far = Container::Zone { id: ZoneId::new("far-top").unwrap() }; + assert!(score.blind_spots.contains(&far)); + + // The far-top zone is a blind spot; the on-line zone is not. + let on_line = score + .per_target + .iter() + .find(|t| t.target == Container::Zone { id: ZoneId::new("on-line").unwrap() }) + .unwrap(); + let far_top = score + .per_target + .iter() + .find(|t| t.target == far) + .unwrap(); + assert!(!on_line.blind_spot); + assert!(far_top.blind_spot); + assert!(far_top.covered_fraction < on_line.covered_fraction); + } + + #[test] + fn adding_a_node_improves_score_monotonically_until_saturation() { + let plan = bottom_pair_plan(); + let objectives = vec![Objective::new( + Container::Zone { id: ZoneId::new("on-line").unwrap() }, + Phenomenon::Presence, + )]; + let p = params(); + + // Incrementally add radios along the bottom wall. + let positions = [ + Point3::new(0.2, 0.1, 1.0), + Point3::new(3.8, 0.1, 1.0), + Point3::new(2.0, 0.1, 1.0), + Point3::new(1.0, 0.1, 1.0), + Point3::new(3.0, 0.1, 1.0), + ]; + let mut radios = Vec::new(); + let mut prev = -1.0_f64; + let mut scores = Vec::new(); + for pos in positions { + radios.push(PlacedRadio::new(pos, 20.0)); + let s = score_placement(&plan, &Placement { radios: radios.clone() }, &objectives, &p) + .total_score; + // Monotone non-decreasing at every step. + assert!(s + 1e-9 >= prev, "score decreased: {prev} -> {s}"); + prev = s; + scores.push(s); + } + + // It strictly improved at least once early on... + assert!(scores[1] > scores[0]); + // ...and saturates: a later step adds (near-)nothing. + let last = scores.len() - 1; + assert!((scores[last] - scores[last - 1]).abs() < 1e-6); + + // The greedy optimizer's own trace is also non-decreasing. + let inv = Inventory::homogeneous("esp32-s3", 20.0, 5); + let recommended = optimize(&plan, &inv, &objectives, &p); + for w in recommended.score_trace.windows(2) { + assert!(w[1] + 1e-9 >= w[0]); + } + } + + #[test] + fn optimize_reports_uncertainty_and_never_a_bare_number() { + let plan = synthetic_floorplan(3); + let inv = Inventory::homogeneous("esp32-s3", 20.0, 4); + let objectives = vec![synthetic_objective(&plan)]; + let recommended = optimize(&plan, &inv, &objectives, ¶ms()); + + // Every known target carries BOTH a score and an uncertainty (ADR-308 §2). + let mut saw_known = false; + for t in &recommended.score.per_target { + if let Observability::Known { score, uncertainty } = t.observability { + saw_known = true; + assert!((0.0..=1.0).contains(&score)); + assert!((0.0..=1.0).contains(&uncertainty)); + } + } + assert!(saw_known); + assert_eq!(recommended.evidence_level, EvidenceLevel::L0); + } + + #[test] + fn predicted_vs_observed_delta_yields_adjustment_suggestion() { + let plan = bottom_pair_plan(); + let placement = bottom_pair_placement(); + let objectives = vec![Objective::new( + Container::Zone { id: ZoneId::new("on-line").unwrap() }, + Phenomenon::Presence, + )]; + let predicted = score_placement(&plan, &placement, &objectives, ¶ms()); + + let target = Container::Zone { id: ZoneId::new("on-line").unwrap() }; + let (pred_score, _) = predicted + .per_target + .iter() + .find(|t| t.target == target) + .unwrap() + .observability + .known() + .expect("predicted score known"); + + // Caller supplies a much *lower* measured observability than predicted. + let measured = MeasuredObservability::new().with(target.clone(), (pred_score - 0.6).max(0.0)); + let report = compare_post_install(&predicted, &measured); + + assert_eq!(report.predicted_evidence_level, EvidenceLevel::L0); + assert!(report.needs_adjustment()); + let adj = report.adjustments.iter().find(|a| a.target == target).unwrap(); + assert!(matches!( + adj.action, + AdjustmentAction::AddNode | AdjustmentAction::MoveNode | AdjustmentAction::ReAim + )); + // The residual points the twin at higher effective attenuation. + let residual = adj.twin_residual.expect("residual suggested"); + assert_eq!(residual.kind, ResidualKind::EffectiveAttenuationHigher); + assert!(residual.magnitude_db > 0.0); + + let cmp = report.comparisons.iter().find(|c| c.target == target).unwrap(); + assert_eq!(cmp.verdict, CompareVerdict::Underperforming); + + // A missing measurement is first-class UNKNOWN, not an error. + let empty = MeasuredObservability::new(); + let report2 = compare_post_install(&predicted, &empty); + let cmp2 = report2.comparisons.iter().find(|c| c.target == target).unwrap(); + assert_eq!(cmp2.verdict, CompareVerdict::Unknown); + assert!(!report2.needs_adjustment()); + } + + #[test] + fn matching_observation_needs_no_adjustment() { + let plan = bottom_pair_plan(); + let placement = bottom_pair_placement(); + let objectives = vec![Objective::new( + Container::Zone { id: ZoneId::new("on-line").unwrap() }, + Phenomenon::Presence, + )]; + let predicted = score_placement(&plan, &placement, &objectives, ¶ms()); + let target = Container::Zone { id: ZoneId::new("on-line").unwrap() }; + let (pred_score, _) = predicted.per_target[0].observability.known().unwrap(); + + // Measured equals predicted: verdict Match, no corrective action. + let measured = MeasuredObservability::new().with(target.clone(), pred_score); + let report = compare_post_install(&predicted, &measured); + let cmp = report.comparisons.iter().find(|c| c.target == target).unwrap(); + assert_eq!(cmp.verdict, CompareVerdict::Match); + assert!(!report.needs_adjustment()); + } + + #[test] + fn optimize_is_deterministic_and_seed_varies_the_scene() { + let inv = Inventory::homogeneous("esp32-s3", 20.0, 4); + let p = params(); + + // Same seed ⇒ identical plan (bit-for-bit via serde). + let plan_a = synthetic_floorplan(11); + let objectives_a = vec![synthetic_objective(&plan_a)]; + let r1 = optimize(&plan_a, &inv, &objectives_a, &p); + let r2 = optimize(&plan_a, &inv, &objectives_a, &p); + assert_eq!(r1, r2); + assert_eq!( + serde_json::to_string(&r1).unwrap(), + serde_json::to_string(&r2).unwrap() + ); + + // Distinct seeds give distinct-but-reproducible scenes. + let plan_b = synthetic_floorplan(12); + assert_ne!(plan_a, plan_b); + + // Candidate generation is seeded and deterministic. + let c1 = candidate_positions(&plan_a, &p); + let c2 = candidate_positions(&plan_a, &p); + assert_eq!(c1, c2); + let mut p_seeded = p; + p_seeded.seed = p.seed.wrapping_add(1); + let c3 = candidate_positions(&plan_a, &p_seeded); + assert_ne!(c1, c3); // a different seed shifts the grid + } + + #[test] + fn serde_round_trip_is_lossless_and_labels_evidence() { + let plan = synthetic_floorplan(1); + let inv = Inventory::homogeneous("esp32-s3", 20.0, 3); + let objectives = vec![synthetic_objective(&plan)]; + let recommended = optimize(&plan, &inv, &objectives, ¶ms()); + + let json = serde_json::to_string_pretty(&recommended).unwrap(); + let back: PlacementPlan = serde_json::from_str(&json).unwrap(); + assert_eq!(recommended, back); + // Evidence discipline is on the wire: L0 / SYNTHETIC. + assert!(json.contains("\"evidence_level\": \"L0\"")); + } + + #[test] + fn boundary_validation_rejects_malformed_input_without_panic() { + // Inverted rectangle. + let mut plan = synthetic_floorplan(1); + plan.bounds = Rect::new(5.0, 4.0, 0.0, 0.0); + assert!(matches!(plan.validate(), Err(PlacementError::InvalidRect { .. }))); + + // Non-finite wall coordinate. + let mut plan = synthetic_floorplan(1); + plan.walls[0].a.0 = f64::NAN; + assert!(matches!(plan.validate(), Err(PlacementError::NonFinite { .. }))); + + // Too many radios. + let over = Inventory::homogeneous("x", 20.0, MAX_INVENTORY + 1); + assert!(matches!(over.validate(), Err(PlacementError::TooManyRadios { .. }))); + + // Non-finite tx power. + let bad_inv = Inventory { radios: vec![RadioSpec::new("x", f64::INFINITY)] }; + assert!(matches!(bad_inv.validate(), Err(PlacementError::NonFinite { .. }))); + + // Invalid parameter. + let mut bad_params = params(); + bad_params.wavelength_m = 0.0; + assert!(matches!(bad_params.validate(), Err(PlacementError::InvalidParameter { .. }))); + + // Objective targeting a container not in the plan ⇒ first-class UNKNOWN, + // never a panic or error. + let plan = synthetic_floorplan(1); + let placement = Placement { + radios: vec![ + PlacedRadio::new(Point3::new(0.5, 0.5, 1.0), 20.0), + PlacedRadio::new(Point3::new(4.5, 3.5, 1.0), 20.0), + ], + }; + let ghost = Objective::new( + Container::Zone { id: ZoneId::new("ghost-zone").unwrap() }, + Phenomenon::Presence, + ); + let score = score_placement(&plan, &placement, &[ghost], ¶ms()); + assert!(matches!( + score.per_target[0].observability, + Observability::Unknown { reason: ObservabilityUnknown::TargetNotInPlan } + )); + + // A non-finite placement position is skipped, never panics: with fewer + // than two finite nodes there are no links, so the target is UNKNOWN. + let nan_placement = Placement { + radios: vec![PlacedRadio::new(Point3::new(f64::NAN, 0.0, 1.0), 20.0)], + }; + let objectives = vec![synthetic_objective(&plan)]; + let score = score_placement(&plan, &nan_placement, &objectives, ¶ms()); + assert!(matches!( + score.per_target[0].observability, + Observability::Unknown { reason: ObservabilityUnknown::NoLinks } + )); + } + + #[test] + fn marginal_case_reports_higher_uncertainty() { + // A zone squarely on the link line vs. a marginal one at the Fresnel edge: + // the marginal case is reported with higher uncertainty (ADR-308 §2, the + // model reports uncertainty rather than overstating a coarse result). + let plan = FloorPlan { + space: SpaceId::new("room").unwrap(), + bounds: Rect::new(0.0, 0.0, 4.0, 4.0), + walls: Vec::new(), + zones: vec![ + ZoneGeometry { + id: ZoneId::new("on-line").unwrap(), + bounds: Rect::new(1.0, 0.0, 3.0, 0.2), + }, + ZoneGeometry { + id: ZoneId::new("marginal").unwrap(), + bounds: Rect::new(1.0, 1.5, 3.0, 1.9), + }, + ], + }; + let placement = bottom_pair_placement(); + let p = params(); + let on_line = score_placement( + &plan, + &placement, + &[Objective::new( + Container::Zone { id: ZoneId::new("on-line").unwrap() }, + Phenomenon::Presence, + )], + &p, + ); + let marginal = score_placement( + &plan, + &placement, + &[Objective::new( + Container::Zone { id: ZoneId::new("marginal").unwrap() }, + Phenomenon::Presence, + )], + &p, + ); + let (_, u_on) = on_line.per_target[0].observability.known().unwrap(); + let (_, u_marg) = marginal.per_target[0].observability.known().unwrap(); + assert!(u_marg > u_on, "marginal uncertainty {u_marg} should exceed on-line {u_on}"); + } +} diff --git a/v2/crates/ruview-placement/src/plan.rs b/v2/crates/ruview-placement/src/plan.rs new file mode 100644 index 00000000..8792fb84 --- /dev/null +++ b/v2/crates/ruview-placement/src/plan.rs @@ -0,0 +1,163 @@ +//! Deterministic placement search: floor plan + inventory → recommended plan +//! (ADR-308 §2). +//! +//! **SYNTHETIC / L0.** The search consumes the SYNTHETIC coverage model in +//! [`crate::coverage`] and recommends radio positions that maximise modelled +//! objective observability subject to the inventory count and the scene geometry. +//! It is a *recommendation*, never a guarantee that a room is sensed (ADR-308 +//! consequences). Determinism is total: candidate positions come from a seeded +//! grid ([`PlacementParams::seed`]) with **no RNG and no wall-clock**; greedy +//! forward selection then adds the best candidate one radio at a time. Because +//! point observability is a *max over links*, adding a radio can only maintain or +//! raise the score — so the recorded [`PlacementPlan::score_trace`] is +//! monotonically non-decreasing and plateaus at saturation. + +use serde::{Deserialize, Serialize}; + +use ruview_ontology::{EvidenceLevel, SemanticProvenance}; +use ruview_twin::Point3; + +use crate::coverage::{ + score_placement, Objective, Placement, PlacedRadio, PlacementParams, PlacementScore, +}; +use crate::geometry::FloorPlan; +use crate::inventory::Inventory; + +/// A recommended placement plan. +/// +/// **SYNTHETIC / L0.** Carries the chosen [`Placement`], its [`PlacementScore`], +/// and the monotonic score trace of the greedy search (one entry per radio +/// added). A recommendation, never a sensing claim. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct PlacementPlan { + /// The recommended radio positions. + pub placement: Placement, + /// The score of the recommended placement. + pub score: PlacementScore, + /// Total score after each radio was added, in order — non-decreasing. + pub score_trace: Vec, + /// Number of candidate positions the search considered. + pub candidate_count: usize, + /// Evidence level of this plan. Always `L0` (SYNTHETIC). + pub evidence_level: EvidenceLevel, + /// Provenance travelling with the plan. + pub provenance: SemanticProvenance, +} + +/// One `splitmix64` step mapped to `[0, 1)`. Deterministic; the only source of +/// candidate-grid variation in this crate (varied by an explicit seed, never RNG). +fn splitmix64_unit(state: &mut u64) -> f64 { + *state = state.wrapping_add(0x9E37_79B9_7F4A_7C15); + let mut z = *state; + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + z ^= z >> 31; + ((z >> 11) as f64) / ((1u64 << 53) as f64) +} + +/// Generate the deterministic candidate-position grid over the plan bounds. +/// +/// A seeded sub-step offset varies the grid reproducibly between seeds; the same +/// seed always yields the same candidates. Bounded by `params.max_candidates`. +#[must_use] +pub fn candidate_positions(plan: &FloorPlan, params: &PlacementParams) -> Vec { + if !plan.bounds.is_valid() || !(params.candidate_step_m.is_finite() && params.candidate_step_m > 0.0) + { + return Vec::new(); + } + let step = params.candidate_step_m; + let mut state = params.seed; + // Seeded offsets in [0, step) so distinct seeds shift the grid deterministically. + let ox = splitmix64_unit(&mut state) * step; + let oy = splitmix64_unit(&mut state) * step; + + let mut out = Vec::new(); + let mut y = plan.bounds.min_y + step / 2.0 + oy; + // Keep the first row inside the room if the offset pushed it past the far edge. + if y >= plan.bounds.max_y { + y = plan.bounds.center().1; + } + while y < plan.bounds.max_y { + let mut x = plan.bounds.min_x + step / 2.0 + ox; + if x >= plan.bounds.max_x { + x = plan.bounds.center().0; + } + while x < plan.bounds.max_x { + if out.len() >= params.max_candidates { + return out; + } + out.push(Point3::new(x, y, 1.0)); + x += step; + } + y += step; + } + if out.is_empty() { + let (cx, cy) = plan.bounds.center(); + out.push(Point3::new(cx, cy, 1.0)); + } + out +} + +/// Improvement below this counts as no gain (tie), so ties break deterministically +/// to the first (lowest-index) candidate. +const IMPROVEMENT_EPS: f64 = 1e-9; + +/// Optimise a placement: greedily add radios from the inventory to maximise +/// modelled objective observability over the floor plan. +/// +/// **SYNTHETIC / L0.** Deterministic and never panics. The number of radios is +/// bounded by the inventory; positions come from the seeded candidate grid. The +/// returned [`PlacementPlan::score_trace`] is non-decreasing by construction. +#[must_use] +pub fn optimize( + plan: &FloorPlan, + inventory: &Inventory, + objectives: &[Objective], + params: &PlacementParams, +) -> PlacementPlan { + let candidates = candidate_positions(plan, params); + let mut chosen: Vec = Vec::new(); + let mut trace: Vec = Vec::new(); + + for spec in &inventory.radios { + let tx = spec.tx_power_dbm; + let mut best_index: Option = None; + let mut best_score = f64::NEG_INFINITY; + + for (ci, cand) in candidates.iter().enumerate() { + // Skip a position already chosen (a duplicate adds no link geometry). + if chosen.iter().any(|r| r.position == *cand) { + continue; + } + let mut trial = chosen.clone(); + trial.push(PlacedRadio::new(*cand, tx)); + let s = score_placement(plan, &Placement { radios: trial }, objectives, params) + .total_score; + if s > best_score + IMPROVEMENT_EPS { + best_score = s; + best_index = Some(ci); + } + } + + match best_index { + Some(ci) => { + chosen.push(PlacedRadio::new(candidates[ci], tx)); + trace.push(best_score.max(0.0)); + } + // No usable candidate remained (e.g. all positions taken); stop. + None => break, + } + } + + let placement = Placement { radios: chosen }; + let score = score_placement(plan, &placement, objectives, params); + + PlacementPlan { + placement, + score, + score_trace: trace, + candidate_count: candidates.len(), + evidence_level: EvidenceLevel::L0, + provenance: SemanticProvenance::declared("ruview-placement@0 (SYNTHETIC/L0)"), + } +} diff --git a/v2/crates/ruview-policy/Cargo.toml b/v2/crates/ruview-policy/Cargo.toml new file mode 100644 index 00000000..a93c4d97 --- /dev/null +++ b/v2/crates/ruview-policy/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "ruview-policy" +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-evidence = { path = "../ruview-evidence" } + +[dev-dependencies] +serde_json.workspace = true diff --git a/v2/crates/ruview-policy/src/lib.rs b/v2/crates/ruview-policy/src/lib.rs new file mode 100644 index 00000000..1d000b7c --- /dev/null +++ b/v2/crates/ruview-policy/src/lib.rs @@ -0,0 +1,753 @@ +//! # `ruview-policy` — action authorization gate (ADR-321, ADR-300 phase 1) +//! +//! A capability certificate (ADR-318) 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. This crate is the authorization +//! layer that sits between governed spatial state and any actuator: given the +//! assurance an action demands and the live assurance actually available, it +//! returns [`Authorization::Allow`] or a **fail-closed** +//! [`Authorization::Deny`] that names the *specific* condition that failed. +//! +//! ## The four non-negotiable rules (ADR-300) +//! +//! - **UNKNOWN is a first-class value, never an error.** An UNKNOWN domain +//! ([`DomainState::Unknown`]) does not raise — it *denies* high-assurance +//! actions. It may still authorize a [`ActionClass::Convenience`] action if +//! that class does not require a known domain, but the resulting +//! [`Authorization::Allow`] *records* that it proceeded under UNKNOWN +//! (`under_unknown_domain`). +//! - **Staleness guard `VALID → DEGRADED → UNKNOWN`.** A safety- or +//! security-class action requires the live domain signature (ADR-302) to be +//! `KNOWN`; a `DEGRADED` domain denies with [`FailedCondition::DomainDegraded`] +//! and an `UNKNOWN` domain denies with [`FailedCondition::DomainNotKnown`]. +//! - **Honesty / no silent optimism.** A missing or expired certificate, a +//! certificate class below the floor, an over-ceiling uncertainty, an +//! evidence level below the floor, or the *absence of any policy* all deny by +//! default. Absence of a policy is not permission. No accuracy is claimed +//! here; the crate ships the gating machinery only, and its test fixtures are +//! SYNTHETIC / L0. +//! +//! The decision is a **pure function** of (action class, assurance inputs): +//! deterministic, clock-free (time is pre-reduced by the caller into a bool + +//! an age), free of randomness, bounded in allocation, and panic-free on +//! malformed input (a `NaN` uncertainty fails closed rather than aborting). +//! +//! ## Adapter note — real certificate + OOD domain state → [`AssuranceInputs`] +//! +//! To stay parallel-buildable this crate does **not** depend on the concrete +//! `ruview-certify` / `ruview-ood` types; it owns [`AssuranceInputs`]. A caller +//! that *does* hold those types maps them on as follows: +//! +//! - `certificate_valid` ← the certificate's **time + signature** validity +//! only: `cert.verify(key) && now < content.valid_until_unix_s`. Note this is +//! deliberately *not* `CapabilityCertificate::is_valid`, which also folds the +//! live domain in — the domain gate is applied *separately* by this policy so +//! that an out-of-domain deny is attributed to the domain condition +//! ([`FailedCondition::DomainNotKnown`]) rather than being hidden inside a +//! generic "certificate invalid". +//! - `certificate_age` ← `now - content.calibrated_date_unix_s`, clamped at 0. +//! - `certificate_class` ← the ADR-318 assurance tier the certificate was +//! minted at (derived by the caller from the certificate's evidence floor and +//! validated capability); see [`CertificateClass`]. +//! - `domain_state` ← `ruview_ood::DomainState`: `Known → `[`DomainState::Known`], +//! `Degraded(_) → `[`DomainState::Degraded`], `Unknown(_) → `[`DomainState::Unknown`]. +//! - `uncertainty` ← the model head's live predictive uncertainty (ADR-302/301). +//! - `evidence_level` ← the certificate's [`EvidenceLevel`] (ADR-282/301). +//! +//! Every allow or deny is intended to be emitted as the terminal stage of the +//! witness chain (ADR-319); this crate returns the decision, the caller records +//! it. + +#![forbid(unsafe_code)] + +use ruview_evidence::EvidenceLevel; +use serde::{Deserialize, Serialize}; + +// --------------------------------------------------------------------------- +// Value types owned by this crate +// --------------------------------------------------------------------------- + +/// The assurance tier a certificate was minted at (ADR-318). Ordering is +/// meaningful and load-bearing: an action declares a +/// [`AssuranceRequirements::min_certificate_class`] and a certificate at a +/// class strictly below that floor is rejected. `Basic < Standard < High`. +/// +/// This is a policy-side ladder: the ADR-318 certificate binds a capability and +/// an evidence level, and the adapter (see crate docs) derives the class from +/// them. Keeping the ladder local lets the policy crate build in parallel with +/// the certificate crate. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum CertificateClass { + /// Convenience-grade attestation: adequate to gate low-stakes actions. + Basic, + /// Security-grade attestation: bounded uncertainty, held-out evidence. + Standard, + /// Safety-grade attestation: the strictest tier, for actuators whose + /// failure is unsafe. + High, +} + +/// Local, simplified mirror of the ADR-302 domain signature. The concrete +/// `ruview_ood::DomainState` carries a `DomainCause`; this policy only needs +/// the three-way outcome, so the cause is dropped at the adapter boundary (see +/// crate docs). `Known` is the only state that satisfies a "requires known +/// domain" action. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum DomainState { + /// The live situation is recognized: inside the calibrated domain (ADR-302). + Known, + /// Drift/quality has crossed the inner envelope — degraded but not lost. + Degraded, + /// The situation is not recognized (ADR-302). A first-class value, never an + /// error; it *denies* high-assurance actions rather than guessing. + Unknown, +} + +impl DomainState { + /// `true` only for [`DomainState::Known`]. + #[must_use] + pub const fn is_known(self) -> bool { + matches!(self, DomainState::Known) + } + + /// `true` only for [`DomainState::Unknown`]. + #[must_use] + pub const fn is_unknown(self) -> bool { + matches!(self, DomainState::Unknown) + } +} + +/// The live assurance actually available at the moment of the decision. Owned +/// by this crate so it does not depend on the concrete certificate / OOD types +/// (see the crate-level adapter note for the mapping). +/// +/// Time is pre-reduced by the caller: `certificate_valid` is the injected +/// time+signature validity and `certificate_age_secs` the injected age. This +/// keeps [`authorize`] a pure, clock-free function. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct AssuranceInputs { + /// The certificate's assurance tier (ADR-318), derived by the adapter. + pub certificate_class: CertificateClass, + /// Whether the certificate is currently signed and unexpired (time + + /// signature validity **only** — the domain gate is applied separately). + /// `false` covers both a *missing* and an *expired* certificate: absence is + /// not permission. + pub certificate_valid: bool, + /// Age of the certificate's calibration, in seconds (`now - calibrated_date`). + pub certificate_age_secs: u64, + /// The live domain signature (ADR-302), reduced to three states. + pub domain_state: DomainState, + /// The model head's live predictive uncertainty, in `[0.0, 1.0]`. A `NaN` + /// or out-of-range value is treated as over any ceiling (fail-closed). + pub uncertainty: f64, + /// The evidence floor backing this inference (ADR-282/301). + pub evidence_level: EvidenceLevel, +} + +/// The assurance an [`ActionClass`] demands (ADR-321 §1). Every field is a +/// gate; an input that fails any one denies. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct AssuranceRequirements { + /// The certificate must be at least this class. + pub min_certificate_class: CertificateClass, + /// The certificate calibration must be no older than this (freshness). + pub max_certificate_age_secs: u64, + /// Inference uncertainty must not exceed this ceiling. + pub max_uncertainty: f64, + /// The evidence level must be at least this floor. + pub min_evidence_level: EvidenceLevel, + /// Whether the live domain must be [`DomainState::Known`]. When `true`, a + /// `Degraded`/`Unknown` domain denies (the staleness guard). When `false`, + /// an `Unknown` domain is allowed but recorded on the [`Authorization`]. + pub requires_domain_known: bool, +} + +/// The class of action being authorized (ADR-321 §1). Each class declares the +/// assurance it demands via [`ActionClass::requirements`]. The classes are +/// reference defaults — illustrative and, in a fuller system, configurable. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum ActionClass { + /// Lighting, scenes: tolerant — `Basic`+, higher uncertainty ok, does not + /// require a known domain (but records an UNKNOWN proceed). + Convenience, + /// Alerts, arming: stricter — valid `Standard`+ cert, bounded uncertainty, + /// requires a known domain. + Security, + /// Door lock, machine stop: strict — fresh `High` cert, low uncertainty, + /// `L3`+ evidence, known domain only. + SafetyCritical, +} + +/// One day / one week / thirty days in seconds, for the reference freshness +/// ceilings below. +const ONE_DAY_SECS: u64 = 86_400; +const ONE_WEEK_SECS: u64 = 7 * ONE_DAY_SECS; +const THIRTY_DAYS_SECS: u64 = 30 * ONE_DAY_SECS; + +impl ActionClass { + /// The reference assurance requirements for this class (ADR-321 §1 table). + #[must_use] + pub const fn requirements(self) -> AssuranceRequirements { + match self { + ActionClass::Convenience => AssuranceRequirements { + min_certificate_class: CertificateClass::Basic, + max_certificate_age_secs: THIRTY_DAYS_SECS, + max_uncertainty: 0.6, + min_evidence_level: EvidenceLevel::L1, + requires_domain_known: false, + }, + ActionClass::Security => AssuranceRequirements { + min_certificate_class: CertificateClass::Standard, + max_certificate_age_secs: ONE_WEEK_SECS, + max_uncertainty: 0.3, + min_evidence_level: EvidenceLevel::L2, + requires_domain_known: true, + }, + ActionClass::SafetyCritical => AssuranceRequirements { + min_certificate_class: CertificateClass::High, + max_certificate_age_secs: ONE_DAY_SECS, + max_uncertainty: 0.1, + min_evidence_level: EvidenceLevel::L3, + requires_domain_known: true, + }, + } + } +} + +/// The specific condition that caused a [`Authorization::Deny`]. A denial always +/// names exactly one — the *first* unmet condition in the fixed evaluation +/// order — so "why was this actuator denied" is unambiguous. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum FailedCondition { + /// No policy was supplied for the action — an unrecognized action class. + /// Absence of a policy is not permission (ADR-321 §3). + NoPolicy, + /// The certificate is missing or expired (`certificate_valid == false`). + CertificateInvalid, + /// The certificate's class is below the action's floor. + CertificateClassTooLow { + /// The floor the action requires. + required: CertificateClass, + /// The class actually presented. + actual: CertificateClass, + }, + /// The certificate calibration is older than the freshness ceiling. + CertificateStale { + /// Actual age, seconds. + age_secs: u64, + /// Maximum permitted age, seconds. + max_secs: u64, + }, + /// The action requires a known domain and the live domain is `DEGRADED`. + DomainDegraded, + /// The action requires a known domain and the live domain is `UNKNOWN` + /// (ADR-300 acceptance test: drift-invalidated capability denied at the + /// actuator). This is the canonical `domain_not_known` failure. + DomainNotKnown, + /// Inference uncertainty exceeds the ceiling (a `NaN` lands here too). + UncertaintyOverCeiling { + /// The ceiling the action requires; the actual value is elided because + /// `f64` is not `Eq`/`Hash`-friendly across the wire, but the ceiling + /// names the boundary that was crossed. + max_uncertainty: f64, + }, + /// The evidence level is below the action's floor. + EvidenceBelowFloor { + /// The floor the action requires. + required: EvidenceLevel, + /// The level actually backing the inference. + actual: EvidenceLevel, + }, +} + +impl FailedCondition { + /// A stable, lower-snake-case name for the condition. Useful for witness + /// records and log lines; the acceptance test asserts the SafetyCritical + /// drift case names `domain_not_known`. + #[must_use] + pub const fn name(self) -> &'static str { + match self { + FailedCondition::NoPolicy => "no_policy", + FailedCondition::CertificateInvalid => "certificate_invalid", + FailedCondition::CertificateClassTooLow { .. } => "certificate_class_too_low", + FailedCondition::CertificateStale { .. } => "certificate_stale", + FailedCondition::DomainDegraded => "domain_degraded", + FailedCondition::DomainNotKnown => "domain_not_known", + FailedCondition::UncertaintyOverCeiling { .. } => "uncertainty_over_ceiling", + FailedCondition::EvidenceBelowFloor { .. } => "evidence_below_floor", + } + } +} + +/// The authorization decision (ADR-321 §2). Fail-closed: anything that is not an +/// [`Authorization::Allow`] is a deny that names its condition. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Authorization { + /// The action is authorized. `under_unknown_domain` is `true` only when a + /// class that does *not* require a known domain (e.g. + /// [`ActionClass::Convenience`]) was allowed while the domain was `UNKNOWN` + /// — the allow is honest about having proceeded out-of-domain. + Allow { + /// Records that the allow proceeded while the domain was `UNKNOWN`. + under_unknown_domain: bool, + }, + /// The action is denied; `failed_condition` names the specific unmet gate. + Deny { + /// The first unmet condition in evaluation order. + failed_condition: FailedCondition, + }, +} + +impl Authorization { + /// `true` only for [`Authorization::Allow`]. + #[must_use] + pub const fn is_allowed(self) -> bool { + matches!(self, Authorization::Allow { .. }) + } + + /// The failed condition, if this is a deny. + #[must_use] + pub const fn failed_condition(self) -> Option { + match self { + Authorization::Deny { failed_condition } => Some(failed_condition), + Authorization::Allow { .. } => None, + } + } +} + +// --------------------------------------------------------------------------- +// The decision +// --------------------------------------------------------------------------- + +/// Authorize an action of `class` against the live `inputs` (ADR-321 §2). +/// +/// A **pure**, fail-closed function of `(class, inputs)`: deterministic, no +/// clock, no randomness, no panics. It applies the class's reference +/// [`AssuranceRequirements`]; use [`authorize_with`] to supply custom +/// requirements or to model an unrecognized action (a `None` policy denies). +#[must_use] +pub fn authorize(class: ActionClass, inputs: &AssuranceInputs) -> Authorization { + authorize_with(Some(&class.requirements()), inputs) +} + +/// Authorize against an explicit, optional policy. `None` means *no policy was +/// found for this action* — an unrecognized action class — and denies with +/// [`FailedCondition::NoPolicy`] (absence of a policy is not permission, +/// ADR-321 §3). +/// +/// Evaluation order (the first unmet condition is the one named): +/// 1. policy present, +/// 2. certificate valid (present + unexpired), +/// 3. certificate class ≥ floor, +/// 4. certificate age ≤ freshness ceiling, +/// 5. domain gate (when the class requires a known domain), +/// 6. uncertainty ≤ ceiling, +/// 7. evidence ≥ floor. +#[must_use] +pub fn authorize_with( + requirements: Option<&AssuranceRequirements>, + inputs: &AssuranceInputs, +) -> Authorization { + let req = match requirements { + Some(req) => req, + None => { + return Authorization::Deny { + failed_condition: FailedCondition::NoPolicy, + } + } + }; + + // 2. A missing or expired certificate denies by default. + if !inputs.certificate_valid { + return Authorization::Deny { + failed_condition: FailedCondition::CertificateInvalid, + }; + } + + // 3. Certificate class must meet the floor. + if inputs.certificate_class < req.min_certificate_class { + return Authorization::Deny { + failed_condition: FailedCondition::CertificateClassTooLow { + required: req.min_certificate_class, + actual: inputs.certificate_class, + }, + }; + } + + // 4. Freshness / staleness ceiling on certificate age. + if inputs.certificate_age_secs > req.max_certificate_age_secs { + return Authorization::Deny { + failed_condition: FailedCondition::CertificateStale { + age_secs: inputs.certificate_age_secs, + max_secs: req.max_certificate_age_secs, + }, + }; + } + + // 5. Domain gate. A class that requires a known domain denies on + // DEGRADED/UNKNOWN, naming the specific state. + if req.requires_domain_known { + match inputs.domain_state { + DomainState::Known => {} + DomainState::Degraded => { + return Authorization::Deny { + failed_condition: FailedCondition::DomainDegraded, + } + } + DomainState::Unknown => { + return Authorization::Deny { + failed_condition: FailedCondition::DomainNotKnown, + } + } + } + } + + // 6. Uncertainty ceiling. `!(<=)` catches NaN too, failing closed. + if !(inputs.uncertainty <= req.max_uncertainty) { + return Authorization::Deny { + failed_condition: FailedCondition::UncertaintyOverCeiling { + max_uncertainty: req.max_uncertainty, + }, + }; + } + + // 7. Evidence floor. + if inputs.evidence_level < req.min_evidence_level { + return Authorization::Deny { + failed_condition: FailedCondition::EvidenceBelowFloor { + required: req.min_evidence_level, + actual: inputs.evidence_level, + }, + }; + } + + // All gates passed. Record if we proceeded under an UNKNOWN domain (only + // reachable for a class that does not require a known domain). + Authorization::Allow { + under_unknown_domain: inputs.domain_state.is_unknown(), + } +} + +// --------------------------------------------------------------------------- +// Tests — all fixtures are SYNTHETIC / L0 (CLAUDE.md honesty rule). +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + /// A baseline SYNTHETIC input that *passes* every gate for `class`. Tests + /// then mutate exactly one field to force a specific deny. + fn passing(class: ActionClass) -> AssuranceInputs { + let req = class.requirements(); + AssuranceInputs { + certificate_class: req.min_certificate_class, + certificate_valid: true, + certificate_age_secs: 0, + domain_state: DomainState::Known, + uncertainty: req.max_uncertainty, // exactly at ceiling → allowed + evidence_level: req.min_evidence_level, // exactly at floor → allowed + } + } + + #[test] + fn baseline_passes_for_every_class() { + for class in [ + ActionClass::Convenience, + ActionClass::Security, + ActionClass::SafetyCritical, + ] { + assert_eq!( + authorize(class, &passing(class)), + Authorization::Allow { + under_unknown_domain: false + }, + "baseline should allow {class:?}", + ); + } + } + + #[test] + fn absence_of_policy_denies() { + let inputs = passing(ActionClass::SafetyCritical); + assert_eq!( + authorize_with(None, &inputs), + Authorization::Deny { + failed_condition: FailedCondition::NoPolicy + }, + ); + } + + #[test] + fn missing_or_expired_certificate_denies() { + let mut inputs = passing(ActionClass::Convenience); + inputs.certificate_valid = false; + assert_eq!( + authorize(ActionClass::Convenience, &inputs).failed_condition(), + Some(FailedCondition::CertificateInvalid), + ); + } + + #[test] + fn certificate_class_too_low_denies() { + let mut inputs = passing(ActionClass::SafetyCritical); + inputs.certificate_class = CertificateClass::Basic; + assert_eq!( + authorize(ActionClass::SafetyCritical, &inputs).failed_condition(), + Some(FailedCondition::CertificateClassTooLow { + required: CertificateClass::High, + actual: CertificateClass::Basic, + }), + ); + } + + #[test] + fn stale_certificate_denies() { + let mut inputs = passing(ActionClass::SafetyCritical); + inputs.certificate_age_secs = ONE_DAY_SECS + 1; + assert_eq!( + authorize(ActionClass::SafetyCritical, &inputs).failed_condition(), + Some(FailedCondition::CertificateStale { + age_secs: ONE_DAY_SECS + 1, + max_secs: ONE_DAY_SECS, + }), + ); + } + + #[test] + fn uncertainty_over_ceiling_denies() { + let mut inputs = passing(ActionClass::SafetyCritical); + inputs.uncertainty = 0.1 + 1e-6; // just above the 0.1 ceiling + match authorize(ActionClass::SafetyCritical, &inputs).failed_condition() { + Some(FailedCondition::UncertaintyOverCeiling { .. }) => {} + other => panic!("expected uncertainty deny, got {other:?}"), + } + } + + #[test] + fn nan_uncertainty_fails_closed() { + let mut inputs = passing(ActionClass::Convenience); + inputs.uncertainty = f64::NAN; + match authorize(ActionClass::Convenience, &inputs).failed_condition() { + Some(FailedCondition::UncertaintyOverCeiling { .. }) => {} + other => panic!("NaN uncertainty must fail closed, got {other:?}"), + } + } + + #[test] + fn evidence_below_floor_denies() { + let mut inputs = passing(ActionClass::SafetyCritical); + inputs.evidence_level = EvidenceLevel::L2; // floor is L3 + assert_eq!( + authorize(ActionClass::SafetyCritical, &inputs).failed_condition(), + Some(FailedCondition::EvidenceBelowFloor { + required: EvidenceLevel::L3, + actual: EvidenceLevel::L2, + }), + ); + } + + #[test] + fn unknown_domain_denies_security() { + let mut inputs = passing(ActionClass::Security); + inputs.domain_state = DomainState::Unknown; + assert_eq!( + authorize(ActionClass::Security, &inputs).failed_condition(), + Some(FailedCondition::DomainNotKnown), + ); + } + + #[test] + fn unknown_domain_denies_safety_critical() { + let mut inputs = passing(ActionClass::SafetyCritical); + inputs.domain_state = DomainState::Unknown; + assert_eq!( + authorize(ActionClass::SafetyCritical, &inputs).failed_condition(), + Some(FailedCondition::DomainNotKnown), + ); + } + + #[test] + fn degraded_domain_denies_high_assurance_with_its_own_condition() { + let mut inputs = passing(ActionClass::SafetyCritical); + inputs.domain_state = DomainState::Degraded; + assert_eq!( + authorize(ActionClass::SafetyCritical, &inputs).failed_condition(), + Some(FailedCondition::DomainDegraded), + ); + } + + #[test] + fn convenience_may_proceed_under_unknown_but_records_it() { + let mut inputs = passing(ActionClass::Convenience); + inputs.domain_state = DomainState::Unknown; + assert_eq!( + authorize(ActionClass::Convenience, &inputs), + Authorization::Allow { + under_unknown_domain: true + }, + ); + + // Degraded convenience is allowed and is not "under unknown". + inputs.domain_state = DomainState::Degraded; + assert_eq!( + authorize(ActionClass::Convenience, &inputs), + Authorization::Allow { + under_unknown_domain: false + }, + ); + } + + /// ADR-300 / ADR-321 acceptance-test B: a post-drift UNKNOWN domain causes a + /// `SafetyCritical` authorize() to Deny with `domain_not_known`, *before* + /// the inference reaches the actuator. The certificate is otherwise valid + /// (signed, unexpired, correct class, fresh) — the domain gate is what + /// stops it. + #[test] + fn acceptance_test_b_post_drift_unknown_denies_safety_critical() { + // Pre-drift: domain KNOWN → the safety-critical action is authorized. + let mut inputs = passing(ActionClass::SafetyCritical); + assert!(authorize(ActionClass::SafetyCritical, &inputs).is_allowed()); + + // Drift drives the domain to UNKNOWN (ADR-302 VALID→DEGRADED→UNKNOWN). + inputs.domain_state = DomainState::Unknown; + let decision = authorize(ActionClass::SafetyCritical, &inputs); + + assert_eq!( + decision, + Authorization::Deny { + failed_condition: FailedCondition::DomainNotKnown + }, + ); + assert_eq!( + decision.failed_condition().map(FailedCondition::name), + Some("domain_not_known"), + ); + } + + #[test] + fn every_deny_names_a_condition() { + // Force a deny in each class and assert the decision carries a named + // condition (never a bare/empty deny). + let cases = [ + (ActionClass::Convenience, { + let mut i = passing(ActionClass::Convenience); + i.certificate_valid = false; + i + }), + (ActionClass::Security, { + let mut i = passing(ActionClass::Security); + i.domain_state = DomainState::Unknown; + i + }), + (ActionClass::SafetyCritical, { + let mut i = passing(ActionClass::SafetyCritical); + i.evidence_level = EvidenceLevel::L0; + i + }), + ]; + for (class, inputs) in cases { + let decision = authorize(class, &inputs); + let cond = decision + .failed_condition() + .expect("deny must name a condition"); + assert!( + !cond.name().is_empty(), + "{class:?} deny must have a non-empty condition name", + ); + } + } + + #[test] + fn decision_is_deterministic() { + let inputs = passing(ActionClass::SafetyCritical); + let first = authorize(ActionClass::SafetyCritical, &inputs); + for _ in 0..1_000 { + assert_eq!(authorize(ActionClass::SafetyCritical, &inputs), first); + } + } + + /// The full authorization matrix: + /// (cert valid / invalid) × (age fresh / stale) × (Known/Degraded/Unknown) + /// × (uncertainty below / above ceiling) × (evidence above / below floor). + /// Asserts the outcome and, for every deny, that a condition is named. + #[test] + fn full_matrix() { + for class in [ + ActionClass::Convenience, + ActionClass::Security, + ActionClass::SafetyCritical, + ] { + let req = class.requirements(); + for cert_valid in [true, false] { + for age in [0u64, req.max_certificate_age_secs + 1] { + for domain in [ + DomainState::Known, + DomainState::Degraded, + DomainState::Unknown, + ] { + // "below ceiling" = ceiling itself (allowed, since <=); + // "above ceiling" = ceiling + a hair. + for &unc in &[req.max_uncertainty, req.max_uncertainty + 0.01] { + for evidence in [req.min_evidence_level, EvidenceLevel::L0] { + let inputs = AssuranceInputs { + certificate_class: req.min_certificate_class, + certificate_valid: cert_valid, + certificate_age_secs: age, + domain_state: domain, + uncertainty: unc, + evidence_level: evidence, + }; + let decision = authorize(class, &inputs); + + // Compute the expected outcome independently. + let unc_ok = unc <= req.max_uncertainty; + let evidence_ok = evidence >= req.min_evidence_level; + let age_ok = age <= req.max_certificate_age_secs; + let domain_ok = !req.requires_domain_known || domain.is_known(); + let should_allow = + cert_valid && age_ok && domain_ok && unc_ok && evidence_ok; + + if should_allow { + let under_unknown = !req.requires_domain_known + && domain == DomainState::Unknown; + assert_eq!( + decision, + Authorization::Allow { + under_unknown_domain: under_unknown + }, + "class {class:?} inputs {inputs:?}", + ); + } else { + assert!( + !decision.is_allowed(), + "class {class:?} inputs {inputs:?} should deny", + ); + assert!( + decision.failed_condition().is_some(), + "deny must name a condition for {inputs:?}", + ); + } + } + } + } + } + } + } + } + + #[test] + fn serde_round_trips_the_decision() { + let mut inputs = passing(ActionClass::SafetyCritical); + inputs.domain_state = DomainState::Unknown; + let decision = authorize(ActionClass::SafetyCritical, &inputs); + let json = serde_json::to_string(&decision).expect("serialize"); + let back: Authorization = serde_json::from_str(&json).expect("deserialize"); + assert_eq!(decision, back); + } +} diff --git a/v2/crates/ruview-scorecard/Cargo.toml b/v2/crates/ruview-scorecard/Cargo.toml new file mode 100644 index 00000000..cef6c0f5 --- /dev/null +++ b/v2/crates/ruview-scorecard/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "ruview-scorecard" +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-evidence = { path = "../ruview-evidence" } + +[dev-dependencies] +serde_json.workspace = true diff --git a/v2/crates/ruview-scorecard/src/lib.rs b/v2/crates/ruview-scorecard/src/lib.rs new file mode 100644 index 00000000..7ff1a635 --- /dev/null +++ b/v2/crates/ruview-scorecard/src/lib.rs @@ -0,0 +1,1154 @@ +//! # `ruview-scorecard` — the multi-domain benchmark scorecard (ADR-317, ADR-300 §4) +//! +//! ADR-300 program rule 4 is non-negotiable: **pooled accuracy is never +//! sufficient for promotion**. A single headline number is exactly the surface +//! a domain-generalization regression hides behind — a model can raise mean +//! presence accuracy while quietly collapsing on unseen rooms, unseen devices, +//! or a stationary subject at range (the canonical WiFi failure case). +//! +//! This crate is the *data model* for that discipline (ADR-317 §1). It holds, +//! per capability, one cell **per operating domain** rather than one pooled +//! figure: +//! +//! - **Presence**: `room-known`, `room-unseen`, `device-unseen`, +//! `stationary-10m`. +//! - **Pose**: `matched`, `subject-unseen`, `room-unseen`. +//! - **OOD rejection**: the rate at which genuinely out-of-distribution input +//! is correctly returned as UNKNOWN (a capability, ADR-302). +//! - **Calibration drift**: the fingerprint-distance trajectory against the +//! ADR-301 certificate (lower is better) plus the fraction of inferences in +//! each ADR-302 [`DomainState`] under the `VALID → DEGRADED → UNKNOWN` +//! staleness guard (ADR-300). +//! +//! ## Honesty by construction (CLAUDE.md, ADR-282, ADR-300) +//! +//! - Every scored [`Cell`] carries a point estimate, a **confidence interval** +//! (a documented deterministic Wilson score interval — no RNG), and exactly +//! one [`EvidenceLevel`]. A slice scored on synthetic input is `L0` by +//! construction ([`Metric::synthetic`]); nothing raises it here. +//! - An empty domain is [`Cell::NoEvidence`], a first-class value distinct +//! from a present-but-zero score. The promotion gate treats no evidence as +//! **no coverage, never a pass** (ADR-317 §Provenance). +//! - [`Scorecard::worst_domain`] returns the promotion-relevant number: the +//! minimum across a task's domain slices. [`promotion_gate`] fails if *any* +//! worst-domain slice regresses beyond its budget, **even when the pooled +//! average improved** — a regression cannot hide behind pooled accuracy. +//! +//! Deterministic and leaf-shaped: no wall clock, no randomness, bounded input +//! validation at every constructor. + +#![forbid(unsafe_code)] + +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +pub use ruview_evidence::EvidenceLevel; + +/// The two-sided z-multiplier for a 95% Wilson score interval (the 97.5th +/// percentile of the standard normal). Fixed and documented so intervals are +/// reproducible byte-for-byte. +pub const Z_95: f64 = 1.959_963_984_540_054; + +// --------------------------------------------------------------------------- +// Errors +// --------------------------------------------------------------------------- + +/// Boundary-validation failures. No constructor panics on malformed input. +#[derive(Clone, Copy, Debug, PartialEq, Error)] +pub enum ScorecardError { + /// A rate/point estimate was not a finite value inside `[0, 1]`. + #[error("point estimate {value} out of range (expected finite in [0, 1])")] + PointOutOfRange { + /// The offending value. + value: f64, + }, + /// A metric was minted with zero samples; a confidence interval needs at + /// least one observation. + #[error("sample_count must be >= 1")] + ZeroSamples, + /// The supplied confidence z-multiplier was not finite and positive. + #[error("confidence z-multiplier {value} must be finite and > 0")] + BadConfidence { + /// The offending value. + value: f64, + }, + /// A [`StateFractions`] triple was out of range or did not sum to ~1. + #[error("domain-state fractions invalid: {reason}")] + BadStateFractions { + /// Human-readable reason. + reason: &'static str, + }, +} + +// --------------------------------------------------------------------------- +// Wilson score interval (deterministic, no RNG) +// --------------------------------------------------------------------------- + +/// Compute the two-sided **Wilson score interval** for a binomial proportion. +/// +/// For an observed proportion `p` over `n` samples at z-multiplier `z`: +/// +/// ```text +/// center = (p + z²/2n) / (1 + z²/n) +/// margin = (z / (1 + z²/n)) · sqrt( p(1-p)/n + z²/4n² ) +/// [lo, hi] = clamp(center ∓ margin, 0, 1) +/// ``` +/// +/// The Wilson interval is preferred over the naive normal approximation +/// `p ± z·sqrt(p(1-p)/n)` because it stays inside `[0, 1]` and behaves well at +/// the `p → 0` / `p → 1` extremes and for small `n` — the regimes a thin +/// unseen-domain slice lives in. Caller guarantees `p ∈ [0,1]`, `n ≥ 1`, and a +/// finite `z > 0`; the result is clamped defensively regardless. +#[must_use] +pub fn wilson_interval(p: f64, n: u64, z: f64) -> (f64, f64) { + let n = n as f64; + let z2 = z * z; + let denom = 1.0 + z2 / n; + let center = (p + z2 / (2.0 * n)) / denom; + let radicand = (p * (1.0 - p) / n) + z2 / (4.0 * n * n); + let margin = (z / denom) * radicand.max(0.0).sqrt(); + let lo = (center - margin).clamp(0.0, 1.0); + let hi = (center + margin).clamp(0.0, 1.0); + (lo, hi) +} + +// --------------------------------------------------------------------------- +// Metric: a scored cell +// --------------------------------------------------------------------------- + +/// A single scored measurement: a point estimate, its confidence interval, the +/// sample count it was computed from, and exactly one [`EvidenceLevel`]. The CI +/// is derived deterministically at construction; there is no setter that could +/// desynchronise the interval from its inputs. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct Metric { + point: f64, + ci_low: f64, + ci_high: f64, + sample_count: u64, + level: EvidenceLevel, +} + +impl Metric { + /// Construct a metric at 95% confidence ([`Z_95`]), computing the Wilson + /// interval from `point` and `sample_count`. + /// + /// # Errors + /// [`ScorecardError::PointOutOfRange`] if `point` is not finite in + /// `[0, 1]`; [`ScorecardError::ZeroSamples`] if `sample_count == 0`. + pub fn new( + point: f64, + sample_count: u64, + level: EvidenceLevel, + ) -> Result { + Self::with_confidence(point, sample_count, level, Z_95) + } + + /// Construct a metric at a caller-chosen z-multiplier. + /// + /// # Errors + /// As [`Metric::new`], plus [`ScorecardError::BadConfidence`] if `z` is not + /// finite and positive. + pub fn with_confidence( + point: f64, + sample_count: u64, + level: EvidenceLevel, + z: f64, + ) -> Result { + if !point.is_finite() || !(0.0..=1.0).contains(&point) { + return Err(ScorecardError::PointOutOfRange { value: point }); + } + if sample_count == 0 { + return Err(ScorecardError::ZeroSamples); + } + if !z.is_finite() || z <= 0.0 { + return Err(ScorecardError::BadConfidence { value: z }); + } + let (ci_low, ci_high) = wilson_interval(point, sample_count, z); + Ok(Self { + point, + ci_low, + ci_high, + sample_count, + level, + }) + } + + /// Construct a **synthetic** metric: the evidence level is forced to `L0` + /// (ADR-282/ADR-300 — synthetic input is `L0` by construction and cannot be + /// raised here). + /// + /// # Errors + /// As [`Metric::new`]. + pub fn synthetic(point: f64, sample_count: u64) -> Result { + Self::new(point, sample_count, EvidenceLevel::L0) + } + + /// The point estimate. + #[must_use] + pub fn point(&self) -> f64 { + self.point + } + + /// The lower confidence bound. + #[must_use] + pub fn ci_low(&self) -> f64 { + self.ci_low + } + + /// The upper confidence bound. + #[must_use] + pub fn ci_high(&self) -> f64 { + self.ci_high + } + + /// The confidence interval as `(low, high)`. + #[must_use] + pub fn ci(&self) -> (f64, f64) { + (self.ci_low, self.ci_high) + } + + /// The number of samples the estimate was computed from. + #[must_use] + pub fn sample_count(&self) -> u64 { + self.sample_count + } + + /// The evidence level travelling with this cell. + #[must_use] + pub fn level(&self) -> EvidenceLevel { + self.level + } +} + +// --------------------------------------------------------------------------- +// Cell: scored or explicitly no-evidence +// --------------------------------------------------------------------------- + +/// One scorecard cell. A domain with no coverage is [`Cell::NoEvidence`] — a +/// first-class value the promotion gate treats as no coverage, never a pass +/// (ADR-317). It is deliberately distinct from a scored cell whose point +/// happens to be `0.0`. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub enum Cell { + /// No coverage for this domain slice. + NoEvidence, + /// A scored measurement. + Scored(Metric), +} + +impl Cell { + /// The point estimate if scored, else `None`. + #[must_use] + pub fn point(&self) -> Option { + match self { + Cell::NoEvidence => None, + Cell::Scored(m) => Some(m.point), + } + } + + /// The evidence level if scored, else `None`. + #[must_use] + pub fn level(&self) -> Option { + match self { + Cell::NoEvidence => None, + Cell::Scored(m) => Some(m.level), + } + } + + /// The scored metric, if any. + #[must_use] + pub fn metric(&self) -> Option { + match self { + Cell::NoEvidence => None, + Cell::Scored(m) => Some(*m), + } + } + + /// Whether this cell carries evidence. + #[must_use] + pub fn has_evidence(&self) -> bool { + matches!(self, Cell::Scored(_)) + } +} + +impl From for Cell { + fn from(m: Metric) -> Self { + Cell::Scored(m) + } +} + +// --------------------------------------------------------------------------- +// Domain-state staleness guard (ADR-302 / ADR-300) +// --------------------------------------------------------------------------- + +/// The ADR-302 domain state under the ADR-300 staleness guard +/// `VALID → DEGRADED → UNKNOWN`. A certificate is conditional on a continuously +/// evaluated domain signature; crossing the OOD threshold degrades the state +/// rather than silently continuing. `Unknown` is a first-class output, not an +/// error (ADR-300 rule 1). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum DomainState { + /// In-distribution; the certificate holds. + Valid, + /// Drifting; the affected capability is degraded pending recalibration. + Degraded, + /// Out of distribution; the surface answers UNKNOWN. + Unknown, +} + +/// The fraction of scored inferences observed in each [`DomainState`] over the +/// scoring window (ADR-317 calibration-drift axis). Fractions are each in +/// `[0, 1]` and sum to ~1. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct StateFractions { + /// Fraction of inferences in `VALID`. + pub valid: f64, + /// Fraction of inferences in `DEGRADED`. + pub degraded: f64, + /// Fraction of inferences in `UNKNOWN`. + pub unknown: f64, +} + +impl StateFractions { + /// Tolerance on the fractions summing to one. + pub const SUM_EPS: f64 = 1e-6; + + /// Validate and construct. Each fraction must be finite in `[0, 1]` and the + /// three must sum to `1 ± [`Self::SUM_EPS`]`. + /// + /// # Errors + /// [`ScorecardError::BadStateFractions`]. + pub fn new(valid: f64, degraded: f64, unknown: f64) -> Result { + for v in [valid, degraded, unknown] { + if !v.is_finite() || !(0.0..=1.0).contains(&v) { + return Err(ScorecardError::BadStateFractions { + reason: "each fraction must be finite in [0, 1]", + }); + } + } + if (valid + degraded + unknown - 1.0).abs() > Self::SUM_EPS { + return Err(ScorecardError::BadStateFractions { + reason: "fractions must sum to 1", + }); + } + Ok(Self { + valid, + degraded, + unknown, + }) + } + + /// The dominant [`DomainState`] under the staleness guard. Monotone, + /// documented thresholds: `UNKNOWN` when a majority of inferences fell out + /// of distribution (`unknown >= 0.5`); otherwise `DEGRADED` when a majority + /// were not `VALID` (`valid < 0.5`); otherwise `VALID`. This mirrors the + /// `VALID → DEGRADED → UNKNOWN` progression: rising OOD mass walks the + /// state strictly downward, never silently back up. + #[must_use] + pub fn dominant(&self) -> DomainState { + if self.unknown >= 0.5 { + DomainState::Unknown + } else if self.valid < 0.5 { + DomainState::Degraded + } else { + DomainState::Valid + } + } +} + +// --------------------------------------------------------------------------- +// Slice identity +// --------------------------------------------------------------------------- + +/// A capability task grouping domain slices. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum Task { + /// Presence detection. + Presence, + /// Pose estimation. + Pose, +} + +/// The identity of a single scorecard cell across every capability and domain. +/// Enumerable ([`SliceId::ALL`]) so `worst_domain`, the gate, and `render` all +/// walk the same canonical order deterministically. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum SliceId { + /// Presence on a room seen during training. + PresenceRoomKnown, + /// Presence on an unseen room (a pooled score hides regressions here). + PresenceRoomUnseen, + /// Presence on unseen device hardware. + PresenceDeviceUnseen, + /// Presence on a stationary subject at ~10 m — the canonical WiFi failure. + PresenceStationary10m, + /// Pose on a matched (in-distribution) split. + PoseMatched, + /// Pose on an unseen subject. + PoseSubjectUnseen, + /// Pose on an unseen room. + PoseRoomUnseen, + /// Rate of correctly rejecting out-of-distribution input as UNKNOWN. + OodRejection, + /// Calibration-drift fingerprint distance against the ADR-301 certificate. + CalibrationDrift, +} + +impl SliceId { + /// Every slice in canonical render/iteration order. + pub const ALL: [SliceId; 9] = [ + SliceId::PresenceRoomKnown, + SliceId::PresenceRoomUnseen, + SliceId::PresenceDeviceUnseen, + SliceId::PresenceStationary10m, + SliceId::PoseMatched, + SliceId::PoseSubjectUnseen, + SliceId::PoseRoomUnseen, + SliceId::OodRejection, + SliceId::CalibrationDrift, + ]; + + /// Human-readable label used by [`Scorecard::render`]. + #[must_use] + pub fn label(self) -> &'static str { + match self { + SliceId::PresenceRoomKnown => "presence/room-known", + SliceId::PresenceRoomUnseen => "presence/room-unseen", + SliceId::PresenceDeviceUnseen => "presence/device-unseen", + SliceId::PresenceStationary10m => "presence/stationary-10m", + SliceId::PoseMatched => "pose/matched", + SliceId::PoseSubjectUnseen => "pose/subject-unseen", + SliceId::PoseRoomUnseen => "pose/room-unseen", + SliceId::OodRejection => "ood-rejection", + SliceId::CalibrationDrift => "calibration-drift", + } + } + + /// The task this slice belongs to, if it is a per-domain accuracy task. + /// OOD rejection and calibration drift are single cells and return `None`. + #[must_use] + pub fn task(self) -> Option { + match self { + SliceId::PresenceRoomKnown + | SliceId::PresenceRoomUnseen + | SliceId::PresenceDeviceUnseen + | SliceId::PresenceStationary10m => Some(Task::Presence), + SliceId::PoseMatched | SliceId::PoseSubjectUnseen | SliceId::PoseRoomUnseen => { + Some(Task::Pose) + } + SliceId::OodRejection | SliceId::CalibrationDrift => None, + } + } + + /// Whether a *higher* point estimate is better. True for every accuracy / + /// rejection slice; false for calibration drift, where a larger + /// fingerprint distance is a regression. + #[must_use] + pub fn higher_is_better(self) -> bool { + !matches!(self, SliceId::CalibrationDrift) + } + + /// Whether this is a strict-budget domain (unseen / stationary / OOD) — + /// the ones a pooled score hides, per ADR-317 §2. + #[must_use] + pub fn is_strict(self) -> bool { + matches!( + self, + SliceId::PresenceRoomUnseen + | SliceId::PresenceDeviceUnseen + | SliceId::PresenceStationary10m + | SliceId::PoseSubjectUnseen + | SliceId::PoseRoomUnseen + | SliceId::OodRejection + ) + } + + /// Whether this slice contributes to the pooled accuracy average (every + /// higher-is-better slice; drift has different units and direction and is + /// excluded). + #[must_use] + fn is_pooled(self) -> bool { + self.higher_is_better() + } +} + +// --------------------------------------------------------------------------- +// Scorecard +// --------------------------------------------------------------------------- + +/// The multi-domain scorecard (ADR-317 §1): one [`Cell`] per operating domain, +/// never pooled into a single figure. Fields are public for direct +/// construction from an evidence query; every cell defaults to +/// [`Cell::NoEvidence`] via [`Scorecard::empty`]. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct Scorecard { + /// Presence on a known room. + pub presence_room_known: Cell, + /// Presence on an unseen room. + pub presence_room_unseen: Cell, + /// Presence on unseen device hardware. + pub presence_device_unseen: Cell, + /// Presence on a stationary subject at ~10 m. + pub presence_stationary_10m: Cell, + /// Pose on a matched split. + pub pose_matched: Cell, + /// Pose on an unseen subject. + pub pose_subject_unseen: Cell, + /// Pose on an unseen room. + pub pose_room_unseen: Cell, + /// OOD-rejection rate. + pub ood_rejection: Cell, + /// Calibration-drift fingerprint distance (lower is better). + pub calibration_drift: Cell, + /// Fraction of inferences per [`DomainState`] over the window, if tracked. + pub state_fractions: Option, +} + +impl Default for Scorecard { + fn default() -> Self { + Self::empty() + } +} + +impl Scorecard { + /// An all-`NoEvidence` scorecard. Fill the cells that have coverage; the + /// rest stay honestly empty. + #[must_use] + pub fn empty() -> Self { + Self { + presence_room_known: Cell::NoEvidence, + presence_room_unseen: Cell::NoEvidence, + presence_device_unseen: Cell::NoEvidence, + presence_stationary_10m: Cell::NoEvidence, + pose_matched: Cell::NoEvidence, + pose_subject_unseen: Cell::NoEvidence, + pose_room_unseen: Cell::NoEvidence, + ood_rejection: Cell::NoEvidence, + calibration_drift: Cell::NoEvidence, + state_fractions: None, + } + } + + /// The cell for a given slice. + #[must_use] + pub fn cell(&self, slice: SliceId) -> Cell { + match slice { + SliceId::PresenceRoomKnown => self.presence_room_known, + SliceId::PresenceRoomUnseen => self.presence_room_unseen, + SliceId::PresenceDeviceUnseen => self.presence_device_unseen, + SliceId::PresenceStationary10m => self.presence_stationary_10m, + SliceId::PoseMatched => self.pose_matched, + SliceId::PoseSubjectUnseen => self.pose_subject_unseen, + SliceId::PoseRoomUnseen => self.pose_room_unseen, + SliceId::OodRejection => self.ood_rejection, + SliceId::CalibrationDrift => self.calibration_drift, + } + } + + /// The dominant [`DomainState`] under the staleness guard, if state + /// fractions were tracked. Absent tracking is `None`, not `Valid` — the + /// scorecard never invents a healthy state it did not observe. + #[must_use] + pub fn domain_state(&self) -> Option { + self.state_fractions.map(|f| f.dominant()) + } + + /// The **worst domain** for a task: the promotion-relevant number + /// (ADR-300 §4). Returns the slice with the lowest point estimate, with a + /// [`Cell::NoEvidence`] slice ranking below any scored cell — an uncovered + /// domain is the worst possible outcome, never silently skipped. + /// + /// Every task in this scorecard is higher-is-better, so "worst" is + /// unambiguously the minimum. Returns the first slice in canonical order on + /// a tie for determinism. + #[must_use] + pub fn worst_domain(&self, task: Task) -> WorstCell { + let mut worst: Option = None; + for slice in SliceId::ALL { + if slice.task() != Some(task) { + continue; + } + let cell = self.cell(slice); + let candidate = WorstCell { slice, cell }; + worst = Some(match worst { + None => candidate, + Some(cur) => { + if candidate.is_worse_than(&cur) { + candidate + } else { + cur + } + } + }); + } + // Every task has at least one member slice, so this is always `Some`. + worst.expect("task has at least one slice") + } + + /// The pooled accuracy average across covered higher-is-better slices — the + /// figure ADR-300 rule 4 forbids relying on alone. Provided precisely so + /// [`promotion_gate`] can prove a regression was *hidden behind* a rising + /// pool. `None` when no such slice is covered. + #[must_use] + pub fn pooled_accuracy(&self) -> Option { + let mut sum = 0.0; + let mut count = 0u64; + for slice in SliceId::ALL { + if !slice.is_pooled() { + continue; + } + if let Some(p) = self.cell(slice).point() { + sum += p; + count += 1; + } + } + if count == 0 { + None + } else { + Some(sum / count as f64) + } + } + + /// Render an ASCII scorecard approximating the ADR-317 layout: one row per + /// domain slice with its point estimate, confidence interval, evidence + /// level, and sample count; the per-task worst domain; the pooled figure + /// (labelled as insufficient on its own); and the domain state. Empty + /// slices print `no-evidence`, never a fabricated number. + #[must_use] + pub fn render(&self) -> String { + let mut out = String::new(); + out.push_str("ADR-317 multi-domain scorecard\n"); + out.push_str( + " (per-domain; pooled accuracy is never sufficient for promotion, ADR-300 §4)\n", + ); + out.push_str( + " slice point ci_low ci_high level samples\n", + ); + out.push_str( + " ------------------------- ------- ------- -------- ------ -------\n", + ); + for slice in SliceId::ALL { + let cell = self.cell(slice); + match cell { + Cell::NoEvidence => { + out.push_str(&format!( + " {:<25} {:>7} {:>7} {:>8} {:>6} {:>7}\n", + slice.label(), + "no-ev", + "-", + "-", + "-", + "0", + )); + } + Cell::Scored(m) => { + out.push_str(&format!( + " {:<25} {:>7.4} {:>7.4} {:>8.4} {:>6} {:>7}\n", + slice.label(), + m.point(), + m.ci_low(), + m.ci_high(), + format!("{:?}", m.level()), + m.sample_count(), + )); + } + } + } + out.push('\n'); + for task in [Task::Presence, Task::Pose] { + let worst = self.worst_domain(task); + let shown = match worst.cell { + Cell::NoEvidence => "no-evidence (no coverage)".to_string(), + Cell::Scored(m) => format!("{:.4}", m.point()), + }; + out.push_str(&format!( + " worst {:<9} -> {} = {}\n", + format!("{:?}", task).to_lowercase(), + worst.slice.label(), + shown, + )); + } + match self.pooled_accuracy() { + Some(p) => out.push_str(&format!( + " pooled accuracy = {:.4} (INSUFFICIENT ALONE — see worst-domain)\n", + p + )), + None => out.push_str(" pooled accuracy = no-evidence\n"), + } + match self.domain_state() { + Some(s) => out.push_str(&format!(" domain state = {:?}\n", s)), + None => out.push_str(" domain state = untracked\n"), + } + out + } +} + +/// The result of [`Scorecard::worst_domain`]: which slice was worst and its +/// cell (which may be [`Cell::NoEvidence`]). +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct WorstCell { + /// The worst slice. + pub slice: SliceId, + /// Its cell. + pub cell: Cell, +} + +impl WorstCell { + /// Order for "worseness": a [`Cell::NoEvidence`] cell is worse than any + /// scored cell; among scored cells a lower point estimate is worse (every + /// task is higher-is-better). + fn is_worse_than(&self, other: &WorstCell) -> bool { + match (self.cell.point(), other.cell.point()) { + (None, None) => false, + (None, Some(_)) => true, + (Some(_), None) => false, + (Some(a), Some(b)) => a < b, + } + } + + /// The worst point estimate, if the worst cell was scored. + #[must_use] + pub fn point(&self) -> Option { + self.cell.point() + } +} + +// --------------------------------------------------------------------------- +// Promotion gate +// --------------------------------------------------------------------------- + +/// Per-capability regression budgets. Strict-budget domains +/// (unseen / stationary / OOD) carry the tightest tolerance because they are +/// the ones a pooled score hides (ADR-317 §2). +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct GatePolicy { + /// Budget for non-strict presence/pose slices (e.g. room-known, matched). + pub base_tolerance: f64, + /// Budget for strict domains (unseen / stationary / OOD). + pub strict_tolerance: f64, + /// Budget for a *rise* in calibration drift before it is a regression. + pub drift_tolerance: f64, +} + +impl Default for GatePolicy { + /// Conservative defaults: a small base budget, a much tighter strict budget + /// for the domains that hide behind a pool, and a small drift budget. + fn default() -> Self { + Self { + base_tolerance: 0.02, + strict_tolerance: 0.005, + drift_tolerance: 0.01, + } + } +} + +impl GatePolicy { + /// The tolerance that applies to a slice. + #[must_use] + pub fn tolerance(&self, slice: SliceId) -> f64 { + if slice == SliceId::CalibrationDrift { + self.drift_tolerance + } else if slice.is_strict() { + self.strict_tolerance + } else { + self.base_tolerance + } + } +} + +/// Why a slice regressed. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub enum RegressionKind { + /// A higher-is-better point estimate dropped beyond tolerance. + AccuracyDrop, + /// Calibration drift rose beyond tolerance. + DriftIncrease, + /// A previously-covered domain lost all evidence — no coverage is never a + /// pass (ADR-317 §Provenance). + CoverageLoss, +} + +/// A single per-domain regression found by [`promotion_gate`]. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct Regression { + /// The slice that regressed. + pub slice: SliceId, + /// The nature of the regression. + pub kind: RegressionKind, + /// The previous point estimate, if it was scored. + pub prev: Option, + /// The current point estimate, if it is scored. + pub curr: Option, + /// The tolerance that was exceeded. + pub tolerance: f64, +} + +/// The verdict of the promotion gate. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct GateReport { + /// True only when no domain regressed. + pub passed: bool, + /// Pooled accuracy of the previous scorecard, if computable. + pub pooled_prev: Option, + /// Pooled accuracy of the current scorecard, if computable. + pub pooled_curr: Option, + /// Whether the pooled average improved. + pub pooled_improved: bool, + /// Every per-domain regression found (empty iff `passed`). + pub regressions: Vec, +} + +impl GateReport { + /// True when the pooled average improved yet the gate still failed — the + /// exact "regression hiding behind pooled accuracy" case ADR-300 rule 4 + /// exists to catch. + #[must_use] + pub fn hidden_behind_pooled(&self) -> bool { + self.pooled_improved && !self.passed + } +} + +/// Compare a candidate scorecard against a baseline and decide promotion. +/// +/// The gate **fails if any single domain regresses beyond its budget**, even +/// when the pooled average improved (ADR-317 §2, ADR-300 rule 4): improvement +/// on `room-known` cannot buy a regression on `room-unseen`. Because the gate +/// evaluates every domain independently, a regression can never hide behind a +/// flattering pool; [`GateReport::hidden_behind_pooled`] reports when exactly +/// that was attempted. +/// +/// Rules per slice: +/// - baseline `NoEvidence`: nothing to regress from — skipped (a newly covered +/// or still-empty domain is not itself a regression). +/// - baseline scored, candidate `NoEvidence`: [`RegressionKind::CoverageLoss`] +/// — losing a covered domain is a failure, never a pass. +/// - both scored, higher-is-better: regression if +/// `curr < prev - tolerance(slice)`. +/// - both scored, calibration drift: regression if +/// `curr > prev + tolerance(slice)`. +#[must_use] +pub fn promotion_gate(prev: &Scorecard, curr: &Scorecard, policy: &GatePolicy) -> GateReport { + let mut regressions = Vec::new(); + for slice in SliceId::ALL { + let tol = policy.tolerance(slice); + let prev_cell = prev.cell(slice); + let curr_cell = curr.cell(slice); + match (prev_cell.point(), curr_cell.point()) { + (None, _) => { + // No baseline for this domain: cannot regress below nothing. + } + (Some(_), None) => { + regressions.push(Regression { + slice, + kind: RegressionKind::CoverageLoss, + prev: prev_cell.point(), + curr: None, + tolerance: tol, + }); + } + (Some(p), Some(c)) => { + let regressed = if slice.higher_is_better() { + c < p - tol + } else { + c > p + tol + }; + if regressed { + regressions.push(Regression { + slice, + kind: if slice.higher_is_better() { + RegressionKind::AccuracyDrop + } else { + RegressionKind::DriftIncrease + }, + prev: Some(p), + curr: Some(c), + tolerance: tol, + }); + } + } + } + } + + let pooled_prev = prev.pooled_accuracy(); + let pooled_curr = curr.pooled_accuracy(); + let pooled_improved = matches!((pooled_prev, pooled_curr), (Some(a), Some(b)) if b > a); + + GateReport { + passed: regressions.is_empty(), + pooled_prev, + pooled_curr, + pooled_improved, + regressions, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn scored(point: f64, n: u64) -> Cell { + // Route the inputs through `black_box` so the Wilson math runs at + // runtime (as it does in the ADR-149 flow, computed from live + // evidence) rather than being const-folded — const-eval and the + // runtime FPU can disagree at the last ULP, which is a compiler + // artifact, not real non-determinism. + let point = std::hint::black_box(point); + let n = std::hint::black_box(n); + Cell::Scored(Metric::synthetic(point, n).expect("valid metric")) + } + + // ---- CI computation vs hand-computed fixtures ------------------------- + + #[test] + fn wilson_ci_matches_hand_computed_50_of_100() { + // p=0.5, n=100, z=1.96 (approx): classic Wilson 95% CI ≈ [0.4038, 0.5962]. + let m = Metric::with_confidence(0.5, 100, EvidenceLevel::L0, 1.96).unwrap(); + assert!((m.ci_low() - 0.4038).abs() < 1e-3, "lo={}", m.ci_low()); + assert!((m.ci_high() - 0.5962).abs() < 1e-3, "hi={}", m.ci_high()); + // Interval is symmetric about the point at p=0.5. + assert!(((m.ci_low() + m.ci_high()) / 2.0 - 0.5).abs() < 1e-9); + } + + #[test] + fn wilson_ci_matches_hand_computed_10_of_10() { + // p=1.0, n=10, z=1.96: Wilson lower bound ≈ 0.7225, upper clamps to 1.0. + let m = Metric::with_confidence(1.0, 10, EvidenceLevel::L0, 1.96).unwrap(); + assert!((m.ci_low() - 0.7225).abs() < 1e-3, "lo={}", m.ci_low()); + assert!(m.ci_high() <= 1.0 && m.ci_high() > 0.999, "hi={}", m.ci_high()); + } + + #[test] + fn wilson_ci_stays_in_unit_interval_at_extremes() { + for &p in &[0.0, 1.0, 0.01, 0.99] { + for &n in &[1u64, 5, 1000] { + let (lo, hi) = wilson_interval(p, n, Z_95); + assert!((0.0..=1.0).contains(&lo), "lo={lo} p={p} n={n}"); + assert!((0.0..=1.0).contains(&hi), "hi={hi} p={p} n={n}"); + assert!(lo <= hi); + } + } + } + + #[test] + fn ci_narrows_with_more_samples() { + let few = Metric::synthetic(0.8, 10).unwrap(); + let many = Metric::synthetic(0.8, 10_000).unwrap(); + let w_few = few.ci_high() - few.ci_low(); + let w_many = many.ci_high() - many.ci_low(); + assert!(w_many < w_few, "expected tighter CI with more samples"); + } + + // ---- boundary validation --------------------------------------------- + + #[test] + fn malformed_metric_input_is_an_error_not_a_panic() { + assert_eq!( + Metric::new(1.5, 10, EvidenceLevel::L0).unwrap_err(), + ScorecardError::PointOutOfRange { value: 1.5 } + ); + // NaN != NaN, so match the variant rather than compare the payload. + assert!(matches!( + Metric::new(f64::NAN, 10, EvidenceLevel::L0).unwrap_err(), + ScorecardError::PointOutOfRange { .. } + )); + assert_eq!( + Metric::new(0.5, 0, EvidenceLevel::L0).unwrap_err(), + ScorecardError::ZeroSamples + ); + assert!(matches!( + Metric::with_confidence(0.5, 10, EvidenceLevel::L0, 0.0).unwrap_err(), + ScorecardError::BadConfidence { .. } + )); + } + + #[test] + fn synthetic_metric_is_l0_by_construction() { + assert_eq!(Metric::synthetic(0.9, 100).unwrap().level(), EvidenceLevel::L0); + } + + #[test] + fn state_fractions_validate_and_pick_dominant() { + assert_eq!( + StateFractions::new(0.9, 0.08, 0.02).unwrap().dominant(), + DomainState::Valid + ); + assert_eq!( + StateFractions::new(0.3, 0.6, 0.1).unwrap().dominant(), + DomainState::Degraded + ); + assert_eq!( + StateFractions::new(0.2, 0.2, 0.6).unwrap().dominant(), + DomainState::Unknown + ); + assert!(StateFractions::new(0.5, 0.4, 0.4).is_err()); // sums to 1.3 + assert!(StateFractions::new(-0.1, 0.6, 0.5).is_err()); + } + + // ---- worst-domain selection ------------------------------------------ + + #[test] + fn worst_domain_is_the_minimum_slice() { + let mut sc = Scorecard::empty(); + sc.presence_room_known = scored(0.95, 500); + sc.presence_room_unseen = scored(0.70, 500); + sc.presence_device_unseen = scored(0.82, 500); + sc.presence_stationary_10m = scored(0.61, 500); + let worst = sc.worst_domain(Task::Presence); + assert_eq!(worst.slice, SliceId::PresenceStationary10m); + assert_eq!(worst.point(), Some(0.61)); + } + + #[test] + fn worst_domain_ranks_no_evidence_below_any_score() { + let mut sc = Scorecard::empty(); + sc.presence_room_known = scored(0.95, 500); + sc.presence_room_unseen = scored(0.10, 500); + // device-unseen and stationary remain NoEvidence — no coverage is worst. + let worst = sc.worst_domain(Task::Presence); + assert!(matches!(worst.cell, Cell::NoEvidence)); + assert_eq!(worst.point(), None); + // Canonical order breaks the NoEvidence tie deterministically. + assert_eq!(worst.slice, SliceId::PresenceDeviceUnseen); + } + + // ---- promotion gate --------------------------------------------------- + + #[test] + fn gate_fails_on_hidden_worst_domain_regression_while_pooled_improves() { + // Only two covered presence slices, so pooled == their mean. + // prev pooled = (0.80 + 0.75)/2 = 0.775 + let mut prev = Scorecard::empty(); + prev.presence_room_known = scored(0.80, 1000); + prev.presence_room_unseen = scored(0.75, 1000); + + // curr pooled = (0.95 + 0.65)/2 = 0.80 -> pooled IMPROVED + // but room-unseen (strict budget) dropped 0.75 -> 0.65 -> regression. + let mut curr = Scorecard::empty(); + curr.presence_room_known = scored(0.95, 1000); + curr.presence_room_unseen = scored(0.65, 1000); + + let report = promotion_gate(&prev, &curr, &GatePolicy::default()); + assert!(report.pooled_improved, "pooled should have improved"); + assert!(!report.passed, "gate must fail on the hidden regression"); + assert!(report.hidden_behind_pooled()); + assert_eq!(report.regressions.len(), 1); + assert_eq!(report.regressions[0].slice, SliceId::PresenceRoomUnseen); + assert_eq!(report.regressions[0].kind, RegressionKind::AccuracyDrop); + } + + #[test] + fn gate_passes_on_across_the_board_improvement() { + let mut prev = Scorecard::empty(); + prev.presence_room_known = scored(0.80, 1000); + prev.presence_room_unseen = scored(0.70, 1000); + prev.presence_device_unseen = scored(0.72, 1000); + prev.presence_stationary_10m = scored(0.55, 1000); + prev.pose_matched = scored(0.60, 1000); + prev.ood_rejection = scored(0.90, 1000); + prev.calibration_drift = scored(0.20, 1000); + + let mut curr = Scorecard::empty(); + curr.presence_room_known = scored(0.85, 1000); + curr.presence_room_unseen = scored(0.74, 1000); + curr.presence_device_unseen = scored(0.76, 1000); + curr.presence_stationary_10m = scored(0.60, 1000); + curr.pose_matched = scored(0.65, 1000); + curr.ood_rejection = scored(0.93, 1000); + curr.calibration_drift = scored(0.15, 1000); // drift down = better + + let report = promotion_gate(&prev, &curr, &GatePolicy::default()); + assert!(report.passed, "expected pass: {:?}", report.regressions); + assert!(report.regressions.is_empty()); + assert!(!report.hidden_behind_pooled()); + } + + #[test] + fn gate_fails_on_coverage_loss() { + let mut prev = Scorecard::empty(); + prev.presence_room_unseen = scored(0.75, 1000); + let curr = Scorecard::empty(); // lost the covered domain + let report = promotion_gate(&prev, &curr, &GatePolicy::default()); + assert!(!report.passed); + assert_eq!(report.regressions[0].kind, RegressionKind::CoverageLoss); + } + + #[test] + fn gate_fails_on_drift_increase() { + let mut prev = Scorecard::empty(); + prev.calibration_drift = scored(0.10, 1000); + let mut curr = Scorecard::empty(); + curr.calibration_drift = scored(0.30, 1000); // drift rose beyond budget + let report = promotion_gate(&prev, &curr, &GatePolicy::default()); + assert!(!report.passed); + assert_eq!(report.regressions[0].kind, RegressionKind::DriftIncrease); + } + + #[test] + fn small_move_within_tolerance_is_not_a_regression() { + let mut prev = Scorecard::empty(); + prev.presence_room_known = scored(0.80, 1000); // base budget 0.02 + let mut curr = Scorecard::empty(); + curr.presence_room_known = scored(0.79, 1000); // within budget + let report = promotion_gate(&prev, &curr, &GatePolicy::default()); + assert!(report.passed); + } + + // ---- determinism ------------------------------------------------------ + + fn sample_scorecard() -> Scorecard { + let mut sc = Scorecard::empty(); + sc.presence_room_known = scored(0.91, 800); + sc.presence_room_unseen = scored(0.68, 800); + sc.presence_stationary_10m = scored(0.52, 400); + sc.ood_rejection = scored(0.88, 300); + sc.calibration_drift = scored(0.12, 800); + sc.state_fractions = Some(StateFractions::new(0.7, 0.2, 0.1).unwrap()); + sc + } + + #[test] + fn render_and_gate_are_deterministic() { + // Rendering and the gate are pure: the same inputs yield the same + // output every time (no wall clock, no RNG). + let a = sample_scorecard(); + let b = sample_scorecard(); + assert_eq!(a.render(), a.render()); + // Two independent builds render identically; 4-decimal formatting is + // stable across any last-ULP difference the compiler may introduce by + // const-folding one build differently from the other. + assert_eq!(a.render(), b.render()); + assert_eq!( + promotion_gate(&a, &b, &GatePolicy::default()), + promotion_gate(&a, &b, &GatePolicy::default()) + ); + + // Serialisation preserves the scorecard to reporting precision: a + // JSON round-trip reproduces the same rendered scorecard. (Rendering + // fixes precision at 4 decimals; the ADR-149 hash binding hashes the + // reproducible reporting form, not raw f64 bits.) + let back: Scorecard = + serde_json::from_str(&serde_json::to_string(&a).unwrap()).unwrap(); + assert_eq!(back.render(), a.render()); + } + + #[test] + fn render_reports_no_evidence_not_a_number() { + let sc = Scorecard::empty(); + let text = sc.render(); + assert!(text.contains("no-ev")); + assert!(text.contains("no coverage")); + assert!(text.contains("pooled accuracy = no-evidence")); + assert!(text.contains("domain state = untracked")); + } + + #[test] + fn render_flags_pooled_as_insufficient() { + let sc = sample_scorecard(); + let text = sc.render(); + assert!(text.contains("INSUFFICIENT ALONE")); + assert!(text.contains("worst presence")); + assert!(text.contains("domain state = Valid")); + } +} diff --git a/v2/crates/ruview-track/Cargo.toml b/v2/crates/ruview-track/Cargo.toml new file mode 100644 index 00000000..68b520c6 --- /dev/null +++ b/v2/crates/ruview-track/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "ruview-track" +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 diff --git a/v2/crates/ruview-track/src/config.rs b/v2/crates/ruview-track/src/config.rs new file mode 100644 index 00000000..736b9f84 --- /dev/null +++ b/v2/crates/ruview-track/src/config.rs @@ -0,0 +1,76 @@ +//! Tuning for the association / lifecycle / decay policy (ADR-307). +//! +//! All thresholds are explicit and deterministic; nothing here reads a clock or +//! draws randomness. The manager injects every timestamp. + +use ruview_ontology::{EvidenceLevel, SemanticProvenance}; + +/// Bounded-cost association, lifecycle, and decay policy. +#[derive(Clone, Debug, PartialEq)] +pub struct TrackerConfig { + /// Maximum Euclidean position distance for a value-gate pass (same units as + /// [`Detection::position`](crate::Detection)). + pub gate_position: f64, + /// Maximum coarse-feature L1 distance for a value-gate pass. + pub gate_feature: f64, + /// Minimum cost separation between the best and second-best candidate track + /// for an assignment to be *unambiguous*. If two tracks are within this + /// margin the detection is left tentative rather than risk a swap. + pub ambiguity_margin: f64, + /// Associated detections required to promote a tentative track to active. + pub confirm_after: u32, + /// Idle gap (ms) after which an active track is marked lost (still + /// re-identifiable within [`max_coast_ms`](Self::max_coast_ms)). + pub lost_after_ms: i64, + /// Association horizon (ms). Beyond this idle gap a track is expired and a + /// fresh pseudonym is minted rather than forcing a join — under-linking is + /// the privacy-safe failure mode. + pub max_coast_ms: i64, + /// Relative weight of the position term in the association cost. + pub w_pos: f64, + /// Relative weight of the feature term in the association cost. + pub w_feat: f64, + /// Evidence level stamped on emitted [`Track`](ruview_ontology::Track) / + /// [`Person`](ruview_ontology::Person) nodes. Defaults to `L1` + /// (heuristic/synthetic); this crate asserts no accuracy number. + pub emit_evidence_level: EvidenceLevel, + /// Provenance stamped on emitted nodes. Carries the pseudonymous privacy + /// decision; never a civil identifier. + pub provenance: SemanticProvenance, +} + +impl Default for TrackerConfig { + fn default() -> Self { + Self { + gate_position: 2.0, + gate_feature: 6.0, + ambiguity_margin: 0.15, + confirm_after: 2, + lost_after_ms: 1_000, + max_coast_ms: 5_000, + w_pos: 1.0, + w_feat: 1.0, + emit_evidence_level: EvidenceLevel::L1, + provenance: SemanticProvenance { + evidence: Vec::new(), + model_version: "ruview-track".to_string(), + calibration_version: "none".to_string(), + privacy_decision: "pseudonymous".to_string(), + }, + } + } +} + +impl TrackerConfig { + /// Normalizing denominator for the association cost (`w_pos + w_feat`). + /// Guarded to a positive value so confidence math never divides by zero. + #[must_use] + pub(crate) fn weight_sum(&self) -> f64 { + let s = self.w_pos + self.w_feat; + if s > 0.0 { + s + } else { + 1.0 + } + } +} diff --git a/v2/crates/ruview-track/src/error.rs b/v2/crates/ruview-track/src/error.rs new file mode 100644 index 00000000..3f900fdb --- /dev/null +++ b/v2/crates/ruview-track/src/error.rs @@ -0,0 +1,36 @@ +//! Boundary-validation errors (ADR-307). +//! +//! These cover *malformed input* only. Association **uncertainty** is never an +//! error: an ambiguous or unmatched detection is reported as a first-class +//! [`Association::Unknown`](crate::Association) outcome (ADR-300 rule 1), not a +//! `Result::Err`. + +use ruview_ontology::IdError; +use thiserror::Error; + +/// Reasons a detection or a manager operation is rejected at the boundary. +#[derive(Clone, Debug, PartialEq, Eq, Error)] +pub enum TrackError { + /// A position component was NaN or infinite. + #[error("position component is not finite")] + NonFinitePosition, + /// The coarse feature vector was empty. + #[error("coarse feature vector must not be empty")] + EmptyFeature, + /// The coarse feature vector exceeded [`MAX_FEATURE_DIM`](crate::MAX_FEATURE_DIM). + #[error("feature dimension {dim} exceeds maximum {max}")] + FeatureTooLarge { + /// Supplied dimension. + dim: usize, + /// Enforced maximum. + max: usize, + }, + /// A minted pseudonym / track id failed ontology id validation. This is an + /// internal invariant (the manager mints `track_N`/`person_N`) and only + /// surfaces if the counter overflows the id-length bound. + #[error("invalid minted identifier: {0}")] + Id(#[from] IdError), + /// A referenced track id is not held by the manager. + #[error("unknown track id")] + UnknownTrack, +} diff --git a/v2/crates/ruview-track/src/feature.rs b/v2/crates/ruview-track/src/feature.rs new file mode 100644 index 00000000..30e848b6 --- /dev/null +++ b/v2/crates/ruview-track/src/feature.rs @@ -0,0 +1,152 @@ +//! Coarse, non-reversible appearance features (ADR-307 §3, privacy boundary). +//! +//! A [`CoarseFeature`] is the appearance channel used for short-horizon track +//! continuity (the ADR-306/ADR-307 `CsiFingerprint` analogue). Its type is the +//! privacy enforcement point: +//! +//! - **Coarse.** Raw values are quantized into a handful of buckets +//! ([`COARSE_LEVELS`]), so fine structure that could serve as a biometric is +//! discarded at construction. +//! - **Non-reversible.** Quantization is lossy and there is no de-quantizer: +//! the original values cannot be recovered from a `CoarseFeature`. +//! - **Bounded.** Dimension is capped at [`MAX_FEATURE_DIM`], bounding +//! allocation on untrusted input. +//! - **Carries no civil identifier.** The type holds only opaque bucket indices +//! — no name, account, MAC, phone, or other join key exists in the schema. + +use serde::{Deserialize, Serialize}; + +use crate::error::TrackError; + +/// Maximum accepted coarse-feature dimension. Bounds allocation. +pub const MAX_FEATURE_DIM: usize = 16; + +/// Number of coarse quantization buckets per component (a 3-bit coarse code). +/// Deliberately small so the feature is non-identifying. +pub const COARSE_LEVELS: u8 = 8; + +/// A bounded, coarse, non-reversible appearance descriptor. +/// +/// Construct via [`CoarseFeature::quantize`]. Two features are compared with an +/// L1 distance over aligned buckets; features of differing dimension are treated +/// as maximally distant (non-comparable) rather than panicking. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CoarseFeature { + /// Opaque coarse bucket indices, each in `0..COARSE_LEVELS`. + bins: Vec, +} + +impl CoarseFeature { + /// Quantize raw components (each expected in `[0.0, 1.0]`, clamped + /// otherwise) into coarse buckets. + /// + /// Rejects an empty or over-long vector at the boundary; never panics on + /// NaN/inf (those clamp to the nearest bucket edge). + pub fn quantize(raw: &[f64]) -> Result { + if raw.is_empty() { + return Err(TrackError::EmptyFeature); + } + if raw.len() > MAX_FEATURE_DIM { + return Err(TrackError::FeatureTooLarge { + dim: raw.len(), + max: MAX_FEATURE_DIM, + }); + } + let top = i64::from(COARSE_LEVELS) - 1; + let bins = raw + .iter() + .map(|&v| { + // NaN maps to 0 via the failed comparison in clamp guards below. + let c = if v.is_nan() { 0.0 } else { v.clamp(0.0, 1.0) }; + let bucket = (c * f64::from(COARSE_LEVELS)).floor() as i64; + bucket.clamp(0, top) as u8 + }) + .collect(); + Ok(Self { bins }) + } + + /// The number of coarse components. + #[must_use] + pub fn dim(&self) -> usize { + self.bins.len() + } + + /// Borrow the opaque bucket indices (for tests / serialization checks). + #[must_use] + pub fn bins(&self) -> &[u8] { + &self.bins + } + + /// The maximum possible [`distance`](Self::distance) for this dimension — + /// used to normalize the gate. Always finite. + #[must_use] + pub fn max_distance(&self) -> f64 { + self.bins.len() as f64 * f64::from(COARSE_LEVELS - 1) + } + + /// L1 distance over aligned buckets. Differing dimensions are non-comparable + /// and return the larger side's maximum distance (treated as far apart) so a + /// dimension mismatch can never masquerade as a close match. + #[must_use] + pub fn distance(&self, other: &Self) -> f64 { + if self.bins.len() != other.bins.len() { + return self.max_distance().max(other.max_distance()); + } + self.bins + .iter() + .zip(&other.bins) + .map(|(&a, &b)| f64::from(a.abs_diff(b))) + .sum() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn quantize_is_coarse_and_bounded() { + let f = CoarseFeature::quantize(&[0.0, 0.5, 1.0]).unwrap(); + assert_eq!(f.dim(), 3); + // Every bucket is within the coarse range. + assert!(f.bins().iter().all(|&b| b < COARSE_LEVELS)); + // 0.5 lands in the middle bucket, not at an extreme. + assert_eq!(f.bins()[0], 0); + assert_eq!(f.bins()[2], COARSE_LEVELS - 1); + } + + #[test] + fn quantize_is_lossy_non_reversible() { + // Two nearby-but-distinct raw values collapse to the same bucket: + // information is destroyed, so the original is unrecoverable. + let a = CoarseFeature::quantize(&[0.01]).unwrap(); + let b = CoarseFeature::quantize(&[0.10]).unwrap(); + assert_eq!(a, b); + } + + #[test] + fn rejects_empty_and_overlong() { + assert_eq!(CoarseFeature::quantize(&[]), Err(TrackError::EmptyFeature)); + let long = vec![0.5; MAX_FEATURE_DIM + 1]; + assert!(matches!( + CoarseFeature::quantize(&long), + Err(TrackError::FeatureTooLarge { .. }) + )); + } + + #[test] + fn nan_and_inf_do_not_panic() { + let f = CoarseFeature::quantize(&[f64::NAN, f64::INFINITY, f64::NEG_INFINITY]).unwrap(); + assert_eq!(f.bins(), &[0, COARSE_LEVELS - 1, 0]); + } + + #[test] + fn distance_symmetric_and_mismatch_is_far() { + let a = CoarseFeature::quantize(&[0.0, 0.0]).unwrap(); + let b = CoarseFeature::quantize(&[1.0, 1.0]).unwrap(); + assert_eq!(a.distance(&b), b.distance(&a)); + assert!(a.distance(&b) > 0.0); + let c = CoarseFeature::quantize(&[0.0]).unwrap(); + assert!(a.distance(&c) >= a.max_distance()); + } +} diff --git a/v2/crates/ruview-track/src/lib.rs b/v2/crates/ruview-track/src/lib.rs new file mode 100644 index 00000000..c6ef4612 --- /dev/null +++ b/v2/crates/ruview-track/src/lib.rs @@ -0,0 +1,78 @@ +//! # `ruview-track` — persistent, privacy-preserving probabilistic tracking (ADR-307) +//! +//! Builds **track continuity without civil identity**. A [`TrackManager`] +//! ingests per-frame [`Detection`]s (a container + 2-D position + a coarse, +//! non-reversible [`CoarseFeature`] + an injected timestamp) and maintains +//! persistent [`Track`](ruview_ontology::Track) entities, each bound to a +//! pseudonymous [`Person`](ruview_ontology::Person) such as `person_7`. It +//! answers "person_7 moved kitchen → hallway → bedroom" via per-entity +//! [histories](TrackManager::history) — across zones, rooms, and modalities. +//! +//! This crate produces and updates the **canonical ADR-306 ontology types** +//! (`Track`, `Person`, `Container`, `EvidenceLevel`, `SemanticProvenance`) from +//! [`ruview_ontology`]; it invents no per-crate identity shape (ADR-300 rule 3). +//! +//! ## The four privacy invariants (ADR-307 §3), enforced by construction +//! +//! 1. **No civil-identity binding.** The pseudonym is a synthetic id with no +//! field or join key to a name, account, MAC, or phone — the ontology +//! `Person`/`Track` schema carries no such field, so a binding is impossible. +//! 2. **Coarse, non-reversible features.** [`CoarseFeature`] quantizes to a few +//! buckets and offers no de-quantizer; no long-term biometric template is +//! persisted. +//! 3. **Opaque, rotatable ids.** Pseudonyms are `person_N` strings and can be +//! rotated with [`TrackManager::rotate_pseudonym`]. +//! 4. **UNKNOWN is first-class** (ADR-300 rule 1). An unmatched or ambiguous +//! detection spawns a *tentative* track and returns an +//! [`Association::Unknown`] outcome — it never forces a wrong join and never +//! errors. Under-linking (a fresh pseudonym when unsure) is the privacy-safe +//! failure mode. +//! +//! ## Evidence discipline +//! +//! This crate asserts **no accuracy number** (ADR-307 §Validation). Emitted +//! nodes carry the caller-supplied [`EvidenceLevel`](ruview_ontology::EvidenceLevel) +//! (default `L1`, heuristic/synthetic) and a pseudonymous +//! [`SemanticProvenance`](ruview_ontology::SemanticProvenance); tentative tracks +//! are floored to `L0`. In-crate tests use synthetic in-code fixtures only. +//! +//! ## Example +//! +//! ``` +//! use ruview_track::*; +//! use ruview_ontology::{Container, SpaceId}; +//! +//! let kitchen = Container::Space { id: SpaceId::new("kitchen")? }; +//! let hallway = Container::Space { id: SpaceId::new("hallway")? }; +//! +//! let mut topo = Topology::new(); +//! topo.connect(&kitchen, &hallway); // a doorway between them +//! +//! let mut mgr = TrackManager::new(TrackerConfig::default(), topo); +//! let feat = CoarseFeature::quantize(&[0.2, 0.7, 0.4])?; +//! +//! let a = mgr.ingest(Detection::new(kitchen, [1.0, 1.0], feat.clone(), 1_000)?)?; +//! let b = mgr.ingest(Detection::new(hallway, [1.4, 1.1], feat, 1_500)?)?; +//! +//! // Same persistent pseudonym followed across the doorway. +//! assert_eq!(a.person, b.person); +//! assert!(matches!(b.association, Association::Matched { .. })); +//! # Ok::<(), Box>(()) +//! ``` + +#![forbid(unsafe_code)] +#![warn(missing_docs)] + +mod config; +mod error; +mod feature; +mod manager; +mod topology; + +pub use config::TrackerConfig; +pub use error::TrackError; +pub use feature::{CoarseFeature, COARSE_LEVELS, MAX_FEATURE_DIM}; +pub use manager::{ + Association, Detection, IngestOutcome, TrackManager, TrackState, UnknownReason, Waypoint, +}; +pub use topology::Topology; diff --git a/v2/crates/ruview-track/src/manager.rs b/v2/crates/ruview-track/src/manager.rs new file mode 100644 index 00000000..9bb9e7b8 --- /dev/null +++ b/v2/crates/ruview-track/src/manager.rs @@ -0,0 +1,539 @@ +//! [`TrackManager`] — persistent, privacy-preserving probabilistic tracking +//! (ADR-307). +//! +//! # What it does +//! +//! Ingests per-frame [`Detection`]s (a container + 2-D position + a coarse, +//! non-reversible [`CoarseFeature`] + an injected timestamp) and maintains +//! persistent [`Track`](ruview_ontology::Track) entities, each resolved to a +//! pseudonymous [`Person`](ruview_ontology::Person) (`person_7`). It produces +//! per-entity **histories** across zones/rooms +//! (`person_7: kitchen → hallway → bedroom`). +//! +//! # Association (documented, bounded) +//! +//! Per frame it runs gated nearest-neighbour association with a bounded cost: +//! +//! 1. **Topology gate.** A track is a candidate only if the detection's +//! container is the same as, or [adjacent](crate::Topology) to, the track's +//! last container. +//! 2. **Value gate + horizon.** Position distance ≤ `gate_position`, feature +//! distance ≤ `gate_feature`, idle gap ≤ `max_coast_ms`. +//! 3. **Cost.** `w_pos·(pos/gate_pos) + w_feat·(feat/gate_feat)` — bounded to +//! `[0, w_pos+w_feat]`. +//! 4. **Ambiguity.** If the best and second-best candidates are within +//! `ambiguity_margin`, the detection is *not* assigned — it spawns a tentative +//! track. Under-linking, never a wrong join. +//! 5. **Decayed confidence.** `confidence = decay(gap) · similarity`, where +//! `decay` falls linearly to 0 at `max_coast_ms`. Beyond the horizon the +//! track has already expired, so a fresh pseudonym is minted. +//! +//! Any detection without a confident, unambiguous match yields an +//! [`Association::Unknown`] outcome and a new tentative track (ADR-300 rule 1: +//! UNKNOWN is first-class, never an error). +//! +//! # Privacy boundary (by construction) +//! +//! - The persistent id is a synthetic pseudonym (`person_7`) with **no** field +//! or join key to any name, account, MAC, or phone — the ontology +//! [`Person`](ruview_ontology::Person) schema simply has no such field. +//! - Pseudonyms are **rotatable** via [`TrackManager::rotate_pseudonym`]. +//! - Appearance features are coarse and non-reversible by type +//! ([`CoarseFeature`]); nothing here persists a long-term biometric template. + +use std::collections::BTreeMap; + +use ruview_ontology::{Container, EvidenceLevel, Person, PersonId, Track, TrackId}; + +use crate::config::TrackerConfig; +use crate::error::TrackError; +use crate::feature::CoarseFeature; +use crate::topology::Topology; + +/// A single per-frame detection handed to the manager. +/// +/// Construct with [`Detection::new`], which validates the position at the +/// boundary. The coarse feature is already bounded and non-reversible by type. +#[derive(Clone, Debug, PartialEq)] +pub struct Detection { + /// Where the detection was observed (space or zone). + pub container: Container, + /// A 2-D position within the space frame. + pub position: [f64; 2], + /// Coarse, non-identifying appearance descriptor. + pub feature: CoarseFeature, + /// Injected capture timestamp (Unix ms). Never sampled from a clock here. + pub at_unix_ms: i64, +} + +impl Detection { + /// Validate and build a detection, rejecting a non-finite position. + pub fn new( + container: Container, + position: [f64; 2], + feature: CoarseFeature, + at_unix_ms: i64, + ) -> Result { + if !position[0].is_finite() || !position[1].is_finite() { + return Err(TrackError::NonFinitePosition); + } + Ok(Self { + container, + position, + feature, + at_unix_ms, + }) + } +} + +/// Lifecycle state of a persistent track. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum TrackState { + /// Newly spawned; not yet confirmed by `confirm_after` hits. + Tentative, + /// Confirmed and currently observed. + Active, + /// Confirmed but idle beyond `lost_after_ms`; still re-identifiable within + /// `max_coast_ms`. + Lost, +} + +/// One container transition in a track's history. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Waypoint { + /// The container entered. + pub container: Container, + /// When it was entered (Unix ms). + pub at_unix_ms: i64, +} + +/// Why a detection produced no confident match. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum UnknownReason { + /// No existing tracks were candidates. + NoCandidate, + /// A nearest track existed but failed the value gate / horizon. + GateExceeded, + /// A nearest track existed but was not topologically adjacent. + TopologyBlocked, + /// Two tracks were within `ambiguity_margin` — left tentative to avoid a swap. + Ambiguous, + /// A candidate track existed but was claimed by a closer detection this frame. + Contested, +} + +/// The association decision for one detection. +#[derive(Clone, Debug, PartialEq)] +pub enum Association { + /// Matched to an existing track with a decayed confidence in `[0, 1]`. + Matched { + /// The track the detection was attributed to. + track: TrackId, + /// Decayed continuity confidence (never asserted as certainty). + confidence: f64, + }, + /// No confident, unambiguous match: a fresh tentative track was spawned. + Unknown { + /// The newly minted tentative track. + spawned: TrackId, + /// Why no existing track was chosen. + reason: UnknownReason, + }, +} + +/// The outcome of ingesting one detection. +#[derive(Clone, Debug, PartialEq)] +pub struct IngestOutcome { + /// The track the detection now belongs to (matched or newly spawned). + pub track: TrackId, + /// The persistent pseudonym for that track. + pub person: PersonId, + /// The association decision. + pub association: Association, +} + +/// Internal persistent-track record. Not part of the public schema. +#[derive(Clone, Debug)] +struct Entity { + id: TrackId, + person: PersonId, + state: TrackState, + container: Container, + position: [f64; 2], + feature: CoarseFeature, + last_ms: i64, + hits: u32, + history: Vec, +} + +/// Persistent probabilistic tracker producing ADR-306 `Track`/`Person` nodes +/// without civil identity. +#[derive(Clone, Debug)] +pub struct TrackManager { + config: TrackerConfig, + topology: Topology, + entities: BTreeMap, + track_counter: u64, + person_counter: u64, +} + +impl TrackManager { + /// A manager with the given config and topology. + #[must_use] + pub fn new(config: TrackerConfig, topology: Topology) -> Self { + Self { + config, + topology, + entities: BTreeMap::new(), + track_counter: 0, + person_counter: 0, + } + } + + /// A manager with default policy and an empty topology. + #[must_use] + pub fn with_defaults() -> Self { + Self::new(TrackerConfig::default(), Topology::new()) + } + + /// Borrow the configuration. + #[must_use] + pub fn config(&self) -> &TrackerConfig { + &self.config + } + + /// Number of live tracks currently held. + #[must_use] + pub fn len(&self) -> usize { + self.entities.len() + } + + /// Whether no tracks are held. + #[must_use] + pub fn is_empty(&self) -> bool { + self.entities.is_empty() + } + + /// Live track ids, in stable order. + #[must_use] + pub fn track_ids(&self) -> Vec { + self.entities.keys().cloned().collect() + } + + /// The lifecycle state of a track, if held. + #[must_use] + pub fn state(&self, track: &TrackId) -> Option { + self.entities.get(track).map(|e| e.state) + } + + /// The pseudonym bound to a track, if held. + #[must_use] + pub fn person_of(&self, track: &TrackId) -> Option<&PersonId> { + self.entities.get(track).map(|e| &e.person) + } + + /// A track's container history (deduplicated on entry), if held. + #[must_use] + pub fn history(&self, track: &TrackId) -> Option<&[Waypoint]> { + self.entities.get(track).map(|e| e.history.as_slice()) + } + + /// A track's trajectory as an ordered list of containers, if held. + #[must_use] + pub fn trajectory(&self, track: &TrackId) -> Option> { + self.entities + .get(track) + .map(|e| e.history.iter().map(|w| w.container.clone()).collect()) + } + + /// Advance time to `now_unix_ms`, applying lifecycle decay: mark idle active + /// tracks lost, and expire (drop) any track idle beyond `max_coast_ms`. + /// Returns the ids that expired. + pub fn tick(&mut self, now_unix_ms: i64) -> Vec { + let mut expired = Vec::new(); + self.entities.retain(|id, e| { + let gap = (now_unix_ms - e.last_ms).max(0); + if gap > self.config.max_coast_ms { + expired.push(id.clone()); + false + } else { + if gap > self.config.lost_after_ms && e.state == TrackState::Active { + e.state = TrackState::Lost; + } + true + } + }); + expired + } + + /// Ingest a single detection. Convenience wrapper over [`Self::ingest_frame`]. + pub fn ingest(&mut self, detection: Detection) -> Result { + let mut out = self.ingest_frame(std::slice::from_ref(&detection))?; + // Exactly one detection in, exactly one outcome out. + Ok(out.pop().expect("one detection yields one outcome")) + } + + /// Ingest a frame of detections, returning one outcome per detection in + /// input order. + /// + /// Association is joint within the frame: each detection matches at most one + /// track and each track absorbs at most one detection, resolved greedily by + /// ascending cost. Detections that are unmatched, gated out, topology-blocked, + /// ambiguous, or contested spawn a fresh tentative track. + pub fn ingest_frame( + &mut self, + detections: &[Detection], + ) -> Result, TrackError> { + // Boundary validation first; malformed input is an error, not UNKNOWN. + for d in detections { + if !d.position[0].is_finite() || !d.position[1].is_finite() { + return Err(TrackError::NonFinitePosition); + } + } + if detections.is_empty() { + return Ok(Vec::new()); + } + + // Expire stale tracks relative to the frame's latest timestamp so they + // are not candidates (privacy-safe under-linking beyond the horizon). + let frame_ms = detections.iter().map(|d| d.at_unix_ms).max().unwrap_or(0); + self.tick(frame_ms); + + let n = detections.len(); + let ws = self.config.weight_sum(); + + // Per-detection scored candidate lists and spawn reasons. + let mut pairs: Vec<(usize, TrackId, f64)> = Vec::new(); // (det, track, cost) + let mut reason: Vec = vec![UnknownReason::NoCandidate; n]; + let mut ambiguous = vec![false; n]; + + for (i, d) in detections.iter().enumerate() { + let mut scored: Vec<(TrackId, f64)> = Vec::new(); + let mut saw_topo_block = false; + let mut saw_gate = false; + let mut saw_any = false; + + for e in self.entities.values() { + saw_any = true; + if !self.topology.adjacent(&e.container, &d.container) { + saw_topo_block = true; + continue; + } + let gap = (d.at_unix_ms - e.last_ms).max(0); + if gap > self.config.max_coast_ms { + saw_gate = true; + continue; + } + let pos = position_distance(d.position, e.position); + let feat = d.feature.distance(&e.feature); + if pos > self.config.gate_position || feat > self.config.gate_feature { + saw_gate = true; + continue; + } + let cost = self.config.w_pos * (pos / self.config.gate_position) + + self.config.w_feat * (feat / self.config.gate_feature); + scored.push((e.id.clone(), cost)); + } + + // Deterministic order: cost, then track id. + scored.sort_by(|a, b| a.1.total_cmp(&b.1).then_with(|| a.0.as_str().cmp(b.0.as_str()))); + + if scored.len() >= 2 && (scored[1].1 - scored[0].1) < self.config.ambiguity_margin { + // Two near-equal candidates: refuse to assign, spawn tentative. + ambiguous[i] = true; + reason[i] = UnknownReason::Ambiguous; + continue; + } + + if scored.is_empty() { + reason[i] = if !saw_any { + UnknownReason::NoCandidate + } else if saw_gate { + UnknownReason::GateExceeded + } else if saw_topo_block { + UnknownReason::TopologyBlocked + } else { + UnknownReason::NoCandidate + }; + } else { + // Provisional reason if greedy fails to secure a track. + reason[i] = UnknownReason::Contested; + for (tid, cost) in scored { + pairs.push((i, tid, cost)); + } + } + } + + // Greedy one-to-one assignment by ascending cost. + pairs.sort_by(|a, b| { + a.2.total_cmp(&b.2) + .then_with(|| a.1.as_str().cmp(b.1.as_str())) + .then_with(|| a.0.cmp(&b.0)) + }); + let mut det_track: Vec> = vec![None; n]; + let mut track_used: BTreeMap = BTreeMap::new(); + for (det, track, cost) in pairs { + if det_track[det].is_some() || track_used.contains_key(&track) { + continue; + } + det_track[det] = Some((track.clone(), cost)); + track_used.insert(track, ()); + } + + // Apply results in detection order (stable pseudonym minting). + let mut outcomes = Vec::with_capacity(n); + for (i, d) in detections.iter().enumerate() { + if let Some((track, cost)) = det_track[i].take() { + let confidence = self.apply_match(&track, d, cost, ws); + let person = self.entities[&track].person.clone(); + outcomes.push(IngestOutcome { + track: track.clone(), + person, + association: Association::Matched { track, confidence }, + }); + } else { + let (track, person) = self.spawn(d)?; + outcomes.push(IngestOutcome { + track: track.clone(), + person, + association: Association::Unknown { + spawned: track, + reason: reason[i], + }, + }); + } + } + Ok(outcomes) + } + + /// Rotate a track's pseudonym: mint a fresh opaque id and rebind it, keeping + /// the track and its history intact. Returns the new pseudonym. + pub fn rotate_pseudonym(&mut self, track: &TrackId) -> Result { + // Mint before the mutable borrow to satisfy the borrow checker. + let fresh = self.next_person_id()?; + let e = self + .entities + .get_mut(track) + .ok_or(TrackError::UnknownTrack)?; + e.person = fresh.clone(); + Ok(fresh) + } + + /// Project a track to a canonical ADR-306 [`Track`] node, carrying the + /// pseudonym, evidence level, and provenance. `None` if not held. + #[must_use] + pub fn to_track(&self, track: &TrackId) -> Option { + let e = self.entities.get(track)?; + Some(Track { + id: e.id.clone(), + person: Some(e.person.clone()), + located_in: e.container.clone(), + evidence_level: self.emit_level(e.state), + provenance: self.config.provenance.clone(), + }) + } + + /// Project a track's pseudonymous entity to a canonical ADR-306 [`Person`] + /// node. `None` if not held. + #[must_use] + pub fn to_person(&self, track: &TrackId) -> Option { + let e = self.entities.get(track)?; + Some(Person { + id: e.person.clone(), + located_in: e.container.clone(), + evidence_level: self.emit_level(e.state), + provenance: self.config.provenance.clone(), + }) + } + + // --- internals --- + + /// Emitted evidence level, floored to `L0` while a track is unconfirmed so a + /// tentative belief cannot masquerade as corroborated. + fn emit_level(&self, state: TrackState) -> EvidenceLevel { + match state { + TrackState::Tentative => EvidenceLevel::L0, + _ => self.config.emit_evidence_level, + } + } + + fn apply_match(&mut self, track: &TrackId, d: &Detection, cost: f64, ws: f64) -> f64 { + let confirm_after = self.config.confirm_after; + let horizon = self.config.max_coast_ms; + let e = self.entities.get_mut(track).expect("matched track exists"); + + let gap = (d.at_unix_ms - e.last_ms).max(0); + let similarity = (1.0 - cost / ws).clamp(0.0, 1.0); + let confidence = (decay_factor(gap, horizon) * similarity).clamp(0.0, 1.0); + + if e.container != d.container { + e.history.push(Waypoint { + container: d.container.clone(), + at_unix_ms: d.at_unix_ms, + }); + e.container = d.container.clone(); + } + e.position = d.position; + e.feature = d.feature.clone(); + e.last_ms = d.at_unix_ms; + e.hits = e.hits.saturating_add(1); + e.state = match e.state { + TrackState::Tentative if e.hits >= confirm_after => TrackState::Active, + TrackState::Lost => TrackState::Active, // re-identified + other => other, + }; + confidence + } + + fn spawn(&mut self, d: &Detection) -> Result<(TrackId, PersonId), TrackError> { + let id = self.next_track_id()?; + let person = self.next_person_id()?; + let confirm_now = self.config.confirm_after <= 1; + let entity = Entity { + id: id.clone(), + person: person.clone(), + state: if confirm_now { + TrackState::Active + } else { + TrackState::Tentative + }, + container: d.container.clone(), + position: d.position, + feature: d.feature.clone(), + last_ms: d.at_unix_ms, + hits: 1, + history: vec![Waypoint { + container: d.container.clone(), + at_unix_ms: d.at_unix_ms, + }], + }; + self.entities.insert(id.clone(), entity); + Ok((id, person)) + } + + fn next_track_id(&mut self) -> Result { + self.track_counter += 1; + Ok(TrackId::new(format!("track_{}", self.track_counter))?) + } + + fn next_person_id(&mut self) -> Result { + self.person_counter += 1; + Ok(PersonId::new(format!("person_{}", self.person_counter))?) + } +} + +/// Euclidean distance between two 2-D positions. Always finite for finite input. +fn position_distance(a: [f64; 2], b: [f64; 2]) -> f64 { + let dx = a[0] - b[0]; + let dy = a[1] - b[1]; + (dx * dx + dy * dy).sqrt() +} + +/// Linear time decay: `1` at zero gap, falling to `0` at the horizon and beyond. +/// Confidence in "same entity" falls with the size of the gap. +fn decay_factor(gap_ms: i64, horizon_ms: i64) -> f64 { + if horizon_ms <= 0 { + return if gap_ms <= 0 { 1.0 } else { 0.0 }; + } + (1.0 - gap_ms as f64 / horizon_ms as f64).clamp(0.0, 1.0) +} diff --git a/v2/crates/ruview-track/src/topology.rs b/v2/crates/ruview-track/src/topology.rs new file mode 100644 index 00000000..fff1b95e --- /dev/null +++ b/v2/crates/ruview-track/src/topology.rs @@ -0,0 +1,93 @@ +//! Space/zone adjacency that constrains plausible hand-offs (ADR-307 §2). +//! +//! Association across containers is only allowed between the **same** container +//! or two **adjacent** ones (the ADR-306 `AdjacentTo`/`Doorway` analogue): a +//! person can only move between spaces that share a boundary. An empty topology +//! therefore permits continuity only *within* a container — the privacy-safe +//! default for single-room deployments, where cross-room joins never happen by +//! accident. + +use std::collections::BTreeSet; + +use ruview_ontology::Container; + +/// Undirected adjacency between [`Container`]s. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Topology { + /// Normalized `(low, high)` key pairs of connected containers. + edges: BTreeSet<(String, String)>, +} + +impl Topology { + /// An empty topology: only same-container continuity is permitted. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Stable string key for a container, discriminated by kind so a space and a + /// zone sharing a raw id never collide. + fn key(c: &Container) -> String { + match c { + Container::Space { id } => format!("space:{}", id.as_str()), + Container::Zone { id } => format!("zone:{}", id.as_str()), + } + } + + fn pair(a: &Container, b: &Container) -> (String, String) { + let (ka, kb) = (Self::key(a), Self::key(b)); + if ka <= kb { + (ka, kb) + } else { + (kb, ka) + } + } + + /// Record that two containers are adjacent (idempotent, undirected). + pub fn connect(&mut self, a: &Container, b: &Container) -> &mut Self { + if Self::key(a) != Self::key(b) { + self.edges.insert(Self::pair(a, b)); + } + self + } + + /// Whether a hand-off from `from` to `to` is topologically plausible: the + /// same container, or a recorded adjacency. + #[must_use] + pub fn adjacent(&self, from: &Container, to: &Container) -> bool { + Self::key(from) == Self::key(to) || self.edges.contains(&Self::pair(from, to)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ruview_ontology::SpaceId; + + fn space(id: &str) -> Container { + Container::Space { + id: SpaceId::new(id).unwrap(), + } + } + + #[test] + fn same_container_is_always_adjacent() { + let t = Topology::new(); + assert!(t.adjacent(&space("kitchen"), &space("kitchen"))); + } + + #[test] + fn empty_topology_blocks_cross_container() { + let t = Topology::new(); + assert!(!t.adjacent(&space("kitchen"), &space("bedroom"))); + } + + #[test] + fn connect_is_undirected() { + let mut t = Topology::new(); + t.connect(&space("kitchen"), &space("hallway")); + assert!(t.adjacent(&space("kitchen"), &space("hallway"))); + assert!(t.adjacent(&space("hallway"), &space("kitchen"))); + assert!(!t.adjacent(&space("kitchen"), &space("bedroom"))); + } +} diff --git a/v2/crates/ruview-track/tests/tracking.rs b/v2/crates/ruview-track/tests/tracking.rs new file mode 100644 index 00000000..047fbba1 --- /dev/null +++ b/v2/crates/ruview-track/tests/tracking.rs @@ -0,0 +1,277 @@ +//! ADR-307 scenario tests: continuity, no-swap, spawn/expire, ambiguity, +//! id opacity, and determinism. All fixtures are synthetic and in-code; time is +//! injected (no wall clock); no randomness. + +use ruview_ontology::{Container, EvidenceLevel, SpaceId}; +use ruview_track::*; + +fn space(id: &str) -> Container { + Container::Space { + id: SpaceId::new(id).unwrap(), + } +} + +fn feat(v: &[f64]) -> CoarseFeature { + CoarseFeature::quantize(v).unwrap() +} + +/// kitchen ▸ hallway ▸ bedroom, wired as a corridor. +fn corridor() -> Topology { + let mut t = Topology::new(); + t.connect(&space("kitchen"), &space("hallway")); + t.connect(&space("hallway"), &space("bedroom")); + t +} + +#[test] +fn single_target_continuity_across_zones() { + let mut mgr = TrackManager::new(TrackerConfig::default(), corridor()); + let f = feat(&[0.2, 0.6, 0.3]); + + let o1 = mgr + .ingest(Detection::new(space("kitchen"), [1.0, 1.0], f.clone(), 1_000).unwrap()) + .unwrap(); + let o2 = mgr + .ingest(Detection::new(space("hallway"), [1.3, 1.1], f.clone(), 1_500).unwrap()) + .unwrap(); + let o3 = mgr + .ingest(Detection::new(space("bedroom"), [1.6, 1.0], f, 2_000).unwrap()) + .unwrap(); + + // One persistent entity, one pseudonym across all three rooms. + assert_eq!(mgr.len(), 1); + assert_eq!(o1.person, o2.person); + assert_eq!(o2.person, o3.person); + assert!(matches!(o2.association, Association::Matched { .. })); + assert!(matches!(o3.association, Association::Matched { .. })); + + // History reads kitchen -> hallway -> bedroom. + let traj = mgr.trajectory(&o1.track).unwrap(); + assert_eq!( + traj, + vec![space("kitchen"), space("hallway"), space("bedroom")] + ); +} + +#[test] +fn topology_blocks_non_adjacent_handoff() { + // kitchen and bedroom are NOT adjacent (no hallway hop recorded here). + let mut t = Topology::new(); + t.connect(&space("kitchen"), &space("hallway")); + let mut mgr = TrackManager::new(TrackerConfig::default(), t); + let f = feat(&[0.2, 0.6, 0.3]); + + let a = mgr + .ingest(Detection::new(space("kitchen"), [1.0, 1.0], f.clone(), 1_000).unwrap()) + .unwrap(); + let b = mgr + .ingest(Detection::new(space("bedroom"), [1.0, 1.0], f, 1_200).unwrap()) + .unwrap(); + + // Non-adjacent: a fresh pseudonym rather than a false join. + assert_ne!(a.person, b.person); + assert!(matches!( + b.association, + Association::Unknown { + reason: UnknownReason::TopologyBlocked, + .. + } + )); + assert_eq!(mgr.len(), 2); +} + +#[test] +fn two_targets_no_swap_under_separation() { + let mut mgr = TrackManager::with_defaults(); // single space, empty topology + let fa = feat(&[0.1, 0.1, 0.1]); + let fb = feat(&[0.9, 0.9, 0.9]); + + // Frame 1: two well-separated detections spawn two tracks. + let f1 = mgr + .ingest_frame(&[ + Detection::new(space("kitchen"), [0.0, 0.0], fa.clone(), 1_000).unwrap(), + Detection::new(space("kitchen"), [10.0, 0.0], fb.clone(), 1_000).unwrap(), + ]) + .unwrap(); + let (pa, pb) = (f1[0].person.clone(), f1[1].person.clone()); + let (ta, tb) = (f1[0].track.clone(), f1[1].track.clone()); + assert_ne!(pa, pb); + + // Several frames of parallel motion, staying separated. + for k in 1..=5 { + let t = 1_000 + k * 200; + let x = k as f64 * 0.1; + let out = mgr + .ingest_frame(&[ + Detection::new(space("kitchen"), [x, 0.0], fa.clone(), t).unwrap(), + Detection::new(space("kitchen"), [10.0 + x, 0.0], fb.clone(), t).unwrap(), + ]) + .unwrap(); + // Each detection stays with its own original track — no swap. + assert_eq!(out[0].track, ta); + assert_eq!(out[1].track, tb); + assert_eq!(out[0].person, pa); + assert_eq!(out[1].person, pb); + } + assert_eq!(mgr.len(), 2); +} + +#[test] +fn track_spawn_and_expire() { + let mut mgr = TrackManager::with_defaults(); + let f = feat(&[0.5]); + let out = mgr + .ingest(Detection::new(space("kitchen"), [0.0, 0.0], f.clone(), 1_000).unwrap()) + .unwrap(); + assert_eq!(mgr.len(), 1); + assert!(matches!( + out.association, + Association::Unknown { + reason: UnknownReason::NoCandidate, + .. + } + )); + assert_eq!(mgr.state(&out.track), Some(TrackState::Tentative)); + + // A second hit confirms the track (default confirm_after = 2). + let out2 = mgr + .ingest(Detection::new(space("kitchen"), [0.1, 0.0], f, 1_100).unwrap()) + .unwrap(); + assert_eq!(out2.track, out.track); + assert_eq!(mgr.state(&out.track), Some(TrackState::Active)); + + // Within the horizon: idle-but-alive (lost), not expired. + let horizon = mgr.config().max_coast_ms; + let expired = mgr.tick(1_100 + horizon); + assert!(expired.is_empty()); + assert_eq!(mgr.len(), 1); + assert_eq!(mgr.state(&out.track), Some(TrackState::Lost)); + + // Past the horizon: expired and dropped. + let expired = mgr.tick(1_100 + horizon + 1); + assert_eq!(expired, vec![out.track.clone()]); + assert_eq!(mgr.len(), 0); + assert_eq!(mgr.state(&out.track), None); +} + +#[test] +fn beyond_horizon_mints_fresh_pseudonym() { + let mut mgr = TrackManager::with_defaults(); + let f = feat(&[0.5, 0.5]); + let a = mgr + .ingest(Detection::new(space("kitchen"), [0.0, 0.0], f.clone(), 1_000).unwrap()) + .unwrap(); + let horizon = mgr.config().max_coast_ms; + // Same place and feature, but long after the horizon: under-link, do not join. + let b = mgr + .ingest(Detection::new(space("kitchen"), [0.0, 0.0], f, 1_000 + horizon + 500).unwrap()) + .unwrap(); + assert_ne!(a.person, b.person); + assert!(matches!(b.association, Association::Unknown { .. })); +} + +#[test] +fn ambiguous_detection_stays_tentative_not_misassigned() { + // Confirm immediately so the two seed tracks are active and equal-footing. + let cfg = TrackerConfig { + confirm_after: 1, + ..TrackerConfig::default() + }; + let mut mgr = TrackManager::new(cfg, Topology::new()); + let f = feat(&[0.5, 0.5]); + + // Two tracks with identical features, symmetric about the origin. Their + // separation (3.0) exceeds the position gate (2.0) so they stay distinct, + // yet each sits within the gate of the midpoint. + let a = mgr + .ingest(Detection::new(space("kitchen"), [-1.5, 0.0], f.clone(), 1_000).unwrap()) + .unwrap(); + let b = mgr + .ingest(Detection::new(space("kitchen"), [1.5, 0.0], f.clone(), 1_000).unwrap()) + .unwrap(); + assert_eq!(mgr.len(), 2); + + // A detection exactly between them, same feature: equidistant → ambiguous. + let mid = mgr + .ingest(Detection::new(space("kitchen"), [0.0, 0.0], f, 1_100).unwrap()) + .unwrap(); + + assert!(matches!( + mid.association, + Association::Unknown { + reason: UnknownReason::Ambiguous, + .. + } + )); + // It was NOT attached to either existing track — a third pseudonym. + assert_ne!(mid.person, a.person); + assert_ne!(mid.person, b.person); + assert_eq!(mgr.len(), 3); +} + +#[test] +fn pseudonyms_are_opaque_and_rotatable_with_no_civil_fields() { + let mut mgr = TrackManager::with_defaults(); + let out = mgr + .ingest(Detection::new(space("kitchen"), [0.0, 0.0], feat(&[0.3]), 1_000).unwrap()) + .unwrap(); + + // Opaque synthetic form, no civil identifier embedded. + let pid = out.person.as_str().to_string(); + assert!(pid.starts_with("person_")); + + // Rotate: new opaque id, same track and history preserved. + let before = mgr.trajectory(&out.track).unwrap(); + let rotated = mgr.rotate_pseudonym(&out.track).unwrap(); + assert_ne!(rotated.as_str(), pid); + assert!(rotated.as_str().starts_with("person_")); + assert_eq!(mgr.person_of(&out.track), Some(&rotated)); + assert_eq!(mgr.trajectory(&out.track).unwrap(), before); + + // The emitted canonical Person/Track carry no civil-identity field. + let person = mgr.to_person(&out.track).unwrap(); + let track = mgr.to_track(&out.track).unwrap(); + let pj = serde_json::to_string(&person).unwrap(); + let tj = serde_json::to_string(&track).unwrap(); + for forbidden in ["name", "mac", "email", "phone", "account", "ssid"] { + assert!(!pj.contains(forbidden), "person leaked `{forbidden}`: {pj}"); + assert!(!tj.contains(forbidden), "track leaked `{forbidden}`: {tj}"); + } + // Tentative track is floored to L0; feature never appears in the node. + assert_eq!(person.evidence_level, EvidenceLevel::L0); + assert!(!pj.contains("bins")); +} + +#[test] +fn ingest_is_deterministic() { + fn run() -> Vec<(String, String)> { + let mut mgr = TrackManager::new(TrackerConfig::default(), corridor()); + let script = [ + (space("kitchen"), [0.0, 0.0], vec![0.1, 0.2], 1_000i64), + (space("kitchen"), [5.0, 0.0], vec![0.8, 0.9], 1_000), + (space("hallway"), [0.3, 0.1], vec![0.1, 0.2], 1_400), + (space("hallway"), [5.3, 0.1], vec![0.8, 0.9], 1_400), + (space("bedroom"), [0.6, 0.0], vec![0.1, 0.2], 1_800), + ]; + let mut trace = Vec::new(); + for (c, p, v, t) in script { + let o = mgr + .ingest(Detection::new(c, p, feat(&v), t).unwrap()) + .unwrap(); + let kind = match o.association { + Association::Matched { .. } => "matched", + Association::Unknown { .. } => "unknown", + }; + trace.push((o.person.as_str().to_string(), kind.to_string())); + } + trace + } + assert_eq!(run(), run()); +} + +#[test] +fn malformed_position_is_a_boundary_error_not_unknown() { + // NaN position is rejected at the boundary — distinct from association UNKNOWN. + let err = Detection::new(space("kitchen"), [f64::NAN, 0.0], feat(&[0.5]), 1_000); + assert_eq!(err.unwrap_err(), TrackError::NonFinitePosition); +} diff --git a/v2/crates/ruview-twin/Cargo.toml b/v2/crates/ruview-twin/Cargo.toml new file mode 100644 index 00000000..3df98bc6 --- /dev/null +++ b/v2/crates/ruview-twin/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "ruview-twin" +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 diff --git a/v2/crates/ruview-twin/src/delta.rs b/v2/crates/ruview-twin/src/delta.rs new file mode 100644 index 00000000..6bd8e065 --- /dev/null +++ b/v2/crates/ruview-twin/src/delta.rs @@ -0,0 +1,215 @@ +//! The load-bearing operation: `delta(observed, expected)` (ADR-315 §2). +//! +//! **SYNTHETIC / L0.** A [`TwinDelta`] is a *model-relative* statement: how far a +//! supplied observation set sits from the twin's own predicted distributions, +//! measured against the twin's own modelled variance. It is **not** a detection, +//! and asserts no accuracy (ADR-315 evidence discipline, ADR-300). A change that +//! is large relative to the modelled variance is a *candidate physical change* +//! to be corroborated, never a confident claim. +//! +//! Consistent with ADR-300 rule 1, a link the twin cannot evaluate — unknown +//! prediction, or an observation for a link outside the twin — is reported as +//! [`LinkDeltaStatus::Unknown`], excluded from the aggregate magnitude, never an +//! error. + +use serde::{Deserialize, Serialize}; + +use crate::predict::{predict_link, ExpectedDistribution, UnknownReason}; +use crate::twin::{LinkId, RfTwin}; + +/// Default significance threshold (standard deviations). A link whose absolute +/// deviation exceeds this many modelled standard deviations is flagged as +/// deviating. `3.0` ≈ a conventional 3-sigma gate; it is a model gate, not a +/// calibrated false-alarm rate. +pub const DEFAULT_SIGNIFICANCE_THRESHOLD: f64 = 3.0; + +/// One observed observable value for a link (e.g. a measured mean RSSI, dBm). +/// The value is caller-supplied; this crate never samples a clock or sensor. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct LinkObservation { + /// The link this observation is for. + pub link: LinkId, + /// The observed value, in the observable's units (dBm for RSSI). + pub value: f64, +} + +/// A supplied set of link observations to compare against the twin. +#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)] +pub struct ObservationSet { + /// The observations. Order is not significant; duplicate links use the first. + pub observations: Vec, +} + +impl ObservationSet { + /// An empty observation set. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Add one observation and return `self` for chaining. + #[must_use] + pub fn with(mut self, link: LinkId, value: f64) -> Self { + self.observations.push(LinkObservation { link, value }); + self + } + + /// Build the observation set that exactly reproduces a twin's own predicted + /// means — the *zero-delta* reference. Links the twin cannot predict are + /// omitted (they would only surface as UNKNOWN). + #[must_use] + pub fn from_twin_prediction(twin: &RfTwin) -> Self { + let mut observations = Vec::new(); + for link in twin.links() { + if let ExpectedDistribution::Known { mean, .. } = predict_link(twin, &link) { + observations.push(LinkObservation { link, value: mean }); + } + } + Self { observations } + } +} + +/// Outcome for a single link in a delta computation. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "status", rename_all = "snake_case")] +pub enum LinkDeltaStatus { + /// The link was evaluated against a known distribution. + Evaluated { + /// Supplied observed value. + observed: f64, + /// Twin's modelled mean. + expected_mean: f64, + /// Twin's modelled variance (dB²). + expected_variance: f64, + /// `observed - expected_mean`, signed. + deviation: f64, + /// `|deviation| / sqrt(variance)`, i.e. standard deviations. `None` when + /// variance is zero (significance is undefined, reported as UNKNOWN-ish + /// rather than infinite). + #[serde(skip_serializing_if = "Option::is_none")] + significance: Option, + }, + /// The link could not be evaluated; first-class UNKNOWN (ADR-300 rule 1). + Unknown { + /// Why it is unknown. + reason: UnknownReason, + }, +} + +/// The delta for one link. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct LinkDelta { + /// The link. + pub link: LinkId, + /// Its outcome. + pub status: LinkDeltaStatus, +} + +/// The typed result of `delta(observed, expected)` over an observation set. +/// +/// **SYNTHETIC / L0.** `total_magnitude` and `deviating_links` are model-relative +/// summaries, not a detection or accuracy claim. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct TwinDelta { + /// Twin baseline version this delta is relative to (ADR-315 §2). + pub baseline_version: u64, + /// Significance threshold (standard deviations) used to flag deviating links. + pub significance_threshold: f64, + /// L2 norm of evaluated per-link deviations — the overall magnitude of the + /// change against the twin. `0.0` exactly when every evaluated link matches + /// its prediction. + pub total_magnitude: f64, + /// Largest per-link significance among evaluated links (`0.0` if none). + pub max_significance: f64, + /// Links whose significance meets or exceeds the threshold — *which* links + /// deviate. + pub deviating_links: Vec, + /// Links present in the observation set but not evaluable (UNKNOWN). + pub unknown_links: Vec, + /// Per-link detail for every observation supplied. + pub per_link: Vec, +} + +impl TwinDelta { + /// True when no evaluated link deviates beyond the threshold. UNKNOWN links + /// do not count as changes (they are reported separately). + #[must_use] + pub fn is_zero(&self) -> bool { + self.deviating_links.is_empty() && self.total_magnitude == 0.0 + } +} + +/// Compute the delta of a supplied observation set against the twin's predicted +/// distributions, using [`DEFAULT_SIGNIFICANCE_THRESHOLD`]. +#[must_use] +pub fn compute_delta(twin: &RfTwin, observed: &ObservationSet) -> TwinDelta { + compute_delta_with_threshold(twin, observed, DEFAULT_SIGNIFICANCE_THRESHOLD) +} + +/// Compute the delta with an explicit significance threshold (standard +/// deviations). Deterministic and allocation-bounded by the observation count. +#[must_use] +pub fn compute_delta_with_threshold( + twin: &RfTwin, + observed: &ObservationSet, + significance_threshold: f64, +) -> TwinDelta { + let mut per_link = Vec::with_capacity(observed.observations.len()); + let mut deviating_links = Vec::new(); + let mut unknown_links = Vec::new(); + let mut sum_sq = 0.0_f64; + let mut max_significance = 0.0_f64; + + for obs in &observed.observations { + match predict_link(twin, &obs.link) { + ExpectedDistribution::Known { + mean, variance, .. + } => { + let deviation = obs.value - mean; + let significance = if variance > 0.0 { + let sig = deviation.abs() / variance.sqrt(); + if sig > max_significance { + max_significance = sig; + } + if sig >= significance_threshold { + deviating_links.push(obs.link.clone()); + } + Some(sig) + } else { + // Zero modelled variance: significance is undefined. A + // non-zero deviation still contributes to magnitude, but we + // do not fabricate an infinite significance. + None + }; + sum_sq += deviation * deviation; + per_link.push(LinkDelta { + link: obs.link.clone(), + status: LinkDeltaStatus::Evaluated { + observed: obs.value, + expected_mean: mean, + expected_variance: variance, + deviation, + significance, + }, + }); + } + ExpectedDistribution::Unknown { reason } => { + unknown_links.push(obs.link.clone()); + per_link.push(LinkDelta { + link: obs.link.clone(), + status: LinkDeltaStatus::Unknown { reason }, + }); + } + } + } + + TwinDelta { + baseline_version: twin.version, + significance_threshold, + total_magnitude: sum_sq.sqrt(), + max_significance, + deviating_links, + unknown_links, + per_link, + } +} diff --git a/v2/crates/ruview-twin/src/lib.rs b/v2/crates/ruview-twin/src/lib.rs new file mode 100644 index 00000000..9c51d2b6 --- /dev/null +++ b/v2/crates/ruview-twin/src/lib.rs @@ -0,0 +1,390 @@ +//! # `ruview-twin` — a digital RF twin (ADR-315, ADR-300 phase 3) +//! +//! **SYNTHETIC / L0 — a simulation scaffold, not a measurement system.** +//! +//! This crate is a *research-forward primitive*: a persistent, versioned, +//! per-deployment **model** of an RF environment. A twin **predicts** an expected +//! observable; it never **measures** one. Every distribution it produces and any +//! propagation it simulates is a model at evidence level `L0` (ADR-282), +//! labelled `SYNTHETIC`. Nothing in this crate is a hardware, `MEASURED`, or +//! accuracy claim, and it asserts **no** detection-accuracy number (ADR-315 +//! evidence discipline). Following ADR-300 rule 1, *insufficient information* is +//! a first-class value ([`ExpectedDistribution::Unknown`] / +//! [`LinkDeltaStatus::Unknown`]), never an error and never a confident default. +//! +//! ## What the twin holds +//! +//! - **Radio node positions** in coarse metric coordinates ([`RadioNode`], +//! [`Point3`]). +//! - **Geometry references** into the canonical ontology ([`SpaceId`], +//! [`Container`]) — the twin *annotates* the ADR-306 scene, it does not invent +//! a second geometry. +//! - A **simple documented propagation model** — log-distance path loss with +//! optional wall attenuation ([`PropagationParams`], [`crate::predict`]), +//! clearly a SYNTHETIC model, not real RF. +//! - **Recorded multipath / calibration state** ([`MultipathRecord`], +//! [`RfTwin::calibration_version`]). +//! - An **[`ExpectedDistribution`] per link** — the mean/variance of an +//! observable under the twin. +//! +//! ## The load-bearing operation +//! +//! [`RfTwin::delta`] compares a supplied observation set to the twin's +//! predictions and returns a typed [`TwinDelta`] with an overall magnitude and +//! *which* links deviate, each scored against the twin's own modelled variance. +//! A physical change becomes a *measurable delta against the twin* — a candidate +//! change to corroborate, never a confident detection. +//! +//! ## Determinism +//! +//! Everything is deterministic. Synthetic scenes are varied by an explicit +//! [`seed`](DeploymentDescription::seed) via [`synthetic_deployment`]; there is +//! no wall-clock, no unseeded randomness, and no I/O anywhere in the crate. +//! +//! ``` +//! use ruview_twin::*; +//! +//! // A reproducible synthetic deployment, then its zero-delta reference. +//! let twin = RfTwin::build(synthetic_deployment(7)).unwrap(); +//! let observed = ObservationSet::from_twin_prediction(&twin); +//! let delta = twin.delta(&observed); +//! assert!(delta.is_zero()); // observation matches prediction ⇒ zero delta +//! assert_eq!(twin.evidence_level, ruview_ontology::EvidenceLevel::L0); +//! ``` + +#![forbid(unsafe_code)] +#![warn(missing_docs)] + +mod delta; +mod predict; +mod twin; + +pub use delta::{ + compute_delta, compute_delta_with_threshold, LinkDelta, LinkDeltaStatus, LinkObservation, + ObservationSet, TwinDelta, DEFAULT_SIGNIFICANCE_THRESHOLD, +}; +pub use predict::{ + path_loss_db, predict_all, predict_link, wall_attenuation_db, ExpectedDistribution, Observable, + UnknownReason, +}; +pub use twin::{ + DeploymentDescription, LinkId, MultipathRecord, Point3, PropagationParams, RadioNode, RfTwin, + TwinError, VersionEvent, Wall, MAX_NODES, MAX_WALLS, +}; + +// Re-export the canonical ontology vocabulary the twin references, so consumers +// speak one semantics (ADR-300 rule 3, ADR-306). +pub use ruview_ontology::{Container, EvidenceLevel, SensorId, SpaceId}; + +impl RfTwin { + /// Predict the expected distribution for a link. See [`predict_link`]. + #[must_use] + pub fn predict(&self, link: &LinkId) -> ExpectedDistribution { + predict_link(self, link) + } + + /// Compute the delta of a supplied observation set against this twin, using + /// the default significance threshold. See [`compute_delta`]. + #[must_use] + pub fn delta(&self, observed: &ObservationSet) -> TwinDelta { + compute_delta(self, observed) + } +} + +/// Build a deterministic **SYNTHETIC** deployment from an explicit `seed`. +/// +/// Four radios are placed in a `5 m × 4 m` room with one interior wall. Node +/// positions are jittered by a seeded `splitmix64` stream so distinct seeds give +/// distinct-but-reproducible scenes; the same seed always yields the same scene. +/// This is a simulation fixture, not a model of any real room. +#[must_use] +pub fn synthetic_deployment(seed: u64) -> DeploymentDescription { + let mut state = seed; + // Deterministic jitter helper in [-0.5, 0.5] metres. + let jitter = |s: &mut u64| -> f64 { splitmix64_unit(s) - 0.5 }; + + let base = [(0.5, 0.5), (4.5, 0.5), (4.5, 3.5), (0.5, 3.5)]; + let nodes: Vec = base + .iter() + .enumerate() + .map(|(i, (bx, by))| { + let x = (bx + jitter(&mut state)).clamp(0.0, 5.0); + let y = (by + jitter(&mut state)).clamp(0.0, 4.0); + RadioNode { + id: SensorId::new(format!("node-{i}")).expect("static id is valid"), + position: Point3::new(x, y, 1.0), + located_in: Container::Space { + id: SpaceId::new(format!("space-{seed}")).expect("static id is valid"), + }, + tx_power_dbm: 20.0, + } + }) + .collect(); + + let walls = vec![Wall { + id: "interior-wall".to_string(), + a: (2.5, 0.0), + b: (2.5, 4.0), + attenuation_db: 6.0, + }]; + + DeploymentDescription { + space: SpaceId::new(format!("space-{seed}")).expect("static id is valid"), + nodes, + walls, + params: PropagationParams::default_indoor(), + multipath: Vec::new(), + calibration_version: "synthetic-cal-v0".to_string(), + seed, + } +} + +/// One `splitmix64` step mapped to a unit `f64` in `[0, 1)`. Deterministic; the +/// only source of scene variation in the crate. +fn splitmix64_unit(state: &mut u64) -> f64 { + *state = state.wrapping_add(0x9E37_79B9_7F4A_7C15); + let mut z = *state; + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + z ^= z >> 31; + // Top 53 bits → [0, 1). + ((z >> 11) as f64) / ((1u64 << 53) as f64) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sensor(id: &str) -> SensorId { + SensorId::new(id).unwrap() + } + + #[test] + fn expected_distribution_is_deterministic() { + // Same seed ⇒ identical twin ⇒ identical predictions, exactly. + let a = RfTwin::build(synthetic_deployment(42)).unwrap(); + let b = RfTwin::build(synthetic_deployment(42)).unwrap(); + assert_eq!(a, b); + + for link in a.links() { + let da = a.predict(&link); + let db = b.predict(&link); + assert_eq!(da, db); + // Every link in this fixture is predictable (SYNTHETIC/L0). + let (mean, var) = da.known().expect("known distribution"); + assert!(mean.is_finite()); + assert!(var > 0.0); + } + + // Distinct seeds give distinct-but-reproducible scenes. + let c = RfTwin::build(synthetic_deployment(43)).unwrap(); + assert_ne!(a, c); + } + + #[test] + fn zero_delta_when_observation_matches_prediction() { + let twin = RfTwin::build(synthetic_deployment(1)).unwrap(); + let observed = ObservationSet::from_twin_prediction(&twin); + let delta = twin.delta(&observed); + + assert!(delta.is_zero()); + assert_eq!(delta.total_magnitude, 0.0); + assert!(delta.deviating_links.is_empty()); + assert!(delta.unknown_links.is_empty()); + assert_eq!(delta.baseline_version, 1); + // Every per-link deviation is exactly zero. + for ld in &delta.per_link { + match &ld.status { + LinkDeltaStatus::Evaluated { deviation, significance, .. } => { + assert_eq!(*deviation, 0.0); + assert_eq!(*significance, Some(0.0)); + } + LinkDeltaStatus::Unknown { .. } => panic!("unexpected unknown link"), + } + } + } + + #[test] + fn delta_is_nonzero_and_localized_to_a_moved_node() { + // Baseline twin and its self-consistent observation set. + let twin = RfTwin::build(synthetic_deployment(2)).unwrap(); + let baseline_obs = ObservationSet::from_twin_prediction(&twin); + + // Build a moved-node world: shift exactly one node, predict from it, and + // treat those predictions as the "observed" set against the baseline. + let mut moved = synthetic_deployment(2); + let moved_id = moved.nodes[0].id.clone(); + moved.nodes[0].position.x += 2.0; // a clear physical relocation + let moved_twin = RfTwin::build(moved).unwrap(); + let observed = ObservationSet::from_twin_prediction(&moved_twin); + + let delta = twin.delta(&observed); + + // A physical change produces a non-zero, significant delta. + assert!(delta.total_magnitude > 0.0); + assert!(!delta.deviating_links.is_empty()); + assert!(delta.max_significance >= delta.significance_threshold); + + // The change is localized: every deviating link touches the moved node, + // and links not touching it match the baseline exactly. + for link in &delta.deviating_links { + assert!(link.a == moved_id || link.b == moved_id, "deviation off the moved node"); + } + for ld in &delta.per_link { + let touches_moved = ld.link.a == moved_id || ld.link.b == moved_id; + if let LinkDeltaStatus::Evaluated { deviation, .. } = &ld.status { + if !touches_moved { + assert_eq!(*deviation, 0.0, "untouched link should not deviate"); + } + } + } + + // Sanity: the untouched baseline observations still yield zero delta. + assert!(twin.delta(&baseline_obs).is_zero()); + } + + #[test] + fn delta_is_localized_to_a_new_reflector() { + let twin = RfTwin::build(synthetic_deployment(3)).unwrap(); + + // Add a new reflector that crosses exactly the node-0 ↔ node-1 path + // (both near y≈0.5) without crossing the far links. + let mut with_reflector = synthetic_deployment(3); + let n0 = with_reflector.nodes[0].id.clone(); + let n1 = with_reflector.nodes[1].id.clone(); + let (x0, _) = with_reflector.nodes[0].position.xy(); + let (x1, _) = with_reflector.nodes[1].position.xy(); + let mid_x = (x0 + x1) / 2.0; + with_reflector.walls.push(Wall { + id: "new-reflector".into(), + a: (mid_x, 0.0), + b: (mid_x, 1.2), + attenuation_db: 12.0, + }); + let reflector_twin = RfTwin::build(with_reflector).unwrap(); + let observed = ObservationSet::from_twin_prediction(&reflector_twin); + + let delta = twin.delta(&observed); + assert!(delta.total_magnitude > 0.0); + let target = LinkId::new(n0, n1); + // The n0-n1 link deviates; it is the crossed path. + let target_delta = delta + .per_link + .iter() + .find(|ld| ld.link == target) + .expect("target link present"); + match &target_delta.status { + LinkDeltaStatus::Evaluated { deviation, .. } => assert!(deviation.abs() > 0.0), + LinkDeltaStatus::Unknown { .. } => panic!("target should be evaluable"), + } + } + + #[test] + fn unknown_is_first_class_not_an_error() { + let twin = RfTwin::build(synthetic_deployment(5)).unwrap(); + + // Predicting a link to a node that does not exist ⇒ UNKNOWN, not panic. + let ghost = LinkId::new(sensor("node-0"), sensor("ghost")); + assert!(matches!( + twin.predict(&ghost), + ExpectedDistribution::Unknown { reason: UnknownReason::MissingNode } + )); + + // Observing an out-of-twin link surfaces as an unknown link in the delta. + let observed = ObservationSet::new().with(ghost.clone(), -50.0); + let delta = twin.delta(&observed); + assert_eq!(delta.unknown_links, vec![ghost]); + assert_eq!(delta.total_magnitude, 0.0); + assert!(delta.deviating_links.is_empty()); + + // A self-link is UNKNOWN too, never a divide-by-zero. + let self_link = LinkId::new(sensor("node-0"), sensor("node-0")); + assert!(matches!( + twin.predict(&self_link), + ExpectedDistribution::Unknown { reason: UnknownReason::SelfLink } + )); + } + + #[test] + fn boundary_validation_rejects_malformed_input_without_panic() { + // Non-finite coordinate. + let mut d = synthetic_deployment(9); + d.nodes[0].position.x = f64::NAN; + assert!(matches!( + RfTwin::build(d), + Err(TwinError::NonFiniteCoordinate { .. }) + )); + + // Duplicate node id. + let mut d = synthetic_deployment(9); + let dup = d.nodes[0].id.clone(); + d.nodes[1].id = dup; + assert!(matches!(RfTwin::build(d), Err(TwinError::DuplicateNode { .. }))); + + // Invalid propagation parameter. + let mut d = synthetic_deployment(9); + d.params.path_loss_exponent = 0.0; + assert!(matches!( + RfTwin::build(d), + Err(TwinError::InvalidParameter { .. }) + )); + + // Too many nodes (bounded allocation). Construct a minimal over-limit + // description directly to avoid allocating a huge scene twice. + let mut nodes = Vec::new(); + for i in 0..(MAX_NODES + 1) { + nodes.push(RadioNode { + id: sensor(&format!("n{i}")), + position: Point3::new(0.0, 0.0, 0.0), + located_in: Container::Space { id: SpaceId::new("s").unwrap() }, + tx_power_dbm: 20.0, + }); + } + let over = DeploymentDescription { + space: SpaceId::new("s").unwrap(), + nodes, + walls: Vec::new(), + params: PropagationParams::default_indoor(), + multipath: Vec::new(), + calibration_version: "v0".into(), + seed: 0, + }; + assert!(matches!(RfTwin::build(over), Err(TwinError::TooManyNodes { .. }))); + } + + #[test] + fn versioning_advances_on_events() { + let mut twin = RfTwin::build(synthetic_deployment(11)).unwrap(); + assert_eq!(twin.version, 1); + assert_eq!(twin.advance_version(VersionEvent::Calibration), 2); + assert_eq!(twin.advance_version(VersionEvent::GeometryEdit), 3); + assert_eq!(twin.advance_version(VersionEvent::AcceptedChange), 4); + assert_eq!(twin.version, 4); + } + + #[test] + fn serde_round_trip_is_lossless() { + let mut twin = RfTwin::build(synthetic_deployment(13)).unwrap(); + twin.multipath.push(MultipathRecord { + link: LinkId::new(sensor("node-0"), sensor("node-1")), + extra_variance_db2: 9.0, + }); + + let json = serde_json::to_string_pretty(&twin).unwrap(); + let back: RfTwin = serde_json::from_str(&json).unwrap(); + assert_eq!(twin, back); + + // Evidence discipline is on the wire: L0 / SYNTHETIC. + assert!(json.contains("\"evidence_level\": \"L0\"")); + + // The delta result also round-trips. + let observed = ObservationSet::from_twin_prediction(&twin).with( + LinkId::new(sensor("node-0"), sensor("node-2")), + -80.0, + ); + let delta = twin.delta(&observed); + let dj = serde_json::to_string(&delta).unwrap(); + let back_delta: TwinDelta = serde_json::from_str(&dj).unwrap(); + assert_eq!(delta, back_delta); + } +} diff --git a/v2/crates/ruview-twin/src/predict.rs b/v2/crates/ruview-twin/src/predict.rs new file mode 100644 index 00000000..6d65f895 --- /dev/null +++ b/v2/crates/ruview-twin/src/predict.rs @@ -0,0 +1,205 @@ +//! The forward model: expected distribution per link (ADR-315 §1). +//! +//! **SYNTHETIC / L0.** This module is a *simulation* of an observable, not a +//! measurement. It implements a deliberately simple, documented log-distance +//! path-loss model with optional wall attenuation — clearly a didactic model, +//! not real RF. Nothing here is a hardware, `MEASURED`, or accuracy claim. +//! +//! An [`ExpectedDistribution`] is the mean/variance of a modelled observable +//! under the twin. Consistent with ADR-300 rule 1, insufficient information is +//! reported as [`ExpectedDistribution::Unknown`] — a first-class value, never an +//! error or a confident default. + +use serde::{Deserialize, Serialize}; + +use crate::twin::{LinkId, PropagationParams, RfTwin, Wall}; + +/// The modelled observable a distribution describes. Kept as an enum so the twin +/// can grow phenomena without changing the delta contract. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Observable { + /// Modelled received signal strength, dBm (SYNTHETIC). + Rssi, +} + +/// Why a link's expected distribution is unknown. UNKNOWN is a first-class +/// output (ADR-300 rule 1), not an error. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum UnknownReason { + /// One or both endpoints are not present in the twin. + MissingNode, + /// The two endpoints are effectively coincident, so path loss is undefined. + ZeroDistance, + /// The endpoints are the same node. + SelfLink, + /// The modelled computation produced a non-finite value. + NonFinite, +} + +/// The predicted distribution of an observable over a link under the twin. +/// +/// **SYNTHETIC / L0.** A model-relative statement, never evidence of a physical +/// state. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "status", rename_all = "snake_case")] +pub enum ExpectedDistribution { + /// A modelled mean and (non-negative) variance for `observable`. + Known { + /// Which observable this describes. + observable: Observable, + /// Modelled mean, in the observable's units (dBm for RSSI). + mean: f64, + /// Modelled variance, in the observable's units squared (dB²). + variance: f64, + }, + /// The twin cannot predict this link; carries a first-class reason. + Unknown { + /// Why it is unknown. + reason: UnknownReason, + }, +} + +impl ExpectedDistribution { + /// Borrow `(mean, variance)` when known. + #[must_use] + pub fn known(&self) -> Option<(f64, f64)> { + match self { + ExpectedDistribution::Known { mean, variance, .. } => Some((*mean, *variance)), + ExpectedDistribution::Unknown { .. } => None, + } + } +} + +/// Below this distance (metres) two radios are treated as coincident and the +/// path loss is left [`UnknownReason::ZeroDistance`] rather than diverging. +const MIN_DISTANCE_M: f64 = 1e-6; + +/// SYNTHETIC log-distance path loss, decibels: +/// `PL(d) = PL(d0) + 10·n·log10(d / d0) + Σ wall_attenuation`. +/// +/// Returns `None` if the result is non-finite. `d` must already be `>=` +/// [`MIN_DISTANCE_M`]. +#[must_use] +pub fn path_loss_db(params: &PropagationParams, distance_m: f64, wall_attenuation_db: f64) -> Option { + let pl = params.reference_loss_db + + 10.0 * params.path_loss_exponent * (distance_m / params.reference_distance_m).log10() + + wall_attenuation_db; + pl.is_finite().then_some(pl) +} + +/// Total attenuation (dB) added by every wall whose floor-plan segment the +/// link's straight path crosses. Deterministic; walls are visited in order. +#[must_use] +pub fn wall_attenuation_db(walls: &[Wall], a_xy: (f64, f64), b_xy: (f64, f64)) -> f64 { + let mut sum = 0.0; + for wall in walls { + if segments_intersect(a_xy, b_xy, wall.a, wall.b) { + sum += wall.attenuation_db; + } + } + sum +} + +/// Predict the expected distribution for one link under the twin. +/// +/// **SYNTHETIC / L0.** The modelled transmitter is the link's canonical `a` +/// endpoint (lower id); the mean is `tx_power - PL(d)` and the variance is the +/// base shadowing variance plus any recorded multipath variance. +#[must_use] +pub fn predict_link(twin: &RfTwin, link: &LinkId) -> ExpectedDistribution { + if link.is_self_link() { + return ExpectedDistribution::Unknown { + reason: UnknownReason::SelfLink, + }; + } + let (tx, rx) = match (twin.node(&link.a), twin.node(&link.b)) { + (Some(tx), Some(rx)) => (tx, rx), + _ => { + return ExpectedDistribution::Unknown { + reason: UnknownReason::MissingNode, + } + } + }; + + let distance = tx.position.distance_to(&rx.position); + if distance < MIN_DISTANCE_M { + return ExpectedDistribution::Unknown { + reason: UnknownReason::ZeroDistance, + }; + } + + let wall_att = wall_attenuation_db(&twin.walls, tx.position.xy(), rx.position.xy()); + let pl = match path_loss_db(&twin.params, distance, wall_att) { + Some(pl) => pl, + None => { + return ExpectedDistribution::Unknown { + reason: UnknownReason::NonFinite, + } + } + }; + + let mean = tx.tx_power_dbm - pl; + let base_var = twin.params.shadowing_sigma_db * twin.params.shadowing_sigma_db; + let variance = base_var + twin.extra_variance(link); + + if !(mean.is_finite() && variance.is_finite()) { + return ExpectedDistribution::Unknown { + reason: UnknownReason::NonFinite, + }; + } + + ExpectedDistribution::Known { + observable: Observable::Rssi, + mean, + variance, + } +} + +/// Predict every link in the twin, paired with its distribution. Deterministic +/// ordering (matches [`RfTwin::links`](crate::twin::RfTwin::links)). +#[must_use] +pub fn predict_all(twin: &RfTwin) -> Vec<(LinkId, ExpectedDistribution)> { + twin.links() + .into_iter() + .map(|link| { + let dist = predict_link(twin, &link); + (link, dist) + }) + .collect() +} + +/// Robust 2D segment-intersection test used for wall crossing. Pure integer-free +/// geometry with an orientation sign; deterministic and allocation-free. +fn segments_intersect(p1: (f64, f64), p2: (f64, f64), p3: (f64, f64), p4: (f64, f64)) -> bool { + let d1 = orientation(p3, p4, p1); + let d2 = orientation(p3, p4, p2); + let d3 = orientation(p1, p2, p3); + let d4 = orientation(p1, p2, p4); + + if ((d1 > 0.0 && d2 < 0.0) || (d1 < 0.0 && d2 > 0.0)) + && ((d3 > 0.0 && d4 < 0.0) || (d3 < 0.0 && d4 > 0.0)) + { + return true; + } + + on_segment(p3, p4, p1, d1) + || on_segment(p3, p4, p2, d2) + || on_segment(p1, p2, p3, d3) + || on_segment(p1, p2, p4, d4) +} + +/// Signed area (twice) of triangle `(a, b, c)`: `>0` left turn, `<0` right turn. +fn orientation(a: (f64, f64), b: (f64, f64), c: (f64, f64)) -> f64 { + (b.0 - a.0) * (c.1 - a.1) - (b.1 - a.1) * (c.0 - a.0) +} + +/// True when collinear point `c` (orientation `d == 0`) lies on segment `a-b`. +fn on_segment(a: (f64, f64), b: (f64, f64), c: (f64, f64), d: f64) -> bool { + d == 0.0 + && c.0 >= a.0.min(b.0) + && c.0 <= a.0.max(b.0) + && c.1 >= a.1.min(b.1) + && c.1 <= a.1.max(b.1) +} diff --git a/v2/crates/ruview-twin/src/twin.rs b/v2/crates/ruview-twin/src/twin.rs new file mode 100644 index 00000000..56c8663b --- /dev/null +++ b/v2/crates/ruview-twin/src/twin.rs @@ -0,0 +1,433 @@ +//! Twin model, geometry, and construction (ADR-315 §1). +//! +//! **SYNTHETIC / L0.** Every structure here is part of a *simulation scaffold*. +//! A [`RfTwin`] is a persistent, per-deployment *model* of an RF environment; it +//! **predicts** an expected observable, it does not **measure** one. No value it +//! holds or produces is a hardware, `MEASURED`, or accuracy claim (ADR-282 L0, +//! ADR-300 evidence discipline). Coordinates are a coarse metric abstraction, +//! and the propagation model in [`crate::predict`] is a deliberately simple +//! log-distance model, not real RF. +//! +//! Geometry and radio identity are *referenced* from the canonical +//! [`ruview_ontology`] vocabulary ([`SpaceId`], [`SensorId`], [`Container`], +//! [`SemanticProvenance`], [`EvidenceLevel`]) rather than reinvented — the twin +//! annotates the ADR-306 scene with RF state (ADR-315 "annotates and persists"). + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use ruview_ontology::{Container, EvidenceLevel, SemanticProvenance, SensorId, SpaceId}; + +/// Upper bound on radio nodes accepted in one deployment. Bounds allocation on +/// untrusted input; construction beyond this is rejected, never truncated. +pub const MAX_NODES: usize = 1024; + +/// Upper bound on wall/reflector segments accepted in one deployment. +pub const MAX_WALLS: usize = 4096; + +/// A point in metric coordinates (metres), in the deployment's local ENU-style +/// frame. This is a coarse abstraction, not a surveyed position. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct Point3 { + /// East / x, metres. + pub x: f64, + /// North / y, metres. + pub y: f64, + /// Up / z, metres. + pub z: f64, +} + +impl Point3 { + /// Construct a point. + #[must_use] + pub const fn new(x: f64, y: f64, z: f64) -> Self { + Self { x, y, z } + } + + /// True when every coordinate is finite (rejects `NaN`/`inf` at the + /// boundary). + #[must_use] + pub fn is_finite(&self) -> bool { + self.x.is_finite() && self.y.is_finite() && self.z.is_finite() + } + + /// Euclidean distance to another point, in metres. + #[must_use] + pub fn distance_to(&self, other: &Point3) -> f64 { + let dx = self.x - other.x; + let dy = self.y - other.y; + let dz = self.z - other.z; + (dx * dx + dy * dy + dz * dz).sqrt() + } + + /// Horizontal-plane endpoint `(x, y)` used for wall-crossing tests. + #[must_use] + pub fn xy(&self) -> (f64, f64) { + (self.x, self.y) + } +} + +/// A wall or static reflector, modelled (SYNTHETIC) as a floor-plan segment that +/// adds a fixed attenuation to any link whose straight path crosses it. This is +/// a coarse stand-in for the worldgraph `Wall { rf_attenuation_db }` (ADR-306), +/// not a solved electromagnetic obstacle. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Wall { + /// Stable, caller-supplied identifier for the wall/reflector. + pub id: String, + /// One floor-plan endpoint `(x, y)`, metres. + pub a: (f64, f64), + /// The other floor-plan endpoint `(x, y)`, metres. + pub b: (f64, f64), + /// Extra one-way attenuation added to a crossing link, in decibels. + pub attenuation_db: f64, +} + +impl Wall { + /// True when both endpoints and the attenuation are finite. + #[must_use] + pub fn is_finite(&self) -> bool { + self.a.0.is_finite() + && self.a.1.is_finite() + && self.b.0.is_finite() + && self.b.1.is_finite() + && self.attenuation_db.is_finite() + } +} + +/// A radio placed at a metric position. Identity and containment are ontology +/// references, not new vocabulary. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RadioNode { + /// Ontology sensor identity (ADR-305 authenticated device / ADR-306 sensor). + pub id: SensorId, + /// Metric position in the deployment frame. + pub position: Point3, + /// Ontology container the radio is placed in (a `Space` or `Zone`). + pub located_in: Container, + /// Modelled transmit power, dBm. SYNTHETIC parameter of the forward model. + pub tx_power_dbm: f64, +} + +/// Parameters of the SYNTHETIC log-distance path-loss model (ADR-315 §1). These +/// describe a simple didactic propagation model, **not** a calibrated RF fit. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct PropagationParams { + /// Path-loss exponent `n` (free space ≈ 2.0; indoor typically higher). Must + /// be strictly positive. + pub path_loss_exponent: f64, + /// Reference distance `d0`, metres. Must be strictly positive. + pub reference_distance_m: f64, + /// Path loss at the reference distance `PL(d0)`, decibels. + pub reference_loss_db: f64, + /// Base shadowing standard deviation, decibels. Its square is the baseline + /// variance of the expected distribution. Must be non-negative. + pub shadowing_sigma_db: f64, +} + +impl PropagationParams { + /// A neutral indoor-ish default (`n = 3`, `d0 = 1 m`, `PL(d0) = 40 dB`, + /// `sigma = 4 dB`). SYNTHETIC; asserts nothing about any real environment. + #[must_use] + pub fn default_indoor() -> Self { + Self { + path_loss_exponent: 3.0, + reference_distance_m: 1.0, + reference_loss_db: 40.0, + shadowing_sigma_db: 4.0, + } + } + + /// Validate the parameters at the boundary. + fn validate(&self) -> Result<(), TwinError> { + if !(self.path_loss_exponent.is_finite() && self.path_loss_exponent > 0.0) { + return Err(TwinError::InvalidParameter { + what: "path_loss_exponent must be finite and > 0", + }); + } + if !(self.reference_distance_m.is_finite() && self.reference_distance_m > 0.0) { + return Err(TwinError::InvalidParameter { + what: "reference_distance_m must be finite and > 0", + }); + } + if !self.reference_loss_db.is_finite() { + return Err(TwinError::InvalidParameter { + what: "reference_loss_db must be finite", + }); + } + if !(self.shadowing_sigma_db.is_finite() && self.shadowing_sigma_db >= 0.0) { + return Err(TwinError::InvalidParameter { + what: "shadowing_sigma_db must be finite and >= 0", + }); + } + Ok(()) + } +} + +/// An unordered radio-to-radio link. Endpoints are stored in a canonical order +/// (`a <= b`) so the same physical link has one key regardless of direction. +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub struct LinkId { + /// The lexicographically smaller endpoint (the modelled transmitter). + pub a: SensorId, + /// The lexicographically larger endpoint (the modelled receiver). + pub b: SensorId, +} + +impl LinkId { + /// Build a canonical link key from two endpoints (self-links are rejected by + /// the caller/twin, not here). + #[must_use] + pub fn new(x: SensorId, y: SensorId) -> Self { + if x <= y { + Self { a: x, b: y } + } else { + Self { a: y, b: x } + } + } + + /// True when both endpoints refer to the same node (a degenerate self-link). + #[must_use] + pub fn is_self_link(&self) -> bool { + self.a == self.b + } +} + +/// Recorded multipath / calibration state for one link: an extra variance +/// (dB²) folded into that link's expected distribution. A bounded temporal +/// summary in ADR-315 terms; here a single non-negative scalar. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct MultipathRecord { + /// The link this record applies to. + pub link: LinkId, + /// Extra variance added to the link's expected distribution, dB² (>= 0). + pub extra_variance_db2: f64, +} + +/// Reason a twin version was advanced (ADR-315 §2 versioning). Kept for audit; +/// the twin is always relative to a *named* baseline version. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum VersionEvent { + /// A calibration event under ADR-301 advanced the baseline. + Calibration, + /// A deliberate geometry edit advanced the baseline. + GeometryEdit, + /// An operator-accepted physical change advanced the baseline. + AcceptedChange, +} + +/// The input description of a deployment, from which a [`RfTwin`] is built. +/// +/// Scenes are varied deterministically by [`seed`](Self::seed): there is **no** +/// wall-clock or unseeded randomness anywhere in this crate. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct DeploymentDescription { + /// Ontology space this deployment annotates (geometry reference, not copy). + pub space: SpaceId, + /// Radio nodes and their metric positions. + pub nodes: Vec, + /// Walls / reflectors modelled as attenuating floor-plan segments. + pub walls: Vec, + /// Propagation model parameters. + pub params: PropagationParams, + /// Recorded per-link multipath / calibration variance state. + #[serde(default)] + pub multipath: Vec, + /// Referenced calibration baseline (ADR-301), as a version handle only. + pub calibration_version: String, + /// Explicit seed identifying the synthetic scene. Deterministic. + pub seed: u64, +} + +/// A persistent, versioned, per-deployment RF *model* (ADR-315). +/// +/// **SYNTHETIC / L0.** The twin's expected distributions and any propagation +/// simulation are a model (ADR-282 L0), never evidence that a physical state is +/// the case. Its load-bearing output is a *delta and its significance against +/// its own modelled variance* (see [`crate::delta`]). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RfTwin { + /// Monotonic baseline version. Advanced on calibration / geometry / accepted + /// change so a delta is always relative to a named baseline. + pub version: u64, + /// Ontology space reference this twin annotates. + pub space: SpaceId, + /// Radio nodes keyed by ontology sensor id (deterministic ordering). + pub nodes: BTreeMap, + /// Walls / reflectors. + pub walls: Vec, + /// Propagation model parameters. + pub params: PropagationParams, + /// Recorded per-link multipath / calibration variance state. + pub multipath: Vec, + /// Referenced calibration baseline handle (ADR-301). + pub calibration_version: String, + /// Provenance travelling with the twin (ADR-306 §2). + pub provenance: SemanticProvenance, + /// Evidence level of everything the twin asserts. Always `L0` (SYNTHETIC): + /// a twin predicts, it does not measure. + pub evidence_level: EvidenceLevel, + /// The synthetic scene seed this twin was built from. + pub seed: u64, +} + +impl RfTwin { + /// Build a twin from a deployment description, validating every input at the + /// boundary. Never panics on malformed input; returns a typed [`TwinError`]. + /// + /// The built twin is always `EvidenceLevel::L0` (SYNTHETIC) and starts at + /// baseline `version = 1`. + pub fn build(desc: DeploymentDescription) -> Result { + if desc.nodes.len() > MAX_NODES { + return Err(TwinError::TooManyNodes { + len: desc.nodes.len(), + max: MAX_NODES, + }); + } + if desc.walls.len() > MAX_WALLS { + return Err(TwinError::TooManyWalls { + len: desc.walls.len(), + max: MAX_WALLS, + }); + } + desc.params.validate()?; + + let mut nodes: BTreeMap = BTreeMap::new(); + for node in desc.nodes { + if !node.position.is_finite() { + return Err(TwinError::NonFiniteCoordinate { + node: node.id.as_str().to_string(), + }); + } + if !node.tx_power_dbm.is_finite() { + return Err(TwinError::InvalidParameter { + what: "tx_power_dbm must be finite", + }); + } + if nodes.insert(node.id.clone(), node.clone()).is_some() { + return Err(TwinError::DuplicateNode { + node: node.id.as_str().to_string(), + }); + } + } + + for wall in &desc.walls { + if !wall.is_finite() { + return Err(TwinError::NonFiniteCoordinate { + node: format!("wall:{}", wall.id), + }); + } + } + + for rec in &desc.multipath { + if !(rec.extra_variance_db2.is_finite() && rec.extra_variance_db2 >= 0.0) { + return Err(TwinError::InvalidParameter { + what: "multipath extra_variance_db2 must be finite and >= 0", + }); + } + if rec.link.is_self_link() { + return Err(TwinError::SelfLink { + node: rec.link.a.as_str().to_string(), + }); + } + } + + Ok(Self { + version: 1, + space: desc.space, + nodes, + walls: desc.walls, + params: desc.params, + multipath: desc.multipath, + calibration_version: desc.calibration_version, + provenance: SemanticProvenance::declared("ruview-twin@0 (SYNTHETIC/L0)"), + evidence_level: EvidenceLevel::L0, + seed: desc.seed, + }) + } + + /// Every unordered link between distinct nodes, in deterministic order. + #[must_use] + pub fn links(&self) -> Vec { + let ids: Vec<&SensorId> = self.nodes.keys().collect(); + let mut out = Vec::new(); + for i in 0..ids.len() { + for j in (i + 1)..ids.len() { + out.push(LinkId::new(ids[i].clone(), ids[j].clone())); + } + } + out + } + + /// Borrow a node by id. + #[must_use] + pub fn node(&self, id: &SensorId) -> Option<&RadioNode> { + self.nodes.get(id) + } + + /// The extra variance recorded for a link (0 when none is recorded). + #[must_use] + pub fn extra_variance(&self, link: &LinkId) -> f64 { + self.multipath + .iter() + .find(|r| &r.link == link) + .map_or(0.0, |r| r.extra_variance_db2) + } + + /// Advance the baseline version on an auditable event and return the new + /// version. History semantics (ADR-312) live outside this crate; here we + /// simply move the named baseline forward. + pub fn advance_version(&mut self, _event: VersionEvent) -> u64 { + self.version = self.version.saturating_add(1); + self.version + } +} + +/// Boundary errors from building or operating on a twin. Malformed input yields +/// one of these; it never panics. +#[derive(Clone, Debug, PartialEq, Eq, Error)] +pub enum TwinError { + /// A node or wall carried a non-finite coordinate. + #[error("non-finite coordinate on `{node}`")] + NonFiniteCoordinate { + /// Offending node id (or `wall:`). + node: String, + }, + /// Two nodes shared the same id. + #[error("duplicate node id `{node}`")] + DuplicateNode { + /// The duplicated node id. + node: String, + }, + /// A degenerate link whose endpoints are the same node. + #[error("self-link on node `{node}`")] + SelfLink { + /// The node id. + node: String, + }, + /// A model parameter was out of its valid domain. + #[error("invalid parameter: {what}")] + InvalidParameter { + /// Human-readable reason. + what: &'static str, + }, + /// More nodes than [`MAX_NODES`]. + #[error("too many nodes: {len} exceeds maximum {max}")] + TooManyNodes { + /// Actual count. + len: usize, + /// The enforced maximum. + max: usize, + }, + /// More walls than [`MAX_WALLS`]. + #[error("too many walls: {len} exceeds maximum {max}")] + TooManyWalls { + /// Actual count. + len: usize, + /// The enforced maximum. + max: usize, + }, +} diff --git a/v2/crates/ruview-witness/Cargo.toml b/v2/crates/ruview-witness/Cargo.toml new file mode 100644 index 00000000..87be495a --- /dev/null +++ b/v2/crates/ruview-witness/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "ruview-witness" +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-attest = { path = "../ruview-attest" } + +[dev-dependencies] +serde_json.workspace = true diff --git a/v2/crates/ruview-witness/src/lib.rs b/v2/crates/ruview-witness/src/lib.rs new file mode 100644 index 00000000..27c3655a --- /dev/null +++ b/v2/crates/ruview-witness/src/lib.rs @@ -0,0 +1,1194 @@ +//! `ruview-witness` — the staged, append-only, hash-linked witness chain. +//! +//! This crate implements **ADR-319** (witness chain), primitive 19 of the +//! ADR-300 perception substrate. Instead of emitting a bare boolean +//! ("person present"), RuView emits a *chain* whose ordered stages record the +//! auditable reasoning behind an output: +//! +//! ```text +//! RF observation ▸ DSP evidence ▸ model inference ▸ independent corroboration +//! ▸ spatial state ▸ policy decision +//! ``` +//! +//! Each stage is a typed record carrying its own [`Confidence`] and its +//! provenance ([`EvidenceLevel`]). The chain is **hash-linked**: each stage +//! binds the hash of the prior stage ([`Stage::prior_hash`]), and the chain +//! carries the recomputed [`WitnessChain::head`] of its terminal stage, so an +//! in-place mutation, a reordering, or a broken link is detectable by +//! [`WitnessChain::verify`] without trusting the emitting host. +//! +//! ## Relationship to sibling ADRs +//! +//! - **ADR-305 ([`ruview_attest`])** roots the chain: the first stage is built +//! from an authenticated [`VerifiedMeasurement`] +//! ([`WitnessChain::from_measurement`]), so the whole chain descends from a +//! verified chain of custody. The stage hash reuses `ruview-attest`'s BLAKE3 +//! ([`ruview_attest::PayloadHash`]) — no separate hash primitive is added. +//! - **ADR-295** contributes [`SourceState`]: a `Synthetic` root can never +//! present as `LiveVerified`, and it caps the chain's effective evidence +//! level. +//! - **ADR-302** contributes [`DomainState`] (the `KNOWN → DEGRADED → UNKNOWN` +//! staleness guard): a low-confidence or out-of-distribution inference is +//! recorded as such, never silently promoted. +//! - **ADR-321** will attach the real terminal [`PolicyDecision`]; here it is a +//! faithfully-typed placeholder for the governed action taken (or withheld). +//! +//! ## Honesty discipline (ADR-300 rule 1 / CLAUDE.md) +//! +//! [`Confidence::Unknown`] is a first-class value, never an error. A missing +//! corroboration is recorded as [`Corroboration::None`], never fabricated. The +//! chain's *effective* evidence level is the **minimum** across its stages, so +//! the chain can never claim more than its weakest link. +//! +//! ### Evidence grade +//! +//! Like `ruview-attest`, any guarantee exercised by this crate's tests is +//! **SYNTHETIC / L0** — the fixtures are constructed in code, never captured +//! from silicon. Hash-linking here is tamper-*evident* against in-place +//! mutation; deployment-grade non-repudiation additionally requires the ADR-305 +//! per-stage RuField signatures, layered on top of this structure, plus +//! real-hardware evidence. + +#![forbid(unsafe_code)] + +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +pub use ruview_attest::{ + CalibrationRef, DeviceId, PayloadHash, SignedMeasurement, Timestamp, VerifiedMeasurement, +}; + +/// Width, in bytes, of a stage hash (BLAKE3, via [`ruview_attest::PayloadHash`]). +pub const HASH_LEN: usize = 32; + +/// Maximum accepted byte length of any free-text field carried in a stage. +/// Bounds allocation at the (untrusted) construction boundary. +pub const MAX_TEXT_LEN: usize = 256; + +/// Domain-separation prefix mixed into every stage's canonical bytes so a stage +/// hash can never collide with a hash computed for another purpose. +const DOMAIN: &[u8] = b"ruview-witness/v1\x00stage\x00"; + +// --------------------------------------------------------------------------- +// Errors +// --------------------------------------------------------------------------- + +/// Failure while constructing a stage value from untrusted input. +#[derive(Debug, Clone, PartialEq, Error)] +pub enum InputError { + /// A free-text field exceeded [`MAX_TEXT_LEN`]. + #[error("text field length {got} exceeds maximum {max}", max = MAX_TEXT_LEN)] + TextTooLong { + /// The offending length. + got: usize, + }, + /// A confidence value was not a finite number in `0.0..=1.0`. + #[error("confidence {0} is not a finite value in 0.0..=1.0")] + InvalidConfidence(f32), +} + +/// Reason a [`WitnessChain`] operation was rejected. Malformed structure is a +/// hard `Err`, never a panic and never a silently-accepted chain. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum ChainError { + /// A stage whose evidence kind does not permit it here was appended: the + /// stage order must be strictly increasing (append-only, no reordering). + #[error("stage {next:?} cannot follow {last:?}: stage order must strictly increase")] + NotAppendable { + /// The current terminal stage kind. + last: StageKind, + /// The rejected stage kind. + next: StageKind, + }, + /// The chain was empty (a chain always roots in an RF observation). + #[error("chain is empty")] + Empty, + /// The root stage was not an RF observation. + #[error("chain root must be an RF observation, found {0:?}")] + RootNotObservation(StageKind), + /// A stage's recorded prior-stage hash did not match the recomputed hash of + /// its predecessor: a link is broken, missing, or a stage was reordered. + #[error("broken hash link at stage index {index}")] + BrokenLink { + /// Index of the stage whose `prior_hash` did not match. + index: usize, + }, + /// Stage order was not strictly increasing (a stage was reordered). + #[error("non-monotonic stage order at index {index}")] + NonMonotonicOrder { + /// Index of the out-of-order stage. + index: usize, + }, + /// The terminal stage's recomputed hash did not match the chain head: the + /// last stage was tampered with in place. + #[error("terminal stage does not match the recorded chain head (tamper)")] + TerminalTampered, +} + +// --------------------------------------------------------------------------- +// Provenance and confidence primitives +// --------------------------------------------------------------------------- + +/// The evidence level of a stage (ADR-282, L0–L5). The chain's effective level +/// is the **minimum** across stages, so the weakest link caps the whole chain. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[repr(u8)] +pub enum EvidenceLevel { + /// L0 — synthetic / self-referential; no external anchor. + L0 = 0, + /// L1. + L1 = 1, + /// L2. + L2 = 2, + /// L3. + L3 = 3, + /// L4. + L4 = 4, + /// L5 — strongest anchored evidence. + L5 = 5, +} + +/// The ADR-295 source state of an RF observation. `Unknown` is structurally +/// absent here: a stage that cannot assert a live state records `Synthetic` or +/// `Disconnected`, and a `Synthetic` root can never present as `LiveVerified`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[repr(u8)] +pub enum SourceState { + /// Synthetic input; caps the chain at [`EvidenceLevel::L0`]. + Synthetic = 0, + /// Live and cryptographically verified (ADR-305). + LiveVerified = 1, + /// Live but unverified. + LiveUnverified = 2, + /// Live source that has gone stale. + Stale = 3, + /// Source disconnected. + Disconnected = 4, +} + +impl SourceState { + /// Whether this state is the authenticated live state. A `Synthetic` root + /// answers `false`, upholding the ADR-295 invariant. + pub fn is_live_verified(&self) -> bool { + matches!(self, SourceState::LiveVerified) + } +} + +/// The ADR-302 domain-signature gate result for a model inference — the +/// `VALID → DEGRADED → UNKNOWN` staleness guard. Recorded faithfully so a +/// degraded or out-of-distribution inference is never silently promoted. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[repr(u8)] +pub enum DomainState { + /// In-distribution; the certificate is valid. + Known = 0, + /// Drifting; the capability is degraded and recalibration is due. + Degraded = 1, + /// Out of distribution; the answer is UNKNOWN (never a confident class). + Unknown = 2, +} + +/// A stage's confidence. [`Confidence::Unknown`] is a first-class value +/// (ADR-300 rule 1), never an error and never coerced to a number. +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +pub enum Confidence { + /// A finite confidence in `0.0..=1.0`. + Known(f32), + /// The stage has no confidence to report. + Unknown, +} + +impl Confidence { + /// Construct a known confidence, rejecting non-finite or out-of-range input + /// at the boundary. + pub fn known(value: f32) -> Result { + if !value.is_finite() || value < 0.0 || value > 1.0 { + return Err(InputError::InvalidConfidence(value)); + } + Ok(Confidence::Known(value)) + } + + /// The unknown confidence. + pub const fn unknown() -> Self { + Confidence::Unknown + } +} + +// --------------------------------------------------------------------------- +// Stage kinds and typed per-stage evidence +// --------------------------------------------------------------------------- + +/// The ordered kinds of a witness stage. Ordering is the pipeline order; a +/// chain's stages must strictly increase, which is what makes reordering +/// detectable and bounds a chain to at most six stages. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[repr(u8)] +pub enum StageKind { + /// The authenticated RF frame envelope (root link). + RfObservation = 0, + /// Deterministic DSP features and ADR-137 quality signals. + DspEvidence = 1, + /// Model version, raw output, uncertainty, and the ADR-302 gate result. + ModelInference = 2, + /// The ADR-303 agreement link (phase 2); "no corroboration" in phase 1. + IndependentCorroboration = 3, + /// The ADR-306 ontology entity the inference updated. + SpatialState = 4, + /// The terminal governed action (ADR-321). + PolicyDecision = 5, +} + +/// The RF observation stage: the authenticated measurement lineage plus its +/// ADR-295 source state. This is the root link of every chain. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RfObservation { + /// The verified chain-of-custody record from `ruview-attest` (ADR-305). + pub measurement: VerifiedMeasurement, + /// The ADR-295 source state of the measurement. + pub source_state: SourceState, +} + +impl RfObservation { + /// Root an observation in a verified measurement and its source state. + pub fn from_verified(measurement: VerifiedMeasurement, source_state: SourceState) -> Self { + Self { + measurement, + source_state, + } + } +} + +/// The DSP evidence stage: a deterministic feature descriptor, an SNR-style +/// quality figure, and the ADR-137 quality-gate verdict. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct DspEvidence { + descriptor: String, + /// Signal-quality figure (e.g. SNR in dB) supporting the features. + pub snr_db: f32, + /// Whether the ADR-137 quality gate passed. + pub quality_ok: bool, +} + +impl DspEvidence { + /// Construct DSP evidence, validating the descriptor length at the boundary. + pub fn new( + descriptor: impl Into, + snr_db: f32, + quality_ok: bool, + ) -> Result { + Ok(Self { + descriptor: checked_text(descriptor.into())?, + snr_db, + quality_ok, + }) + } + + /// The feature descriptor. + pub fn descriptor(&self) -> &str { + &self.descriptor + } +} + +/// The model inference stage: which model produced which raw output, with what +/// predictive uncertainty, under which ADR-302 domain gate. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ModelInference { + model_version: String, + label: String, + /// Predictive uncertainty of the raw output. + pub uncertainty: f32, + /// The ADR-302 domain-gate result. `Unknown` records an OOD inference. + pub domain_state: DomainState, +} + +impl ModelInference { + /// Construct a model inference, validating text fields at the boundary. + pub fn new( + model_version: impl Into, + label: impl Into, + uncertainty: f32, + domain_state: DomainState, + ) -> Result { + Ok(Self { + model_version: checked_text(model_version.into())?, + label: checked_text(label.into())?, + uncertainty, + domain_state, + }) + } + + /// The model version string. + pub fn model_version(&self) -> &str { + &self.model_version + } + + /// The raw output label. + pub fn label(&self) -> &str { + &self.label + } +} + +/// The independent-corroboration stage (ADR-303). In phase 1 the honest value +/// is [`Corroboration::None`] — a missing corroboration is recorded, never +/// fabricated. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum Corroboration { + /// No independent corroboration was available (phase-1 default). + None, + /// A second modality agreed, with an agreement score in `0.0..=1.0`. + Agreed { + /// The corroborating modality. + modality: String, + /// Agreement score. + score: f32, + }, + /// A second modality disagreed. + Disagreed { + /// The corroborating modality. + modality: String, + /// Agreement score. + score: f32, + }, +} + +impl Corroboration { + /// Construct an `Agreed` corroboration, validating the modality length. + pub fn agreed(modality: impl Into, score: f32) -> Result { + Ok(Corroboration::Agreed { + modality: checked_text(modality.into())?, + score, + }) + } + + /// Construct a `Disagreed` corroboration, validating the modality length. + pub fn disagreed(modality: impl Into, score: f32) -> Result { + Ok(Corroboration::Disagreed { + modality: checked_text(modality.into())?, + score, + }) + } +} + +/// The kind of ADR-306 ontology entity a stage updated. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[repr(u8)] +pub enum SpatialEntity { + /// A single observation. + Observation = 0, + /// A track (linked observations over time). + Track = 1, + /// A discrete event. + Event = 2, +} + +/// The spatial-state stage: the ADR-306 ontology entity the inference updated. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SpatialState { + /// The kind of entity updated. + pub entity: SpatialEntity, + entity_id: String, +} + +impl SpatialState { + /// Construct a spatial-state record, validating the entity id length. + pub fn new(entity: SpatialEntity, entity_id: impl Into) -> Result { + Ok(Self { + entity, + entity_id: checked_text(entity_id.into())?, + }) + } + + /// The entity identifier. + pub fn entity_id(&self) -> &str { + &self.entity_id + } +} + +/// The governed action a policy took or withheld (ADR-321 owns the real one). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum PolicyAction { + /// An action was taken. + Act { + /// The action identifier. + action: String, + }, + /// The action was withheld (e.g. on a degraded/unknown domain). + Withhold { + /// Why the action was withheld. + reason: String, + }, +} + +/// The terminal policy-decision stage: the governed action, with the ADR-318 +/// capability certificate it relied on (if any). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PolicyDecision { + /// The governed action taken or withheld. + pub action: PolicyAction, + certificate_ref: Option, +} + +impl PolicyDecision { + /// Record a taken action. + pub fn act( + action: impl Into, + certificate_ref: Option, + ) -> Result { + Self::new( + PolicyAction::Act { + action: checked_text(action.into())?, + }, + certificate_ref, + ) + } + + /// Record a withheld action. + pub fn withhold( + reason: impl Into, + certificate_ref: Option, + ) -> Result { + Self::new( + PolicyAction::Withhold { + reason: checked_text(reason.into())?, + }, + certificate_ref, + ) + } + + fn new(action: PolicyAction, certificate_ref: Option) -> Result { + let certificate_ref = match certificate_ref { + Some(c) => Some(checked_text(c)?), + None => None, + }; + Ok(Self { + action, + certificate_ref, + }) + } + + /// The relied-upon certificate reference, if any. + pub fn certificate_ref(&self) -> Option<&str> { + self.certificate_ref.as_deref() + } +} + +/// The typed evidence carried by a stage. Its variant fixes the stage kind, so +/// a stage can never carry evidence inconsistent with its position. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum StageEvidence { + /// RF observation (root). + RfObservation(RfObservation), + /// DSP evidence. + DspEvidence(DspEvidence), + /// Model inference. + ModelInference(ModelInference), + /// Independent corroboration. + IndependentCorroboration(Corroboration), + /// Spatial state. + SpatialState(SpatialState), + /// Policy decision (terminal). + PolicyDecision(PolicyDecision), +} + +impl StageEvidence { + /// The stage kind this evidence variant belongs to. + pub fn kind(&self) -> StageKind { + match self { + StageEvidence::RfObservation(_) => StageKind::RfObservation, + StageEvidence::DspEvidence(_) => StageKind::DspEvidence, + StageEvidence::ModelInference(_) => StageKind::ModelInference, + StageEvidence::IndependentCorroboration(_) => StageKind::IndependentCorroboration, + StageEvidence::SpatialState(_) => StageKind::SpatialState, + StageEvidence::PolicyDecision(_) => StageKind::PolicyDecision, + } + } + + fn write_canonical(&self, out: &mut Vec) { + match self { + StageEvidence::RfObservation(o) => { + out.push(0); + let m = &o.measurement; + push_field(out, m.device.as_str().as_bytes()); + out.extend_from_slice(&m.sequence.to_le_bytes()); + out.extend_from_slice(&m.timestamp.0.to_le_bytes()); + push_field(out, &m.payload_hash.0); + match &m.calibration_ref { + Some(c) => { + out.push(1); + push_field(out, c.as_str().as_bytes()); + } + None => out.push(0), + } + out.push(o.source_state as u8); + } + StageEvidence::DspEvidence(d) => { + out.push(1); + push_field(out, d.descriptor.as_bytes()); + out.extend_from_slice(&d.snr_db.to_le_bytes()); + out.push(d.quality_ok as u8); + } + StageEvidence::ModelInference(m) => { + out.push(2); + push_field(out, m.model_version.as_bytes()); + push_field(out, m.label.as_bytes()); + out.extend_from_slice(&m.uncertainty.to_le_bytes()); + out.push(m.domain_state as u8); + } + StageEvidence::IndependentCorroboration(c) => { + out.push(3); + match c { + Corroboration::None => out.push(0), + Corroboration::Agreed { modality, score } => { + out.push(1); + push_field(out, modality.as_bytes()); + out.extend_from_slice(&score.to_le_bytes()); + } + Corroboration::Disagreed { modality, score } => { + out.push(2); + push_field(out, modality.as_bytes()); + out.extend_from_slice(&score.to_le_bytes()); + } + } + } + StageEvidence::SpatialState(s) => { + out.push(4); + out.push(s.entity as u8); + push_field(out, s.entity_id.as_bytes()); + } + StageEvidence::PolicyDecision(p) => { + out.push(5); + match &p.action { + PolicyAction::Act { action } => { + out.push(0); + push_field(out, action.as_bytes()); + } + PolicyAction::Withhold { reason } => { + out.push(1); + push_field(out, reason.as_bytes()); + } + } + match &p.certificate_ref { + Some(c) => { + out.push(1); + push_field(out, c.as_bytes()); + } + None => out.push(0), + } + } + } + } +} + +// --------------------------------------------------------------------------- +// The hash-linked stage +// --------------------------------------------------------------------------- + +/// A stage hash: the BLAKE3 (via [`ruview_attest::PayloadHash`]) of a stage's +/// canonical bytes, which include the prior stage's hash. Fixed width so a +/// malformed wire value cannot force an unbounded allocation. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct StageHash(pub [u8; HASH_LEN]); + +/// One stage of a witness chain: a typed [`StageEvidence`] with its +/// [`Confidence`] and [`EvidenceLevel`], hash-linked to the prior stage. +/// +/// Fields are readable for inspection but a `Stage` inside a [`WitnessChain`] is +/// only reachable immutably ([`WitnessChain::stages`]); the chain never hands +/// out a mutable stage, upholding the append-only invariant. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Stage { + /// The stage kind (equals `evidence.kind()`). + pub kind: StageKind, + /// The stage's confidence (may be [`Confidence::Unknown`]). + pub confidence: Confidence, + /// The stage's provenance / evidence level. + pub evidence_level: EvidenceLevel, + /// The hash of the prior stage, or `None` for the root. + pub prior_hash: Option, + /// The typed evidence. + pub evidence: StageEvidence, +} + +impl Stage { + fn new( + evidence: StageEvidence, + confidence: Confidence, + evidence_level: EvidenceLevel, + prior_hash: Option, + ) -> Self { + Self { + kind: evidence.kind(), + confidence, + evidence_level, + prior_hash, + evidence, + } + } + + /// The deterministic hash of this stage over its canonical, length-prefixed + /// bytes — including the prior-stage hash, which is what links the chain. + pub fn hash(&self) -> StageHash { + let mut b = Vec::with_capacity(DOMAIN.len() + 64); + b.extend_from_slice(DOMAIN); + b.push(self.kind as u8); + match self.confidence { + Confidence::Unknown => b.push(0), + Confidence::Known(v) => { + b.push(1); + b.extend_from_slice(&v.to_le_bytes()); + } + } + b.push(self.evidence_level as u8); + match &self.prior_hash { + Some(h) => { + b.push(1); + b.extend_from_slice(&h.0); + } + None => b.push(0), + } + self.evidence.write_canonical(&mut b); + StageHash(PayloadHash::of(&b).0) + } +} + +// --------------------------------------------------------------------------- +// The chain +// --------------------------------------------------------------------------- + +/// A verified summary of a chain, returned by [`WitnessChain::verify`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ChainSummary { + /// Number of stages. + pub len: usize, + /// The effective evidence level: the **minimum** across all stages. + pub effective_level: EvidenceLevel, + /// The kind of the terminal stage. + pub terminal_kind: StageKind, + /// The verified head hash. + pub head: StageHash, + /// Whether the terminal stage is a [`StageKind::PolicyDecision`]. + pub finalized: bool, +} + +/// A staged, append-only, hash-linked witness chain (ADR-319). +/// +/// A chain always roots in an RF observation and grows by strictly-increasing +/// stage kind. Prior stages are immutable: [`WitnessChain::append`] only ever +/// pushes, and the stored [`WitnessChain::head`] binds the terminal stage so +/// even a last-stage in-place mutation is caught by [`WitnessChain::verify`]. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct WitnessChain { + /// When this chain is an append-only correction of a prior chain, the head + /// hash of the chain it supersedes. + correction_of: Option, + stages: Vec, + head: StageHash, +} + +impl WitnessChain { + /// Root a chain in an RF observation. + pub fn root( + observation: RfObservation, + confidence: Confidence, + evidence_level: EvidenceLevel, + ) -> Self { + let stage = Stage::new( + StageEvidence::RfObservation(observation), + confidence, + evidence_level, + None, + ); + let head = stage.hash(); + Self { + correction_of: None, + stages: vec![stage], + head, + } + } + + /// Root a chain directly in an authenticated [`VerifiedMeasurement`] + /// (ADR-305), so the chain descends from a verified chain of custody. + pub fn from_measurement( + measurement: VerifiedMeasurement, + source_state: SourceState, + confidence: Confidence, + evidence_level: EvidenceLevel, + ) -> Self { + Self::root( + RfObservation::from_verified(measurement, source_state), + confidence, + evidence_level, + ) + } + + /// Begin an append-only *correction* of `prior`: a fresh chain that + /// references the superseded chain's head rather than editing it in place. + pub fn correcting( + prior: &WitnessChain, + observation: RfObservation, + confidence: Confidence, + evidence_level: EvidenceLevel, + ) -> Self { + let mut chain = Self::root(observation, confidence, evidence_level); + chain.correction_of = Some(prior.head); + chain + } + + /// Append a stage. The stage kind (derived from `evidence`) must be strictly + /// greater than the current terminal kind, so prior stages are never + /// mutated or reordered. The new stage binds the current head hash. + pub fn append( + &mut self, + evidence: StageEvidence, + confidence: Confidence, + evidence_level: EvidenceLevel, + ) -> Result<(), ChainError> { + let next = evidence.kind(); + let last = self.stages.last().map(|s| s.kind).ok_or(ChainError::Empty)?; + if (next as u8) <= (last as u8) { + return Err(ChainError::NotAppendable { last, next }); + } + let stage = Stage::new(evidence, confidence, evidence_level, Some(self.head)); + self.head = stage.hash(); + self.stages.push(stage); + Ok(()) + } + + /// The chain's stages, immutably. There is no mutable accessor: a chain is + /// append-only. + pub fn stages(&self) -> &[Stage] { + &self.stages + } + + /// The recorded head (terminal-stage) hash. + pub fn head(&self) -> StageHash { + self.head + } + + /// The head hash of a chain this one corrects, if any. + pub fn correction_of(&self) -> Option { + self.correction_of + } + + /// Verify the whole chain: the root is an RF observation, every stage binds + /// the recomputed hash of its predecessor, stage order strictly increases, + /// and the terminal stage matches the recorded head. Returns a + /// [`ChainSummary`] whose effective level is the minimum across stages. + pub fn verify(&self) -> Result { + let first = self.stages.first().ok_or(ChainError::Empty)?; + if first.kind != StageKind::RfObservation { + return Err(ChainError::RootNotObservation(first.kind)); + } + + let mut prev_hash: Option = None; + let mut prev_kind: Option = None; + let mut effective = EvidenceLevel::L5; + + for (index, stage) in self.stages.iter().enumerate() { + // Link integrity: the recorded prior hash must equal the recomputed + // hash of the predecessor (None for the root). + if stage.prior_hash != prev_hash { + return Err(ChainError::BrokenLink { index }); + } + // Monotonic stage order. + if let Some(pk) = prev_kind { + if (stage.kind as u8) <= (pk as u8) { + return Err(ChainError::NonMonotonicOrder { index }); + } + } + if stage.evidence_level < effective { + effective = stage.evidence_level; + } + prev_hash = Some(stage.hash()); + prev_kind = Some(stage.kind); + } + + // Terminal integrity: catches an in-place mutation of the last stage, + // which no successor's `prior_hash` binds. + let head = prev_hash.ok_or(ChainError::Empty)?; + if head != self.head { + return Err(ChainError::TerminalTampered); + } + + let terminal_kind = prev_kind.ok_or(ChainError::Empty)?; + Ok(ChainSummary { + len: self.stages.len(), + effective_level: effective, + terminal_kind, + head, + finalized: terminal_kind == StageKind::PolicyDecision, + }) + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/// Validate a free-text field length at the boundary. +fn checked_text(text: String) -> Result { + if text.len() > MAX_TEXT_LEN { + return Err(InputError::TextTooLong { got: text.len() }); + } + Ok(text) +} + +/// Append a `u32` little-endian length prefix followed by the bytes, so the +/// canonical encoding is field-unambiguous and serde-format independent. +fn push_field(out: &mut Vec, bytes: &[u8]) { + out.extend_from_slice(&(bytes.len() as u32).to_le_bytes()); + out.extend_from_slice(bytes); +} + +// --------------------------------------------------------------------------- +// Tests (SYNTHETIC / L0 fixtures — constructed in code, no wall clock, no RNG) +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use ruview_attest::{ + AttestationVerifier, Blake3MacSigner, FreshnessPolicy, SignedMeasurement, TAG_LEN, + }; + + fn verified(seq: u64) -> VerifiedMeasurement { + VerifiedMeasurement { + device: DeviceId::new("esp32-node-01").unwrap(), + sequence: seq, + timestamp: Timestamp(1000), + payload_hash: PayloadHash::of(b"csi-frame"), + calibration_ref: Some(CalibrationRef::new("cal-cert-abc").unwrap()), + } + } + + /// Build a full six-stage chain from an L-labelled root. + fn full_chain(root_level: EvidenceLevel, source: SourceState) -> WitnessChain { + let mut chain = WitnessChain::from_measurement( + verified(1), + source, + Confidence::known(0.9).unwrap(), + root_level, + ); + chain + .append( + StageEvidence::DspEvidence( + DspEvidence::new("doppler-motion-band", 12.5, true).unwrap(), + ), + Confidence::known(0.8).unwrap(), + EvidenceLevel::L3, + ) + .unwrap(); + chain + .append( + StageEvidence::ModelInference( + ModelInference::new("presence-v3", "person", 0.15, DomainState::Known).unwrap(), + ), + Confidence::known(0.72).unwrap(), + EvidenceLevel::L2, + ) + .unwrap(); + chain + .append( + StageEvidence::IndependentCorroboration(Corroboration::None), + Confidence::Unknown, + EvidenceLevel::L1, + ) + .unwrap(); + chain + .append( + StageEvidence::SpatialState( + SpatialState::new(SpatialEntity::Track, "track-7").unwrap(), + ), + Confidence::known(0.7).unwrap(), + EvidenceLevel::L2, + ) + .unwrap(); + chain + .append( + StageEvidence::PolicyDecision( + PolicyDecision::act("dim-lights", Some("cap-cert-9".into())).unwrap(), + ), + Confidence::known(0.7).unwrap(), + EvidenceLevel::L2, + ) + .unwrap(); + chain + } + + #[test] + fn full_chain_verifies() { + let chain = full_chain(EvidenceLevel::L3, SourceState::LiveVerified); + let summary = chain.verify().unwrap(); + assert_eq!(summary.len, 6); + assert_eq!(summary.terminal_kind, StageKind::PolicyDecision); + assert!(summary.finalized); + // Effective level is the minimum across stages (L1 from the + // no-corroboration stage), never the root's L3. + assert_eq!(summary.effective_level, EvidenceLevel::L1); + } + + #[test] + fn stages_are_in_pipeline_order() { + let chain = full_chain(EvidenceLevel::L3, SourceState::LiveVerified); + let kinds: Vec = chain.stages().iter().map(|s| s.kind).collect(); + assert_eq!( + kinds, + vec![ + StageKind::RfObservation, + StageKind::DspEvidence, + StageKind::ModelInference, + StageKind::IndependentCorroboration, + StageKind::SpatialState, + StageKind::PolicyDecision, + ] + ); + } + + #[test] + fn root_from_authenticated_measurement() { + // Sign + verify with ruview-attest, then root the chain in the result. + let key = [7u8; TAG_LEN]; + let signer = Blake3MacSigner::new(key); + let device = DeviceId::new("esp32-node-01").unwrap(); + let mut verifier = AttestationVerifier::new(FreshnessPolicy::new(1_000_000_000, 100_000_000)); + verifier.enroll(device.clone(), signer.clone()); + let signed = + SignedMeasurement::sign(&signer, device, 1, Timestamp(1000), b"csi-frame", None); + let vm = verifier.verify(&signed, b"csi-frame", Timestamp(1000)).unwrap(); + + let chain = WitnessChain::from_measurement( + vm, + SourceState::LiveVerified, + Confidence::known(0.9).unwrap(), + EvidenceLevel::L4, + ); + assert!(chain.verify().is_ok()); + match &chain.stages()[0].evidence { + StageEvidence::RfObservation(o) => { + assert_eq!(o.measurement.sequence, 1); + assert!(o.source_state.is_live_verified()); + } + _ => panic!("root must be an RF observation"), + } + } + + #[test] + fn synthetic_root_caps_effective_level_at_l0() { + // A synthetic root labelled L0 caps the whole chain regardless of + // later, higher-labelled stages. + let chain = full_chain(EvidenceLevel::L0, SourceState::Synthetic); + let summary = chain.verify().unwrap(); + assert_eq!(summary.effective_level, EvidenceLevel::L0); + match &chain.stages()[0].evidence { + StageEvidence::RfObservation(o) => { + assert_eq!(o.source_state, SourceState::Synthetic); + assert!(!o.source_state.is_live_verified()); + } + _ => panic!(), + } + } + + #[test] + fn append_rejects_out_of_order_stage() { + let mut chain = full_chain(EvidenceLevel::L3, SourceState::LiveVerified); + // Terminal is PolicyDecision; nothing can follow. + let err = chain + .append( + StageEvidence::DspEvidence(DspEvidence::new("x", 1.0, true).unwrap()), + Confidence::Unknown, + EvidenceLevel::L1, + ) + .unwrap_err(); + assert!(matches!(err, ChainError::NotAppendable { .. })); + + // A second RF observation is also rejected (kind not strictly greater). + let mut c2 = WitnessChain::from_measurement( + verified(1), + SourceState::LiveVerified, + Confidence::Unknown, + EvidenceLevel::L2, + ); + assert!(matches!( + c2.append( + StageEvidence::RfObservation(RfObservation::from_verified( + verified(2), + SourceState::LiveVerified + )), + Confidence::Unknown, + EvidenceLevel::L2, + ), + Err(ChainError::NotAppendable { .. }) + )); + } + + #[test] + fn tampered_middle_stage_fails() { + let mut chain = full_chain(EvidenceLevel::L3, SourceState::LiveVerified); + // Mutate a middle stage's confidence in place without re-linking. + chain.stages[2].confidence = Confidence::known(0.01).unwrap(); + assert!(matches!(chain.verify(), Err(ChainError::BrokenLink { index: 3 }))); + } + + #[test] + fn tampered_terminal_stage_fails() { + let mut chain = full_chain(EvidenceLevel::L3, SourceState::LiveVerified); + let last = chain.stages.len() - 1; + // No successor binds the terminal stage; the recorded head catches it. + chain.stages[last].evidence_level = EvidenceLevel::L5; + assert_eq!(chain.verify(), Err(ChainError::TerminalTampered)); + } + + #[test] + fn tampered_evidence_content_fails() { + let mut chain = full_chain(EvidenceLevel::L3, SourceState::LiveVerified); + // Rewrite the model label on a middle stage. + chain.stages[2].evidence = StageEvidence::ModelInference( + ModelInference::new("presence-v3", "empty-room", 0.15, DomainState::Known).unwrap(), + ); + assert!(matches!(chain.verify(), Err(ChainError::BrokenLink { index: 3 }))); + } + + #[test] + fn reordered_stages_fail() { + let mut chain = full_chain(EvidenceLevel::L3, SourceState::LiveVerified); + chain.stages.swap(2, 3); + // The swap breaks both the hash link and the monotonic order; either + // way verification must reject it. + assert!(chain.verify().is_err()); + } + + #[test] + fn missing_link_fails() { + let mut chain = full_chain(EvidenceLevel::L3, SourceState::LiveVerified); + // Drop a non-root stage's prior-hash link. + chain.stages[3].prior_hash = None; + assert!(matches!(chain.verify(), Err(ChainError::BrokenLink { index: 3 }))); + } + + #[test] + fn dropped_stage_fails() { + let mut chain = full_chain(EvidenceLevel::L3, SourceState::LiveVerified); + // Remove a middle stage entirely: the follower's link no longer matches + // and the stored head no longer matches the recomputed terminal. + chain.stages.remove(3); + assert!(chain.verify().is_err()); + } + + #[test] + fn serde_round_trip_preserves_chain() { + let chain = full_chain(EvidenceLevel::L3, SourceState::LiveVerified); + let json = serde_json::to_string(&chain).unwrap(); + let back: WitnessChain = serde_json::from_str(&json).unwrap(); + assert_eq!(chain, back); + // A deserialized chain still verifies end to end. + assert!(back.verify().is_ok()); + } + + #[test] + fn confidence_and_provenance_preserved() { + let chain = full_chain(EvidenceLevel::L3, SourceState::LiveVerified); + let json = serde_json::to_string(&chain).unwrap(); + let back: WitnessChain = serde_json::from_str(&json).unwrap(); + for (a, b) in chain.stages().iter().zip(back.stages()) { + assert_eq!(a.confidence, b.confidence); + assert_eq!(a.evidence_level, b.evidence_level); + assert_eq!(a.kind, b.kind); + } + // The UNKNOWN corroboration confidence survives faithfully. + assert_eq!(back.stages()[3].confidence, Confidence::Unknown); + match &back.stages()[3].evidence { + StageEvidence::IndependentCorroboration(c) => assert_eq!(*c, Corroboration::None), + _ => panic!(), + } + } + + #[test] + fn unknown_gate_recorded_faithfully() { + // An OOD inference is carried as DomainState::Unknown with Unknown + // confidence — never promoted to a confident class. + let mut chain = WitnessChain::from_measurement( + verified(1), + SourceState::LiveVerified, + Confidence::known(0.9).unwrap(), + EvidenceLevel::L3, + ); + chain + .append( + StageEvidence::DspEvidence(DspEvidence::new("band", 3.0, false).unwrap()), + Confidence::Unknown, + EvidenceLevel::L1, + ) + .unwrap(); + chain + .append( + StageEvidence::ModelInference( + ModelInference::new("presence-v3", "unknown", 0.9, DomainState::Unknown) + .unwrap(), + ), + Confidence::Unknown, + EvidenceLevel::L0, + ) + .unwrap(); + let summary = chain.verify().unwrap(); + assert_eq!(summary.effective_level, EvidenceLevel::L0); + match &chain.stages()[2].evidence { + StageEvidence::ModelInference(m) => assert_eq!(m.domain_state, DomainState::Unknown), + _ => panic!(), + } + } + + #[test] + fn append_only_correction_references_prior() { + let prior = full_chain(EvidenceLevel::L3, SourceState::LiveVerified); + let correction = WitnessChain::correcting( + &prior, + RfObservation::from_verified(verified(2), SourceState::LiveVerified), + Confidence::known(0.95).unwrap(), + EvidenceLevel::L3, + ); + // The correction is a new chain that references, not edits, the prior. + assert_eq!(correction.correction_of(), Some(prior.head())); + assert!(correction.verify().is_ok()); + assert!(prior.verify().is_ok()); + assert_ne!(correction.head(), prior.head()); + } + + #[test] + fn chain_building_is_deterministic() { + let a = full_chain(EvidenceLevel::L3, SourceState::LiveVerified); + let b = full_chain(EvidenceLevel::L3, SourceState::LiveVerified); + assert_eq!(a, b); + assert_eq!(a.head(), b.head()); + assert_eq!( + serde_json::to_string(&a).unwrap(), + serde_json::to_string(&b).unwrap() + ); + } + + #[test] + fn confidence_boundary_validation() { + assert!(Confidence::known(f32::NAN).is_err()); + assert!(Confidence::known(-0.1).is_err()); + assert!(Confidence::known(1.1).is_err()); + assert!(Confidence::known(0.0).is_ok()); + assert!(Confidence::known(1.0).is_ok()); + } + + #[test] + fn text_boundary_validation() { + let long = "x".repeat(MAX_TEXT_LEN + 1); + assert!(matches!( + DspEvidence::new(long, 1.0, true), + Err(InputError::TextTooLong { .. }) + )); + assert!(DspEvidence::new("x".repeat(MAX_TEXT_LEN), 1.0, true).is_ok()); + } + + #[test] + fn empty_chain_verify_is_error_not_panic() { + // Constructed only via serde to reach the empty-stages guard. + let json = r#"{"correction_of":null,"stages":[],"head":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]}"#; + let chain: WitnessChain = serde_json::from_str(json).unwrap(); + assert_eq!(chain.verify(), Err(ChainError::Empty)); + } +} diff --git a/v2/crates/wifi-densepose-bfld/Cargo.toml b/v2/crates/wifi-densepose-bfld/Cargo.toml index d49f6400..d374e258 100644 --- a/v2/crates/wifi-densepose-bfld/Cargo.toml +++ b/v2/crates/wifi-densepose-bfld/Cargo.toml @@ -25,6 +25,10 @@ mqtt = ["std", "dep:rumqttc"] # enables privacy_class = 1 (derived) mode and the SoulMatchOracle gate # exemption. Disabled by default per the structural class-2 default. soul-signature = [] +# WiFi Veil advisory integration (ADR-294): deterministic attacker-vs- +# protector assessment of BFI identity leakage and emission-shaping shield +# configs. All numbers it produces are SYNTHETIC / L0 by construction. +veil = ["std", "dep:wifi-veil"] [dependencies] thiserror.workspace = true @@ -36,6 +40,7 @@ serde_json = { workspace = true, optional = true } # MQTT publisher backend (optional). Matches the `rumqttc` choice already in # `wifi-densepose-sensing-server` so both crates share TLS / version posture. rumqttc = { version = "0.24", default-features = false, features = ["use-rustls"], optional = true } +wifi-veil = { workspace = true, optional = true } [dev-dependencies] proptest.workspace = true diff --git a/v2/crates/wifi-densepose-bfld/src/lib.rs b/v2/crates/wifi-densepose-bfld/src/lib.rs index 2789910b..593891af 100644 --- a/v2/crates/wifi-densepose-bfld/src/lib.rs +++ b/v2/crates/wifi-densepose-bfld/src/lib.rs @@ -53,6 +53,9 @@ pub mod signature_hasher; pub mod sink; pub mod soul_channels; pub mod soul_match; +/// WiFi Veil advisory integration (ADR-294). Feature-gated: `veil`. +#[cfg(feature = "veil")] +pub mod veil; pub use coherence_gate::{CoherenceGate, MatchOutcome, NullOracle, SoulMatchOracle}; #[cfg(feature = "std")] diff --git a/v2/crates/wifi-densepose-bfld/src/veil.rs b/v2/crates/wifi-densepose-bfld/src/veil.rs new file mode 100644 index 00000000..d710415a --- /dev/null +++ b/v2/crates/wifi-densepose-bfld/src/veil.rs @@ -0,0 +1,187 @@ +//! WiFi Veil advisory integration (ADR-294). +//! +//! Bridges BFLD's privacy layer to the [`wifi-veil`](https://github.com/ruvnet/wifi-veil) +//! countermeasure crate: a deterministic, dependency-free attacker-vs-protector +//! model of BFI identity leakage and keyed emission-shaping ("shield") +//! configurations. +//! +//! # Evidence discipline +//! +//! Everything this module produces is **`SYNTHETIC` / evidence level L0** by +//! construction: `wifi-veil` models compliant waveform controls on synthetic +//! scenes and never touches a radio. Assessments quantify the *modeled* +//! re-identification risk of unprotected beamforming feedback and the *modeled* +//! effect of a shield; they are advisory inputs to privacy posture, never +//! measured hardware claims. See ADR-294 and the wifi-veil README. +//! +//! # Relationship to BFLD invariants +//! +//! BFLD's structural invariants (I1–I3, see the crate README) govern data that +//! *enters* this node. WiFi Veil addresses the complementary surface: what this +//! node's own *outgoing* feedback leaks to passive third parties. The +//! integration is advisory-only — nothing here emits RF, alters frames, or +//! relaxes a BFLD gate. + +use wifi_veil::{experiment, ExperimentConfig}; + +/// Evidence label attached to every veil-derived figure. +/// +/// Matches the repository-wide claim taxonomy (CLAUDE.md): synthetic model +/// output, reproduced by `cargo test`, not measured on hardware. +pub const VEIL_EVIDENCE: &str = "SYNTHETIC/L0"; + +/// Summary of one deterministic attacker-vs-protector experiment. +/// +/// A thin, stable projection of [`wifi_veil::ExperimentReport`] carrying only +/// the figures BFLD consumers need, plus the mandatory evidence label. +#[derive(Debug, Clone, PartialEq)] +pub struct ShieldAssessment { + /// Number of candidate identities in the synthetic scene. + pub identities: usize, + /// Ideal chance-level re-identification accuracy (`1 / identities`). + pub chance_level: f32, + /// Modeled passive re-identification accuracy with the shield **off**. + pub reid_accuracy_off: f32, + /// Modeled passive re-identification accuracy with the shield **on**. + pub reid_accuracy_on: f32, + /// Modeled protected-link throughput as a fraction of baseline. + pub throughput_ratio: f64, + /// `output_energy / input_energy` of a representative protected frame. + /// ~1.0 means the control is energy-preserving (compliant, not jamming). + pub energy_ratio: f32, + /// True iff the energy ratio is within tolerance of 1.0. + pub energy_conserving: bool, + /// True iff the shield drove re-identification into the accepted + /// chance band. + pub drives_to_chance: bool, + /// Evidence label; always [`VEIL_EVIDENCE`]. + pub evidence: &'static str, +} + +impl ShieldAssessment { + /// Residual re-identification margin above chance with the shield on. + /// + /// `0.0` (or below) means the modeled attacker is at or below chance; + /// larger values mean residual identity leakage in the model. + #[must_use] + pub fn residual_reid_margin(&self) -> f32 { + self.reid_accuracy_on - self.chance_level + } + + /// One-line human-readable summary, evidence-tagged. + #[must_use] + pub fn summary(&self) -> String { + format!( + "[{}] re-ID {:.1}% -> {:.1}% (chance {:.1}%, {} identities), \ + throughput {:.1}%, energy ratio {:.6} ({})", + self.evidence, + self.reid_accuracy_off * 100.0, + self.reid_accuracy_on * 100.0, + self.chance_level * 100.0, + self.identities, + self.throughput_ratio * 100.0, + self.energy_ratio, + if self.energy_conserving { + "energy-conserving" + } else { + "NOT energy-conserving" + }, + ) + } +} + +impl From for ShieldAssessment { + fn from(r: wifi_veil::ExperimentReport) -> Self { + Self { + identities: r.identities, + chance_level: r.chance_level, + reid_accuracy_off: r.accuracy_shield_off, + reid_accuracy_on: r.accuracy_shield_on, + throughput_ratio: r.throughput_ratio, + energy_ratio: r.compliance.energy_ratio, + energy_conserving: r.compliance.energy_conserving, + drives_to_chance: r.drives_to_chance(), + evidence: VEIL_EVIDENCE, + } + } +} + +/// Run the deterministic attacker-vs-protector experiment for `cfg`. +/// +/// Fully deterministic: identical configs produce identical assessments. +#[must_use] +pub fn assess(cfg: &ExperimentConfig) -> ShieldAssessment { + experiment::run(cfg).into() +} + +/// Run the experiment with wifi-veil's shipped default scene and shield. +#[must_use] +pub fn assess_default() -> ShieldAssessment { + assess(&ExperimentConfig::default()) +} + +/// Derive the optimizer-shipped shield configuration and its verifying +/// assessment for `base`. +/// +/// Wraps [`wifi_veil::hyper_optimize`]: the returned shield uses the +/// spec-allowed throughput-optimal feedback resolution and a Givens-pass +/// count grown by the privacy margin factor. +#[must_use] +pub fn optimized_shield(base: &ExperimentConfig) -> (wifi_veil::ShieldConfig, ShieldAssessment) { + let hyper = wifi_veil::hyper_optimize(base); + let assessment = hyper.report.into(); + (hyper.shield, assessment) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_assessment_is_deterministic() { + let a = assess_default(); + let b = assess_default(); + assert_eq!(a, b); + } + + #[test] + fn shield_reduces_modeled_reid_accuracy() { + let a = assess_default(); + assert!( + a.reid_accuracy_on < a.reid_accuracy_off, + "shield-on accuracy {} must be below shield-off {}", + a.reid_accuracy_on, + a.reid_accuracy_off + ); + } + + #[test] + fn default_shield_is_compliant_and_at_chance() { + let a = assess_default(); + assert!(a.energy_conserving, "veil must be energy-preserving"); + assert!(a.drives_to_chance, "shipped default must reach chance band"); + assert!(a.throughput_ratio > 0.9, "throughput ratio {} too low", a.throughput_ratio); + } + + #[test] + fn evidence_label_is_synthetic_l0() { + let a = assess_default(); + assert_eq!(a.evidence, VEIL_EVIDENCE); + assert!(a.summary().starts_with("[SYNTHETIC/L0]")); + } + + #[test] + fn residual_margin_matches_fields() { + let a = assess_default(); + let m = a.residual_reid_margin(); + assert!((m - (a.reid_accuracy_on - a.chance_level)).abs() < f32::EPSILON); + } + + #[test] + fn optimized_shield_verifies() { + let (shield, assessment) = optimized_shield(&ExperimentConfig::default()); + assert!(shield.enabled); + assert!(assessment.drives_to_chance); + assert!(assessment.energy_conserving); + } +} diff --git a/v2/crates/wifi-densepose-calibration/Cargo.toml b/v2/crates/wifi-densepose-calibration/Cargo.toml index 80832f58..1df2f644 100644 --- a/v2/crates/wifi-densepose-calibration/Cargo.toml +++ b/v2/crates/wifi-densepose-calibration/Cargo.toml @@ -13,6 +13,7 @@ wifi-densepose-signal = { version = "0.3.0", path = "../wifi-densepose-signal", serde = { workspace = true } serde_json = "1.0" +sha2 = { workspace = true } thiserror = { workspace = true } uuid = { version = "1.6", features = ["v4", "serde"] } diff --git a/v2/crates/wifi-densepose-calibration/src/certificate.rs b/v2/crates/wifi-densepose-calibration/src/certificate.rs new file mode 100644 index 00000000..d3e6c799 --- /dev/null +++ b/v2/crates/wifi-densepose-calibration/src/certificate.rs @@ -0,0 +1,1135 @@ +//! Signed, versioned, invalidatable room-fingerprint certificate (ADR-301). +//! +//! ADR-301 is primitive 1 of the perception-substrate program (ADR-300) and the +//! first brick of its "certificate spine". This module implements the +//! **certificate portion**: a 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."* +//! +//! Nothing here re-derives room state. The [`RoomFingerprint`] is a bounded, +//! fixed-length statistical summary *reused* from the existing calibration types +//! — the [`SpecialistBank`](crate::bank::SpecialistBank)'s empty-vs-occupied +//! presence separation (ADR-135 baseline / ADR-151) and its transceiver +//! [`GeometryEmbedding`](crate::geometry_embedding::GeometryEmbedding) +//! (ADR-152). The [`CalibrationCertificate`] binds that fingerprint to a space, +//! a signing sensor identity, a monotonic version, a capture time, an expiry, +//! an evidence level (ADR-282), and a content hash suitable for signing. +//! +//! ## Honesty discipline (ADR-301 §"Provenance and honesty") +//! +//! A certificate produced from generated CSI is [`EvidenceLevel::L0Synthetic`] +//! by construction; [`CalibrationCertificate::mint`] **rejects** labelling a +//! synthetic characterization as measured, and rejects an automatic +//! ([`CalibrationTier::Auto`]) characterization claiming more than L2. +//! +//! ## Invalidation is explicit, not a silent `STALE` flag +//! +//! [`CalibrationCertificate::status`] returns a typed [`CertificateStatus`]: +//! valid, past-expiry, tampered signature, or drifted beyond the +//! [`CompatibilityEnvelope`]. Small drift stays inside the envelope and is +//! absorbed; drift beyond it invalidates the certificate and forces +//! re-characterization. Compensation never rewrites a signed certificate in +//! place — [`CalibrationCertificate::renew`] mints a *new* version, preserving +//! an append-only history. + +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use crate::bank::SpecialistBank; +use crate::error::{CalibrationError, Result}; +use crate::geometry_embedding::GeometryEmbedding; + +/// Schema version for the [`RoomFingerprint`] wire format. Bumped when the +/// fingerprint's field set changes (ADR-301 §1: "its schema is versioned"). +pub const FINGERPRINT_SCHEMA_VERSION: u32 = 1; + +/// Schema version for the [`CalibrationCertificate`] wire format. +pub const CERTIFICATE_SCHEMA_VERSION: u32 = 1; + +// Fixed, data-independent normalization scales for the fingerprint distance. +// Data-independent so `distance` is strictly monotonic under a single-field +// perturbation (a data-dependent denominator would grow with the perturbation +// and could mask it) — see `distance_is_monotonic` in the tests. +const MEAN_SCALE: f32 = 1.0; +const VAR_SCALE: f32 = 10.0; +const GEOM_SCALE: f32 = 1.0; + +// --------------------------------------------------------------------------- +// Evidence level, tier, characterization source +// --------------------------------------------------------------------------- + +/// Evidence ladder (ADR-282 L0–L5). An automatic characterization on real +/// captured CSI is at most L1/L2 and is labelled as such, never L3+ (ADR-301 +/// §3). L0 is reserved for synthetic/generated input. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +pub enum EvidenceLevel { + /// Generated / synthetic CSI — no measured evidence (ADR-279 invariant 6). + L0Synthetic, + /// Weakest measured evidence (automatic observe-only, unverified). + L1, + /// Automatic characterization with a passing quality gate. + L2, + /// Guided enrollment or better (not reachable from `autocal`). + L3, + /// Cross-validated against a held-out split. + L4, + /// Independently reproduced on real silicon. + L5, +} + +impl EvidenceLevel { + /// `true` for any measured level (L1+); L0 is synthetic. + pub fn is_measured(self) -> bool { + self != EvidenceLevel::L0Synthetic + } + + /// Stable tag for canonical hashing. + fn tag(self) -> u8 { + match self { + EvidenceLevel::L0Synthetic => 0, + EvidenceLevel::L1 => 1, + EvidenceLevel::L2 => 2, + EvidenceLevel::L3 => 3, + EvidenceLevel::L4 => 4, + EvidenceLevel::L5 => 5, + } + } +} + +/// How the fingerprint was characterized (ADR-301 §"Provenance and honesty"). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum CharacterizationSource { + /// Generated / simulated CSI. Forces [`EvidenceLevel::L0Synthetic`]. + Synthetic, + /// Real captured CSI from a sensor. + MeasuredCsi, +} + +impl CharacterizationSource { + fn tag(self) -> u8 { + match self { + CharacterizationSource::Synthetic => 0, + CharacterizationSource::MeasuredCsi => 1, + } + } +} + +/// Calibration tier (ADR-301 §1/§3): the automatic observe-only path yields a +/// weaker evidence level than guided enrollment; the certificate states which +/// path produced it so consumers can weight it. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum CalibrationTier { + /// Automatic observe-only characterization (`autocal`). Capped at L2. + Auto, + /// Guided human enrollment (existing anchor ritual). + Guided, +} + +impl CalibrationTier { + fn tag(self) -> u8 { + match self { + CalibrationTier::Auto => 0, + CalibrationTier::Guided => 1, + } + } + + /// The strongest evidence level this tier may honestly claim. + fn max_measured_evidence(self) -> EvidenceLevel { + match self { + CalibrationTier::Auto => EvidenceLevel::L2, + CalibrationTier::Guided => EvidenceLevel::L5, + } + } +} + +// --------------------------------------------------------------------------- +// Room fingerprint (reused summary of the existing calibration state) +// --------------------------------------------------------------------------- + +/// A bounded, fixed-length statistical summary of a room's CSI distribution — +/// the distance-comparable object ADR-302 measures against. +/// +/// It is *derived* from the existing calibration state, not a new measurement: +/// the empty-vs-occupied separation comes from the bank's +/// [`PresenceSpecialist`](crate::specialist::PresenceSpecialist) (ADR-135 +/// baseline / ADR-151), and the geometry conditioning comes from the bank's +/// [`GeometryEmbedding`](crate::geometry_embedding::GeometryEmbedding) +/// (ADR-152). Both the **empty** distribution and the **occupied** distribution +/// are stored so downstream OOD gating can distinguish "the empty room changed" +/// (furniture/geometry drift) from "occupancy statistics changed". +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RoomFingerprint { + /// Schema version ([`FINGERPRINT_SCHEMA_VERSION`]). + pub schema_version: u32, + /// Empty-room scalar mean (static multipath load), from the presence gate's + /// `empty_mean` reference. `0.0` when the bank learned no presence gate. + pub empty_mean: f32, + /// Empty-room band energy (variance), reconstructed from the presence gate's + /// finite decision boundary; `0.0` when unavailable. + pub empty_variance: f32, + /// Occupied-room band energy (mean occupied-anchor variance). + pub occupied_variance: f32, + /// Learned variance decision boundary. Finite-guarded: an "inert" (issue + /// #1440, `+inf`) boundary is stored as `0.0` so distance math stays finite. + pub presence_threshold: f32, + /// Empty→occupied mean separation (occupancy signal strength). + pub occupancy_mean_shift: f32, + /// Transceiver-geometry conditioning (ADR-152); all-zero when no geometry + /// was recorded. + pub geometry: GeometryEmbedding, +} + +impl RoomFingerprint { + /// Derive the fingerprint from a trained [`SpecialistBank`] — a pure + /// function of the bank's existing state (no new capture). + pub fn from_bank(bank: &SpecialistBank) -> Self { + let (empty_mean, empty_variance, occupied_variance, presence_threshold, mean_shift) = + match bank.presence.as_ref() { + Some(p) => { + let threshold = if p.threshold.is_finite() { + p.threshold + } else { + 0.0 + }; + // threshold == 0.5 * (empty_var + occupied_var) when finite, so + // empty_var reconstructs as 2*threshold - occupied_var (>= 0). + let empty_var = if p.threshold.is_finite() { + (2.0 * p.threshold - p.occupied_var).max(0.0) + } else { + 0.0 + }; + // presence threshold == 0.5 * mean_dist ⇒ mean_dist == 2*threshold. + let mean_shift = p.mean_dist_threshold.map(|t| 2.0 * t).unwrap_or(0.0); + ( + p.empty_mean, + empty_var, + p.occupied_var, + threshold, + mean_shift, + ) + } + None => (0.0, 0.0, 0.0, 0.0, 0.0), + }; + + Self { + schema_version: FINGERPRINT_SCHEMA_VERSION, + empty_mean, + empty_variance, + occupied_variance, + presence_threshold, + occupancy_mean_shift: mean_shift, + geometry: bank.geometry_embedding(), + } + } + + /// Bounded fingerprint distance to another fingerprint — the primitive + /// ADR-302 uses to gate KNOWN → DEGRADED → UNKNOWN. + /// + /// Splits drift into an **empty-room** component (static multipath + physical + /// geometry) and an **occupancy** component (dynamics), so a consumer can + /// tell furniture/geometry drift from a different subject. The `total` is + /// squashed into `[0, 1)` and is monotonic in any single-field perturbation. + pub fn distance(&self, other: &RoomFingerprint) -> FingerprintDistance { + let dmean = (self.empty_mean - other.empty_mean) / MEAN_SCALE; + let dempty_var = (self.empty_variance - other.empty_variance) / VAR_SCALE; + let geom_sq = geometry_l2_sq(&self.geometry, &other.geometry) / (GEOM_SCALE * GEOM_SCALE); + let baseline_raw = (dmean * dmean + dempty_var * dempty_var + geom_sq).sqrt(); + + let docc_var = (self.occupied_variance - other.occupied_variance) / VAR_SCALE; + let dshift = (self.occupancy_mean_shift - other.occupancy_mean_shift) / MEAN_SCALE; + let dthr = (self.presence_threshold - other.presence_threshold) / VAR_SCALE; + let occupancy_raw = (docc_var * docc_var + dshift * dshift + dthr * dthr).sqrt(); + + let raw = baseline_raw + occupancy_raw; + FingerprintDistance { + baseline_drift: baseline_raw, + occupancy_drift: occupancy_raw, + total: raw / (1.0 + raw), + } + } +} + +fn geometry_l2_sq(a: &GeometryEmbedding, b: &GeometryEmbedding) -> f32 { + a.as_slice() + .iter() + .zip(b.as_slice().iter()) + .map(|(x, y)| { + let d = x - y; + d * d + }) + .sum() +} + +/// A drift/distance summary between two [`RoomFingerprint`]s. +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +pub struct FingerprintDistance { + /// Empty-room drift: static multipath + transceiver geometry ("did the room + /// itself change"). + pub baseline_drift: f32, + /// Occupancy drift: how differently occupants perturb the field. + pub occupancy_drift: f32, + /// Total drift, squashed into `[0, 1)`. Monotonic in the underlying raw + /// distance, so it is directly comparable against a [`CompatibilityEnvelope`]. + pub total: f32, +} + +impl FingerprintDistance { + /// `true` when total drift stays within the envelope (small drift absorbed). + pub fn within_envelope(&self, envelope: &CompatibilityEnvelope) -> bool { + self.total <= envelope.max_total_drift + } +} + +/// The compatibility envelope for continuous drift compensation (ADR-301 §4). +/// Drift within the envelope is absorbed and logged; drift beyond it invalidates +/// the certificate. +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +pub struct CompatibilityEnvelope { + /// Maximum tolerated total fingerprint drift in `[0, 1)` before the + /// certificate is invalidated and re-characterization is forced. + pub max_total_drift: f32, +} + +impl Default for CompatibilityEnvelope { + fn default() -> Self { + // A conservative default: modest drift is absorbed, a clearly different + // room is rejected. Consumers (ADR-302) may tighten this per space. + Self { + max_total_drift: 0.15, + } + } +} + +impl CompatibilityEnvelope { + /// Validated constructor. Rejects a non-finite or out-of-`[0, 1)` envelope + /// (bounded-input discipline at the config boundary). + pub fn new(max_total_drift: f32) -> Result { + if !max_total_drift.is_finite() || !(0.0..1.0).contains(&max_total_drift) { + return Err(CalibrationError::InvalidCertificate(format!( + "compatibility envelope must be finite in [0, 1), got {max_total_drift}" + ))); + } + Ok(Self { max_total_drift }) + } +} + +// --------------------------------------------------------------------------- +// Signing abstraction (kept behind a trait, consistent with the crate's style) +// --------------------------------------------------------------------------- + +/// Signs a certificate content hash. Kept behind a trait so the RuField +/// provenance/signature backend (ADR-260/262/277/279) can be substituted +/// without changing the certificate types. A signature is mandatory: an +/// unsigned certificate is not a valid certificate (ADR-301 §3). +pub trait CertificateSigner { + /// Identity of the signing key (bound into the certificate as the signer). + fn key_id(&self) -> &str; + /// Produce a detached signature over the 32-byte content hash. + fn sign(&self, content_hash: &[u8; 32]) -> Vec; +} + +/// Verifies a detached signature over a certificate content hash. +pub trait CertificateVerifier { + /// `true` iff `signature` is a valid signature by `key_id` over `content_hash`. + fn verify(&self, key_id: &str, content_hash: &[u8; 32], signature: &[u8]) -> bool; +} + +/// A deterministic keyed-hash signer/verifier (`SHA-256(secret‖hash‖secret)`). +/// +/// This is a self-contained, dependency-free stand-in for the RuField signature +/// backend so the certificate machinery is testable today. It is a *keyed MAC*, +/// not asymmetric provenance — it is honest about being a placeholder and is +/// never labelled as the production RuField signature. Determinism makes signing +/// reproducible in tests; secrecy of `secret` gives tamper detection. +#[derive(Debug, Clone)] +pub struct KeyedHashSigner { + key_id: String, + secret: Vec, +} + +impl KeyedHashSigner { + /// Construct from a key identity and secret bytes. + pub fn new(key_id: impl Into, secret: impl Into>) -> Self { + Self { + key_id: key_id.into(), + secret: secret.into(), + } + } + + fn mac(&self, content_hash: &[u8; 32]) -> [u8; 32] { + let mut h = Sha256::new(); + h.update(&self.secret); + h.update(content_hash); + h.update(&self.secret); + h.finalize().into() + } +} + +impl CertificateSigner for KeyedHashSigner { + fn key_id(&self) -> &str { + &self.key_id + } + fn sign(&self, content_hash: &[u8; 32]) -> Vec { + self.mac(content_hash).to_vec() + } +} + +impl CertificateVerifier for KeyedHashSigner { + fn verify(&self, key_id: &str, content_hash: &[u8; 32], signature: &[u8]) -> bool { + // Constant-time-ish comparison is out of scope for a placeholder; the + // production RuField verifier owns that. Bind the key identity too. + key_id == self.key_id && signature == self.mac(content_hash).as_slice() + } +} + +/// A detached signature bound to a content hash and a signing-key identity. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CertificateSignature { + /// Identity of the signing key (ADR-305 sensor identity binding). + pub key_id: String, + /// Lowercase-hex SHA-256 of the certificate's canonical signable bytes. + pub content_hash_hex: String, + /// Lowercase-hex detached signature over the content hash. + pub signature_hex: String, +} + +// --------------------------------------------------------------------------- +// Certificate +// --------------------------------------------------------------------------- + +/// Parameters for [`CalibrationCertificate::mint`]. Keeping them in one struct +/// avoids a long positional argument list and documents each binding. +#[derive(Debug, Clone)] +pub struct MintParams { + /// Canonical space identifier (ADR-306 ontology) — *which* space. + pub space_id: String, + /// Signing sensor identity (ADR-305) — *which signed device* produced it. + /// Must equal the signer's `key_id`. + pub sensor_id: String, + /// Capture time (unix seconds). Injected, never read from the wall clock. + pub captured_at_unix_s: i64, + /// Validity window in seconds; `expires_at = captured_at + validity_secs`. + pub validity_secs: i64, + /// Monotonic version (start at 1; [`CalibrationCertificate::renew`] increments). + pub version: u64, + /// Which calibration path produced this (caps the evidence level). + pub tier: CalibrationTier, + /// Evidence level claimed (ADR-282). Validated against `tier`/`source`. + pub evidence: EvidenceLevel, + /// How the fingerprint was characterized (synthetic vs measured). + pub source: CharacterizationSource, + /// Drift envelope governing invalidation. + pub envelope: CompatibilityEnvelope, +} + +/// A signed, versioned, comparable, invalidatable room-fingerprint certificate. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct CalibrationCertificate { + /// Certificate schema version ([`CERTIFICATE_SCHEMA_VERSION`]). + pub schema_version: u32, + /// Canonical space identifier (ADR-306). + pub space_id: String, + /// Room scope carried through from the calibration state. + pub room_id: String, + /// ADR-135 baseline id the fingerprint was derived against. + pub baseline_id: String, + /// Signing sensor identity (ADR-305). + pub sensor_id: String, + /// Monotonic version (append-only history; renewal increments). + pub version: u64, + /// Capture time (unix seconds). + pub captured_at_unix_s: i64, + /// Expiry time (unix seconds); `captured_at + validity_secs`. + pub expires_at_unix_s: i64, + /// Calibration tier. + pub tier: CalibrationTier, + /// Evidence level (honesty-checked at mint). + pub evidence: EvidenceLevel, + /// Characterization source. + pub source: CharacterizationSource, + /// The room fingerprint this certificate attests. + pub fingerprint: RoomFingerprint, + /// Drift envelope governing invalidation. + pub envelope: CompatibilityEnvelope, + /// Mandatory signature over the canonical signable bytes. + pub signature: CertificateSignature, +} + +impl CalibrationCertificate { + /// Mint a certificate from existing calibration state — a pure function over + /// the [`SpecialistBank`] and [`MintParams`], plus a signer. + /// + /// Enforces the honesty discipline before signing: + /// - a [`CharacterizationSource::Synthetic`] fingerprint must be labelled + /// [`EvidenceLevel::L0Synthetic`] (never measured); + /// - a [`CharacterizationSource::MeasuredCsi`] fingerprint must be L1+; + /// - the claimed evidence may not exceed what the `tier` can honestly bear + /// (an [`CalibrationTier::Auto`] characterization is capped at L2); + /// - the `sensor_id` must match the signer's `key_id`; + /// - `version` must be ≥ 1 and `validity_secs` ≥ 0. + pub fn mint( + params: MintParams, + bank: &SpecialistBank, + signer: &S, + ) -> Result { + Self::validate_mint(¶ms, signer)?; + + let fingerprint = RoomFingerprint::from_bank(bank); + let expires_at_unix_s = params.captured_at_unix_s.saturating_add(params.validity_secs); + + // Build the unsigned certificate, then sign its canonical bytes. + let unsigned = UnsignedCertificate { + schema_version: CERTIFICATE_SCHEMA_VERSION, + space_id: ¶ms.space_id, + room_id: &bank.room_id, + baseline_id: &bank.baseline_id, + sensor_id: ¶ms.sensor_id, + version: params.version, + captured_at_unix_s: params.captured_at_unix_s, + expires_at_unix_s, + tier: params.tier, + evidence: params.evidence, + source: params.source, + fingerprint: &fingerprint, + envelope: params.envelope, + }; + let content_hash = unsigned.content_hash(); + let signature = CertificateSignature { + key_id: signer.key_id().to_string(), + content_hash_hex: hex_lower(&content_hash), + signature_hex: hex_lower(&signer.sign(&content_hash)), + }; + + Ok(Self { + schema_version: CERTIFICATE_SCHEMA_VERSION, + space_id: params.space_id, + room_id: bank.room_id.clone(), + baseline_id: bank.baseline_id.clone(), + sensor_id: params.sensor_id, + version: params.version, + captured_at_unix_s: params.captured_at_unix_s, + expires_at_unix_s, + tier: params.tier, + evidence: params.evidence, + source: params.source, + fingerprint, + envelope: params.envelope, + signature, + }) + } + + fn validate_mint(params: &MintParams, signer: &S) -> Result<()> { + if params.version == 0 { + return Err(CalibrationError::InvalidCertificate( + "certificate version must start at 1 (monotonic)".into(), + )); + } + if params.validity_secs < 0 { + return Err(CalibrationError::InvalidCertificate( + "validity_secs must be non-negative".into(), + )); + } + if params.sensor_id != signer.key_id() { + return Err(CalibrationError::InvalidCertificate(format!( + "sensor_id '{}' does not match signing key '{}'", + params.sensor_id, + signer.key_id() + ))); + } + match params.source { + CharacterizationSource::Synthetic => { + if params.evidence.is_measured() { + return Err(CalibrationError::SyntheticMislabel { + claimed: format!("{:?}", params.evidence), + }); + } + } + CharacterizationSource::MeasuredCsi => { + if !params.evidence.is_measured() { + return Err(CalibrationError::InvalidCertificate( + "measured CSI cannot be labelled L0Synthetic".into(), + )); + } + if params.evidence > params.tier.max_measured_evidence() { + return Err(CalibrationError::InvalidCertificate(format!( + "{:?} tier may claim at most {:?}, got {:?}", + params.tier, + params.tier.max_measured_evidence(), + params.evidence + ))); + } + } + } + Ok(()) + } + + /// Re-characterize into the **next** version, preserving the append-only + /// history (ADR-301 §4). Same space/sensor/tier/evidence/source/envelope, + /// `version + 1`, re-signed over the fresh fingerprint and capture time. + /// + /// `source`/`evidence` are inherited so a renewal cannot silently upgrade a + /// synthetic or auto certificate past its honesty cap. + pub fn renew( + &self, + captured_at_unix_s: i64, + validity_secs: i64, + bank: &SpecialistBank, + signer: &S, + ) -> Result { + let params = MintParams { + space_id: self.space_id.clone(), + sensor_id: self.sensor_id.clone(), + captured_at_unix_s, + validity_secs, + version: self.version.saturating_add(1), + tier: self.tier, + evidence: self.evidence, + source: self.source, + envelope: self.envelope, + }; + Self::mint(params, bank, signer) + } + + /// The 32-byte content hash over this certificate's canonical signable bytes + /// — the object the signature covers and a witness-chain anchor (ADR-319). + pub fn content_hash(&self) -> [u8; 32] { + self.as_unsigned().content_hash() + } + + fn as_unsigned(&self) -> UnsignedCertificate<'_> { + UnsignedCertificate { + schema_version: self.schema_version, + space_id: &self.space_id, + room_id: &self.room_id, + baseline_id: &self.baseline_id, + sensor_id: &self.sensor_id, + version: self.version, + captured_at_unix_s: self.captured_at_unix_s, + expires_at_unix_s: self.expires_at_unix_s, + tier: self.tier, + evidence: self.evidence, + source: self.source, + fingerprint: &self.fingerprint, + envelope: self.envelope, + } + } + + /// `true` iff the signature verifies against `verifier` and the recorded + /// content hash matches the recomputed one (tamper rejection). + pub fn verify_signature(&self, verifier: &V) -> bool { + let content_hash = self.content_hash(); + if self.signature.content_hash_hex != hex_lower(&content_hash) { + return false; + } + let Some(sig) = hex_decode(&self.signature.signature_hex) else { + return false; + }; + verifier.verify(&self.signature.key_id, &content_hash, &sig) + } + + /// Distance between this certificate's fingerprint and another's — two + /// certificates for the same space are comparable (ADR-301 §3). + pub fn distance(&self, other: &CalibrationCertificate) -> FingerprintDistance { + self.fingerprint.distance(&other.fingerprint) + } + + /// Evaluate validity against live room state and a signature verifier. + /// + /// Invalidation is an explicit, typed transition (ADR-301 §4), never a + /// silent flag. Order of precedence: tampered signature → expired → drift + /// beyond the envelope → valid. `now_unix_s` is injected (no wall clock). + pub fn status( + &self, + current: &RoomFingerprint, + now_unix_s: i64, + verifier: &V, + ) -> CertificateStatus { + if !self.verify_signature(verifier) { + return CertificateStatus::TamperedSignature; + } + if now_unix_s >= self.expires_at_unix_s { + return CertificateStatus::Expired { + now_unix_s, + expires_at_unix_s: self.expires_at_unix_s, + }; + } + let distance = self.fingerprint.distance(current); + if !distance.within_envelope(&self.envelope) { + return CertificateStatus::Drifted { + distance, + envelope: self.envelope, + }; + } + CertificateStatus::Valid { distance } + } + + /// Convenience: `true` iff [`Self::status`] is [`CertificateStatus::Valid`]. + pub fn is_valid( + &self, + current: &RoomFingerprint, + now_unix_s: i64, + verifier: &V, + ) -> bool { + matches!( + self.status(current, now_unix_s, verifier), + CertificateStatus::Valid { .. } + ) + } + + /// Serialize to pretty JSON (matches [`SpecialistBank`]'s persistence style). + pub fn to_json(&self) -> Result { + serde_json::to_string_pretty(self).map_err(|e| CalibrationError::Serde(e.to_string())) + } + + /// Deserialize from JSON, validating the schema version at the boundary. + pub fn from_json(s: &str) -> Result { + let cert: Self = + serde_json::from_str(s).map_err(|e| CalibrationError::Serde(e.to_string()))?; + if cert.schema_version != CERTIFICATE_SCHEMA_VERSION { + return Err(CalibrationError::InvalidCertificate(format!( + "unsupported certificate schema version {} (expected {})", + cert.schema_version, CERTIFICATE_SCHEMA_VERSION + ))); + } + Ok(cert) + } +} + +/// The typed result of a certificate validity check (ADR-301 §4). +#[derive(Debug, Clone, PartialEq)] +pub enum CertificateStatus { + /// Still valid; carries the (in-envelope) drift for logging/compensation. + Valid { + /// Measured drift vs the live fingerprint (within the envelope). + distance: FingerprintDistance, + }, + /// Past its expiry (`now_unix_s >= expires_at_unix_s`). + Expired { + /// The injected evaluation time. + now_unix_s: i64, + /// The certificate's recorded expiry. + expires_at_unix_s: i64, + }, + /// Drift beyond the compatibility envelope — re-characterization required. + Drifted { + /// The measured drift that breached the envelope. + distance: FingerprintDistance, + /// The envelope it breached. + envelope: CompatibilityEnvelope, + }, + /// Signature did not verify (content hash mismatch or bad signature). + TamperedSignature, +} + +impl CertificateStatus { + /// `true` only for [`CertificateStatus::Valid`]. + pub fn is_valid(&self) -> bool { + matches!(self, CertificateStatus::Valid { .. }) + } +} + +// --------------------------------------------------------------------------- +// Canonical signable encoding +// --------------------------------------------------------------------------- + +/// A borrowed view of the signable fields, in a fixed order, used to compute the +/// content hash. Excludes the signature itself (which covers this hash). +struct UnsignedCertificate<'a> { + schema_version: u32, + space_id: &'a str, + room_id: &'a str, + baseline_id: &'a str, + sensor_id: &'a str, + version: u64, + captured_at_unix_s: i64, + expires_at_unix_s: i64, + tier: CalibrationTier, + evidence: EvidenceLevel, + source: CharacterizationSource, + fingerprint: &'a RoomFingerprint, + envelope: CompatibilityEnvelope, +} + +impl UnsignedCertificate<'_> { + /// SHA-256 over a deterministic, architecture-independent byte encoding. + /// + /// Fields are hashed in a fixed order: strings length-prefixed, integers and + /// floats as little-endian, enums as a stable one-byte tag. No text + /// formatting of floats (raw IEEE-754 LE), matching the ADR-136 + /// `CanonicalFrame` precedent, so the hash is stable across runs and + /// architectures. + fn content_hash(&self) -> [u8; 32] { + let mut h = Sha256::new(); + // Domain separation so this hash can never collide with another artifact. + h.update(b"ruview.adr298.calibration-certificate.v1"); + h.update(self.schema_version.to_le_bytes()); + hash_str(&mut h, self.space_id); + hash_str(&mut h, self.room_id); + hash_str(&mut h, self.baseline_id); + hash_str(&mut h, self.sensor_id); + h.update(self.version.to_le_bytes()); + h.update(self.captured_at_unix_s.to_le_bytes()); + h.update(self.expires_at_unix_s.to_le_bytes()); + h.update([self.tier.tag()]); + h.update([self.evidence.tag()]); + h.update([self.source.tag()]); + hash_fingerprint(&mut h, self.fingerprint); + h.update(self.envelope.max_total_drift.to_le_bytes()); + h.finalize().into() + } +} + +fn hash_str(h: &mut Sha256, s: &str) { + h.update((s.len() as u64).to_le_bytes()); + h.update(s.as_bytes()); +} + +fn hash_fingerprint(h: &mut Sha256, fp: &RoomFingerprint) { + h.update(fp.schema_version.to_le_bytes()); + h.update(fp.empty_mean.to_le_bytes()); + h.update(fp.empty_variance.to_le_bytes()); + h.update(fp.occupied_variance.to_le_bytes()); + h.update(fp.presence_threshold.to_le_bytes()); + h.update(fp.occupancy_mean_shift.to_le_bytes()); + h.update((GeometryEmbedding::DIM as u64).to_le_bytes()); + for v in fp.geometry.as_slice() { + h.update(v.to_le_bytes()); + } +} + +fn hex_lower(bytes: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut s = String::with_capacity(bytes.len() * 2); + for &b in bytes { + s.push(HEX[(b >> 4) as usize] as char); + s.push(HEX[(b & 0x0f) as usize] as char); + } + s +} + +/// Decode lowercase/uppercase hex. Returns `None` on malformed input (odd length +/// or non-hex digit) — no panics at the deserialization boundary. +fn hex_decode(s: &str) -> Option> { + if s.len() % 2 != 0 { + return None; + } + let mut out = Vec::with_capacity(s.len() / 2); + let bytes = s.as_bytes(); + let mut i = 0; + while i < bytes.len() { + let hi = hex_val(bytes[i])?; + let lo = hex_val(bytes[i + 1])?; + out.push((hi << 4) | lo); + i += 2; + } + Some(out) +} + +fn hex_val(c: u8) -> Option { + match c { + b'0'..=b'9' => Some(c - b'0'), + b'a'..=b'f' => Some(c - b'a' + 10), + b'A'..=b'F' => Some(c - b'A' + 10), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::anchor::AnchorLabel; + use crate::extract::{AnchorFeature, Features}; + use crate::geometry::NodeGeometry; + + fn af(label: AnchorLabel, variance: f32, motion: f32) -> AnchorFeature { + af_mean(label, 1.0, variance, motion) + } + + fn af_mean(label: AnchorLabel, mean: f32, variance: f32, motion: f32) -> AnchorFeature { + AnchorFeature { + room_id: "living-room".into(), + label, + features: Features { + mean, + variance, + motion, + breathing_score: 0.0, + breathing_hz: 0.0, + heart_score: 0.0, + heart_hz: 0.0, + }, + } + } + + fn anchors() -> Vec { + vec![ + af_mean(AnchorLabel::Empty, 1.0, 1.0, 0.1), + af_mean(AnchorLabel::StandStill, 3.0, 10.0, 0.2), + af(AnchorLabel::Sit, 6.0, 0.2), + af(AnchorLabel::LieDown, 3.0, 0.2), + af(AnchorLabel::SmallMove, 4.0, 1.2), + af(AnchorLabel::SleepPosture, 3.0, 0.1), + ] + } + + fn bank_with_geometry() -> SpecialistBank { + let geometry = vec![ + NodeGeometry::new(1, "tape-measure").with_position(0.0, 0.0, 1.0), + NodeGeometry::new(2, "tape-measure").with_position(3.0, 0.0, 1.0), + ]; + SpecialistBank::train("living-room", "base-1", &anchors(), 1000) + .unwrap() + .with_geometry(geometry) + } + + fn signer() -> KeyedHashSigner { + KeyedHashSigner::new("sensor-42", b"top-secret".to_vec()) + } + + fn measured_params(version: u64) -> MintParams { + MintParams { + space_id: "home/living-room".into(), + sensor_id: "sensor-42".into(), + captured_at_unix_s: 1_000_000, + validity_secs: 3600, + version, + tier: CalibrationTier::Auto, + evidence: EvidenceLevel::L2, + source: CharacterizationSource::MeasuredCsi, + envelope: CompatibilityEnvelope::default(), + } + } + + #[test] + fn mint_is_deterministic() { + let bank = bank_with_geometry(); + let s = signer(); + let a = CalibrationCertificate::mint(measured_params(1), &bank, &s).unwrap(); + let b = CalibrationCertificate::mint(measured_params(1), &bank, &s).unwrap(); + assert_eq!(a, b, "same inputs → identical certificate"); + assert_eq!(a.content_hash(), b.content_hash()); + assert_eq!(a.signature, b.signature); + } + + #[test] + fn signature_round_trips_and_rejects_tampering() { + let bank = bank_with_geometry(); + let s = signer(); + let cert = CalibrationCertificate::mint(measured_params(1), &bank, &s).unwrap(); + assert!(cert.verify_signature(&s), "freshly minted cert verifies"); + + // Tamper with a signable field: the recorded content hash no longer matches. + let mut tampered = cert.clone(); + tampered.expires_at_unix_s += 10_000; + assert!(!tampered.verify_signature(&s), "expiry tamper is rejected"); + + // Tamper with the fingerprint payload. + let mut tampered2 = cert.clone(); + tampered2.fingerprint.empty_mean += 5.0; + assert!( + !tampered2.verify_signature(&s), + "fingerprint tamper is rejected" + ); + + // Wrong key does not verify. + let other = KeyedHashSigner::new("sensor-42", b"different-secret".to_vec()); + assert!(!cert.verify_signature(&other), "wrong secret is rejected"); + } + + #[test] + fn version_is_monotonic_across_renewals() { + let bank = bank_with_geometry(); + let s = signer(); + let v1 = CalibrationCertificate::mint(measured_params(1), &bank, &s).unwrap(); + let v2 = v1.renew(2_000_000, 3600, &bank, &s).unwrap(); + let v3 = v2.renew(3_000_000, 3600, &bank, &s).unwrap(); + assert_eq!(v1.version, 1); + assert_eq!(v2.version, 2); + assert_eq!(v3.version, 3); + assert!(v1.version < v2.version && v2.version < v3.version); + // Renewal preserves identity but is a distinct, freshly signed artifact. + assert_eq!(v2.space_id, v1.space_id); + assert_eq!(v2.sensor_id, v1.sensor_id); + assert_ne!(v2.content_hash(), v1.content_hash()); + assert!(v2.verify_signature(&s)); + } + + #[test] + fn version_zero_is_rejected() { + let bank = bank_with_geometry(); + let s = signer(); + assert!(CalibrationCertificate::mint(measured_params(0), &bank, &s).is_err()); + } + + #[test] + fn compare_identical_vs_drifted() { + let bank = bank_with_geometry(); + let s = signer(); + let cert = CalibrationCertificate::mint(measured_params(1), &bank, &s).unwrap(); + + // Identical fingerprint → zero drift. + let same = cert.fingerprint.clone(); + let d0 = cert.fingerprint.distance(&same); + assert_eq!(d0.total, 0.0); + assert_eq!(d0.baseline_drift, 0.0); + assert_eq!(d0.occupancy_drift, 0.0); + + // A drifted room (empty-room mean moved) → positive, larger drift. + let mut drifted = cert.fingerprint.clone(); + drifted.empty_mean += 4.0; + let d1 = cert.fingerprint.distance(&drifted); + assert!(d1.total > d0.total); + assert!(d1.baseline_drift > 0.0); + assert!(d1.total < 1.0, "total is bounded in [0, 1)"); + } + + #[test] + fn distance_is_monotonic() { + let base = RoomFingerprint { + schema_version: FINGERPRINT_SCHEMA_VERSION, + empty_mean: 1.0, + empty_variance: 5.0, + occupied_variance: 10.0, + presence_threshold: 5.5, + occupancy_mean_shift: 2.0, + geometry: GeometryEmbedding::default(), + }; + let mut last = -1.0; + for step in 0..8 { + let mut perturbed = base.clone(); + perturbed.empty_mean = base.empty_mean + step as f32; + let d = base.distance(&perturbed).total; + assert!( + d > last, + "distance must increase with perturbation (step {step}: {d} <= {last})" + ); + last = d; + } + } + + #[test] + fn expiry_invalidates() { + let bank = bank_with_geometry(); + let s = signer(); + let cert = CalibrationCertificate::mint(measured_params(1), &bank, &s).unwrap(); + let current = cert.fingerprint.clone(); + + // Before expiry, same room → valid. + assert!(cert.is_valid(¤t, 1_000_500, &s)); + assert!(matches!( + cert.status(¤t, 1_000_500, &s), + CertificateStatus::Valid { .. } + )); + + // At/after expiry → Expired. + assert!(!cert.is_valid(¤t, cert.expires_at_unix_s, &s)); + assert!(matches!( + cert.status(¤t, cert.expires_at_unix_s + 1, &s), + CertificateStatus::Expired { .. } + )); + } + + #[test] + fn drift_beyond_envelope_invalidates() { + let bank = bank_with_geometry(); + let s = signer(); + let mut params = measured_params(1); + params.envelope = CompatibilityEnvelope::new(0.05).unwrap(); + let cert = CalibrationCertificate::mint(params, &bank, &s).unwrap(); + + // Small drift stays inside the envelope → valid. + let mut small = cert.fingerprint.clone(); + small.empty_mean += 0.01; + assert!(cert.is_valid(&small, 1_000_500, &s)); + + // Large drift breaches the envelope → Drifted (explicit invalidation). + let mut large = cert.fingerprint.clone(); + large.empty_mean += 10.0; + match cert.status(&large, 1_000_500, &s) { + CertificateStatus::Drifted { distance, envelope } => { + assert!(distance.total > envelope.max_total_drift); + } + other => panic!("expected Drifted, got {other:?}"), + } + } + + #[test] + fn tampered_signature_takes_precedence() { + let bank = bank_with_geometry(); + let s = signer(); + let mut cert = CalibrationCertificate::mint(measured_params(1), &bank, &s).unwrap(); + cert.fingerprint.empty_mean += 1.0; // invalidate the signature + let current = cert.fingerprint.clone(); + assert!(matches!( + cert.status(¤t, 1_000_500, &s), + CertificateStatus::TamperedSignature + )); + } + + #[test] + fn json_round_trip() { + let bank = bank_with_geometry(); + let s = signer(); + let cert = CalibrationCertificate::mint(measured_params(1), &bank, &s).unwrap(); + let json = cert.to_json().unwrap(); + let back = CalibrationCertificate::from_json(&json).unwrap(); + assert_eq!(cert, back); + // Signature still verifies after a serialization round-trip. + assert!(back.verify_signature(&s)); + } + + #[test] + fn synthetic_cannot_be_labelled_measured() { + let bank = bank_with_geometry(); + let s = signer(); + let mut params = measured_params(1); + params.source = CharacterizationSource::Synthetic; + params.evidence = EvidenceLevel::L2; // synthetic claiming measured + let err = CalibrationCertificate::mint(params, &bank, &s).unwrap_err(); + assert!(matches!(err, CalibrationError::SyntheticMislabel { .. })); + + // Correctly labelled synthetic (L0) is accepted. + let mut ok = measured_params(1); + ok.source = CharacterizationSource::Synthetic; + ok.evidence = EvidenceLevel::L0Synthetic; + assert!(CalibrationCertificate::mint(ok, &bank, &s).is_ok()); + } + + #[test] + fn auto_tier_cannot_over_claim_evidence() { + let bank = bank_with_geometry(); + let s = signer(); + let mut params = measured_params(1); + params.tier = CalibrationTier::Auto; + params.evidence = EvidenceLevel::L3; // Auto is capped at L2 + assert!(CalibrationCertificate::mint(params, &bank, &s).is_err()); + } + + #[test] + fn sensor_identity_must_match_signer() { + let bank = bank_with_geometry(); + let s = signer(); + let mut params = measured_params(1); + params.sensor_id = "some-other-device".into(); + assert!(CalibrationCertificate::mint(params, &bank, &s).is_err()); + } + + #[test] + fn fingerprint_from_bank_reuses_presence_separation() { + let bank = bank_with_geometry(); + let fp = RoomFingerprint::from_bank(&bank); + let presence = bank.presence.as_ref().unwrap(); + assert_eq!(fp.empty_mean, presence.empty_mean); + assert_eq!(fp.occupied_variance, presence.occupied_var); + assert_eq!(fp.geometry, bank.geometry_embedding()); + assert!(fp.geometry.as_slice().iter().any(|&x| x != 0.0)); + } + + #[test] + fn invalid_envelope_is_rejected() { + assert!(CompatibilityEnvelope::new(-0.1).is_err()); + assert!(CompatibilityEnvelope::new(1.0).is_err()); + assert!(CompatibilityEnvelope::new(f32::NAN).is_err()); + assert!(CompatibilityEnvelope::new(0.2).is_ok()); + } +} diff --git a/v2/crates/wifi-densepose-calibration/src/error.rs b/v2/crates/wifi-densepose-calibration/src/error.rs index 197b9d76..0dc834c8 100644 --- a/v2/crates/wifi-densepose-calibration/src/error.rs +++ b/v2/crates/wifi-densepose-calibration/src/error.rs @@ -35,6 +35,18 @@ pub enum CalibrationError { #[error("serialization error: {0}")] Serde(String), + /// A calibration certificate failed validation at construction (ADR-301). + #[error("invalid calibration certificate: {0}")] + InvalidCertificate(String), + + /// A synthetic characterization was labelled as measured evidence — rejected + /// by the honesty discipline (ADR-279 invariant 6, ADR-282 ladder, ADR-301). + #[error("synthetic characterization cannot be labelled measured (claimed {claimed})")] + SyntheticMislabel { + /// The measured evidence level that was wrongly claimed for synthetic input. + claimed: String, + }, + /// The specialist bank was trained against a different baseline and is stale. #[error("bank is STALE: trained against baseline {trained}, current is {current}")] StaleBaseline { diff --git a/v2/crates/wifi-densepose-calibration/src/lib.rs b/v2/crates/wifi-densepose-calibration/src/lib.rs index db407cf7..278f7dff 100644 --- a/v2/crates/wifi-densepose-calibration/src/lib.rs +++ b/v2/crates/wifi-densepose-calibration/src/lib.rs @@ -23,6 +23,7 @@ pub mod anchor; pub mod bank; +pub mod certificate; pub mod enrollment; pub mod error; pub mod extract; @@ -34,6 +35,11 @@ pub mod specialist; pub use anchor::{Anchor, AnchorLabel, AnchorQuality, EnrollmentEvent, EnrollmentSession, Posture}; pub use bank::SpecialistBank; +pub use certificate::{ + CalibrationCertificate, CalibrationTier, CertificateSignature, CertificateSigner, + CertificateStatus, CertificateVerifier, CharacterizationSource, CompatibilityEnvelope, + EvidenceLevel, FingerprintDistance, KeyedHashSigner, MintParams, RoomFingerprint, +}; pub use enrollment::{AnchorQualityGate, AnchorRecorder}; pub use error::{CalibrationError, Result}; pub use extract::AnchorFeature; diff --git a/v2/crates/wifi-densepose-mat/Cargo.toml b/v2/crates/wifi-densepose-mat/Cargo.toml index cea80cdf..848770f3 100644 --- a/v2/crates/wifi-densepose-mat/Cargo.toml +++ b/v2/crates/wifi-densepose-mat/Cargo.toml @@ -101,6 +101,11 @@ approx = "0.5" name = "detection_bench" harness = false +# FeitCSI record parse throughput at wideband 802.11ax shapes (ADR-292). +[[bench]] +name = "feitcsi_bench" +harness = false + [package.metadata.docs.rs] all-features = true rustdoc-args = ["--cfg", "docsrs"] diff --git a/v2/crates/wifi-densepose-mat/benches/feitcsi_bench.rs b/v2/crates/wifi-densepose-mat/benches/feitcsi_bench.rs new file mode 100644 index 00000000..7e677e68 --- /dev/null +++ b/v2/crates/wifi-densepose-mat/benches/feitcsi_bench.rs @@ -0,0 +1,69 @@ +//! Criterion benchmark for FeitCSI record parse throughput (ADR-292). +//! +//! Measures `parse_record` over synthetic in-code fixtures at the wideband +//! 802.11ax shapes: 20 MHz (242 tones), 80 MHz (996) and the headline +//! 160 MHz / 1992-subcarrier frames an AX210 delivers. Fixtures are +//! deterministic; no wall-clock or randomness feeds the parsed bytes. + +use criterion::{black_box, criterion_group, criterion_main, Criterion, Throughput}; +use wifi_densepose_mat::integration::feitcsi::{parse_record, synth, FeitCsiStreamReader}; + +fn bench_parse(c: &mut Criterion) { + let mut group = c.benchmark_group("feitcsi_parse"); + + // (label, chan_width_val, HE tone count) + let shapes = [ + ("he20_242sc", 0u32, 242u32), + ("he80_996sc", 2u32, 996u32), + ("he160_1992sc", 3u32, 1992u32), + ]; + + for (label, cw, sc) in shapes { + // 2x1 MIMO, HE modulation, fixed timestamp: deterministic bytes. + let bytes = synth::record_bytes(2, 1, sc, cw, 4, 1_000_000); + group.throughput(Throughput::Bytes(bytes.len() as u64)); + group.bench_function(label, |b| { + b.iter(|| { + let (record, consumed) = + parse_record(black_box(&bytes)).expect("valid synthetic record"); + black_box((record.csi.len(), consumed)) + }) + }); + } + + group.finish(); +} + +/// Stream-reader throughput over an in-memory multi-record capture at the +/// headline 160 MHz / 1992-subcarrier shape. Exercises the reusable payload +/// scratch buffer in `FeitCsiStreamReader` (one raw-byte allocation per +/// stream, not per record). +fn bench_stream(c: &mut Criterion) { + let mut group = c.benchmark_group("feitcsi_stream"); + + const RECORDS: u64 = 16; + let mut capture = Vec::new(); + for ts in 0..RECORDS { + // 2x1 MIMO, 160 MHz HE, deterministic device timestamps. + capture.extend_from_slice(&synth::record_bytes(2, 1, 1992, 3, 4, ts * 1_000)); + } + + group.throughput(Throughput::Bytes(capture.len() as u64)); + group.bench_function("he160_1992sc_x16_records", |b| { + b.iter(|| { + let mut reader = FeitCsiStreamReader::new(std::io::Cursor::new(black_box(&capture[..]))); + let mut records = 0u64; + while let Some(rec) = reader.read_next().expect("valid synthetic capture") { + black_box(rec.csi.len()); + records += 1; + } + assert_eq!(records, RECORDS); + black_box(records) + }) + }); + + group.finish(); +} + +criterion_group!(benches, bench_parse, bench_stream); +criterion_main!(benches); diff --git a/v2/crates/wifi-densepose-mat/src/integration/csi_receiver.rs b/v2/crates/wifi-densepose-mat/src/integration/csi_receiver.rs index 06de25a2..3c1b3014 100644 --- a/v2/crates/wifi-densepose-mat/src/integration/csi_receiver.rs +++ b/v2/crates/wifi-densepose-mat/src/integration/csi_receiver.rs @@ -1293,6 +1293,9 @@ impl From for CsiReadings { rssi: Some(packet.rssi as f64), noise_floor: Some(packet.noise_floor as f64), fc_type: FrameControlType::Data, + // Narrowband receiver formats predate wideband provenance + // metadata; FeitCSI ingest attaches it in feitcsi.rs. + wideband: None, }, } } diff --git a/v2/crates/wifi-densepose-mat/src/integration/feitcsi.rs b/v2/crates/wifi-densepose-mat/src/integration/feitcsi.rs new file mode 100644 index 00000000..436a3db8 --- /dev/null +++ b/v2/crates/wifi-densepose-mat/src/integration/feitcsi.rs @@ -0,0 +1,983 @@ +//! Validated parser for FeitCSI binary CSI records (ADR-292). +//! +//! [FeitCSI](https://feitcsi.kuskosoft.com) is an open-source tool +//! () that extracts 802.11ax channel +//! state information from Intel AX200/AX210 NICs at 20/40/80/160 MHz, +//! including the 6 GHz band. FeitCSI is GPL and is used strictly as an +//! *external* capture tool: RuView never links it, never configures the NIC, +//! and only parses the record files/streams its tooling produces. +//! +//! # Record layout (verified against FeitCSI source, `master` @ 2026-08-10) +//! +//! Each record is a packed 272-byte header followed by `csi_data_size` bytes +//! of raw CSI. Layout per `include/Csi.h` (`struct __attribute__((__packed__)) +//! RawHeaderData`) and `Csi::save()` in `src/Csi.cpp`, which writes the raw +//! struct memory followed by the CSI buffer. FeitCSI runs on little-endian +//! x86 hosts and dumps native struct memory, so all fields are little-endian. +//! +//! | Offset | Size | Field | Notes | +//! |-------:|-----:|------------------|-----------------------------------------| +//! | 0 | 4 | `csiDataSize` | u32, bytes of CSI payload after header | +//! | 4 | 4 | reserved | (`space4`) | +//! | 8 | 4 | `ftmClock` | u32 | +//! | 12 | 8 | `timestamp` | u64, device timestamp (microseconds) | +//! | 20 | 26 | reserved | (`space20`) | +//! | 46 | 1 | `numRx` | u8, receive antennas | +//! | 47 | 1 | `numTx` | u8, transmit streams | +//! | 48 | 4 | reserved | (`space48`) | +//! | 52 | 4 | `numSubCarriers` | u32 | +//! | 56 | 4 | reserved | (`space54`; upstream field name lags | +//! | | | | the actual packed offset) | +//! | 60 | 4 | `rssi1` | u32, antenna A RSSI | +//! | 64 | 4 | `rssi2` | u32, antenna B RSSI | +//! | 68 | 6 | `srcMac` | source MAC address | +//! | 74 | 18 | reserved | (`space75`) | +//! | 92 | 4 | `rateNflag` | u32, iwlwifi rate flags (see below) | +//! | 96 | 176 | reserved | (`space96`, 44 × u32) | +//! +//! CSI payload: interleaved little-endian `i16` I/Q pairs, iterated +//! `for rx { for tx { for subcarrier { i16 real, i16 imag } } }` (per the +//! processing loops in `src/Csi.cpp`), i.e. 4 bytes per complex sample and +//! `csiDataSize == numRx * numTx * numSubCarriers * 4`. +//! +//! `rateNflag` uses the iwlwifi rate/flags encoding vendored by FeitCSI in +//! `lib/include/rs.h`: modulation type in bits 8..11 (`RATE_MCS_MOD_TYPE`, +//! 0=CCK, 1=legacy OFDM, 2=HT, 3=VHT, 4=HE, 5=EHT) and channel width in bits +//! 11..14 (`RATE_MCS_CHAN_WIDTH`, 0=20 MHz, 1=40, 2=80, 3=160, 4=320). +//! +//! # Failing loudly on format drift +//! +//! The on-disk format carries **no magic number or version field** (it is the +//! raw iwlwifi notification header), so the "version check" required by +//! ADR-292 is structural and strict: +//! +//! - the declared dimensions must agree exactly with the declared buffer +//! length ([`FeitCsiError::DimensionMismatch`]); +//! - dimensions are hard-capped ([`MAX_SUBCARRIERS`], [`MAX_ANTENNAS`]) so a +//! corrupt length can never cause unbounded allocation +//! ([`FeitCsiError::CapExceeded`]); +//! - `rateNflag` values outside the vendored `rs.h` encoding (unknown +//! modulation type, or a channel width this parser does not support, e.g. +//! 320 MHz EHT) are rejected as [`FeitCsiError::UnsupportedFormat`] instead +//! of being misparsed. +//! +//! All input is untrusted: every read is length-checked, allocation is +//! bounded before it happens, and malformed input yields structured errors, +//! never a panic. + +use super::hardware_adapter::{ + Bandwidth, CsiMetadata, CsiReadings, DeviceType, FrameControlType, SensorCsiReading, + SubcarrierMapping, WidebandMeta, WifiBand, +}; +use super::AdapterError; +use chrono::{DateTime, Utc}; +use num_complex::Complex64; +use std::io::Read; + +/// Size of the packed FeitCSI record header in bytes. +pub const HEADER_LEN: usize = 272; + +/// Hard cap on the declared subcarrier count (802.11ax 160 MHz HE is 1992; +/// 4096 leaves headroom for future 802.11bf truncated-CIR shapes without +/// permitting unbounded allocation from a corrupt length field). +pub const MAX_SUBCARRIERS: u32 = 4096; + +/// Hard cap on declared antenna/stream counts (AX210 is 2x2; 8 is generous). +pub const MAX_ANTENNAS: u8 = 8; + +/// Bytes per complex CSI sample (i16 real + i16 imag). +const BYTES_PER_SAMPLE: usize = 4; + +/// Upper bound on a single record's total size (header + max payload). +/// Used to bound stream-mode buffering. +pub const MAX_RECORD_BYTES: usize = HEADER_LEN + + MAX_ANTENNAS as usize * MAX_ANTENNAS as usize * MAX_SUBCARRIERS as usize * BYTES_PER_SAMPLE; + +// iwlwifi rate flag encoding, per FeitCSI `lib/include/rs.h`. +const RATE_MCS_MOD_TYPE_POS: u32 = 8; +const RATE_MCS_MOD_TYPE_MSK: u32 = 0x7 << RATE_MCS_MOD_TYPE_POS; +const RATE_MCS_CHAN_WIDTH_POS: u32 = 11; +const RATE_MCS_CHAN_WIDTH_MSK: u32 = 0x7 << RATE_MCS_CHAN_WIDTH_POS; + +/// Structured errors for FeitCSI record parsing. Malformed input always +/// yields one of these — the parser never panics on untrusted bytes. +#[derive(Debug, thiserror::Error)] +pub enum FeitCsiError { + /// The buffer ends before the declared record does. + #[error("truncated FeitCSI record: need {needed} bytes, got {got}")] + Truncated { + /// Bytes required to complete the header or record. + needed: usize, + /// Bytes actually available. + got: usize, + }, + + /// The declared CSI buffer length disagrees with the declared dimensions. + #[error( + "FeitCSI dimension mismatch: header declares csi_data_size={declared} \ + but num_rx={num_rx} * num_tx={num_tx} * num_subcarriers={num_subcarriers} \ + * 4 = {expected}" + )] + DimensionMismatch { + /// `csiDataSize` from the header. + declared: u32, + /// Size implied by the dimension fields. + expected: usize, + /// Declared receive antenna count. + num_rx: u8, + /// Declared transmit stream count. + num_tx: u8, + /// Declared subcarrier count. + num_subcarriers: u32, + }, + + /// A dimension field is zero — the record cannot contain CSI. + #[error("FeitCSI record declares zero-sized dimension: {field}")] + ZeroDimension { + /// Which header field was zero. + field: &'static str, + }, + + /// A dimension exceeds its hard cap; parsing stops before any + /// allocation sized from the corrupt value. + #[error("FeitCSI {field}={value} exceeds hard cap {cap} (corrupt or hostile length)")] + CapExceeded { + /// Which header field exceeded its cap. + field: &'static str, + /// The declared value. + value: u64, + /// The enforced cap. + cap: u64, + }, + + /// `rateNflag` encodes a modulation type or channel width outside the + /// layout this parser was written against — fail loudly instead of + /// misparsing a newer/unknown format revision. + #[error( + "unsupported FeitCSI rate flags {rate_n_flags:#010x}: {reason} \ + (layout per FeitCSI master @ 2026-08-10; refusing to guess)" + )] + UnsupportedFormat { + /// Raw `rateNflag` value. + rate_n_flags: u32, + /// Which sub-field was unrecognized. + reason: &'static str, + }, + + /// I/O error while reading a record from a file or stream. + #[error("FeitCSI I/O error: {0}")] + Io(#[from] std::io::Error), +} + +impl From for AdapterError { + fn from(e: FeitCsiError) -> Self { + match e { + FeitCsiError::UnsupportedFormat { .. } => AdapterError::UnsupportedAdapter(e.to_string()), + FeitCsiError::Io(io) => AdapterError::Io(io), + _ => AdapterError::DataFormat(e.to_string()), + } + } +} + +/// Modulation type decoded from `rateNflag` (iwlwifi `RATE_MCS_MOD_TYPE`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FeitCsiModType { + /// Legacy CCK (802.11b) + Cck, + /// Legacy OFDM (802.11a/g) + LegacyOfdm, + /// HT (802.11n) + Ht, + /// VHT (802.11ac) + Vht, + /// HE (802.11ax) + He, + /// EHT (802.11be) + Eht, +} + +/// Channel bandwidth decoded from `rateNflag` (iwlwifi `RATE_MCS_CHAN_WIDTH`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FeitCsiBandwidth { + /// 20 MHz + Bw20, + /// 40 MHz + Bw40, + /// 80 MHz + Bw80, + /// 160 MHz + Bw160, +} + +impl FeitCsiBandwidth { + /// Bandwidth in MHz. + pub fn mhz(&self) -> u16 { + match self { + Self::Bw20 => 20, + Self::Bw40 => 40, + Self::Bw80 => 80, + Self::Bw160 => 160, + } + } + + /// Map to the adapter-level [`Bandwidth`] enum. + pub fn to_bandwidth(&self) -> Bandwidth { + match self { + Self::Bw20 => Bandwidth::HT20, + Self::Bw40 => Bandwidth::HT40, + Self::Bw80 => Bandwidth::VHT80, + Self::Bw160 => Bandwidth::VHT160, + } + } +} + +/// Validated header fields of one FeitCSI record. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FeitCsiHeader { + /// Declared CSI payload size in bytes (already validated against dims). + pub csi_data_size: u32, + /// FTM clock value. + pub ftm_clock: u32, + /// Device timestamp (microseconds, per upstream usage). + pub timestamp_us: u64, + /// Number of receive antennas. + pub num_rx: u8, + /// Number of transmit streams. + pub num_tx: u8, + /// Native subcarrier count of this frame. + pub num_subcarriers: u32, + /// Antenna A RSSI (raw u32 as stored on disk). + pub rssi1: u32, + /// Antenna B RSSI (raw u32 as stored on disk). + pub rssi2: u32, + /// Source MAC address. + pub source_mac: [u8; 6], + /// Raw iwlwifi rate flags. + pub rate_n_flags: u32, + /// Modulation type decoded from `rate_n_flags`. + pub mod_type: FeitCsiModType, + /// Channel bandwidth decoded from `rate_n_flags`. + pub bandwidth: FeitCsiBandwidth, +} + +/// One parsed FeitCSI record: validated header plus complex CSI, kept at +/// native dimensionality (`num_rx * num_tx * num_subcarriers` samples, +/// iterated rx-major, then tx, then subcarrier). +#[derive(Debug, Clone, PartialEq)] +pub struct FeitCsiRecord { + /// Validated header. + pub header: FeitCsiHeader, + /// Complex CSI samples, flattened `[rx][tx][subcarrier]`. + pub csi: Vec, +} + +impl FeitCsiRecord { + /// CSI for one (rx, tx) antenna pair as a subcarrier slice, or `None` + /// when the indices are out of range. + pub fn antenna_pair(&self, rx: u8, tx: u8) -> Option<&[Complex64]> { + if rx >= self.header.num_rx || tx >= self.header.num_tx { + return None; + } + let sc = self.header.num_subcarriers as usize; + let start = (rx as usize * self.header.num_tx as usize + tx as usize) * sc; + self.csi.get(start..start + sc) + } + + /// Convert to adapter-level [`CsiReadings`], one [`SensorCsiReading`] per + /// (rx, tx) antenna pair, carrying native subcarrier count, bandwidth and + /// band as first-class frame metadata (ADR-292). + /// + /// The FeitCSI header does not record channel/band (the capture + /// configuration owns that), so both are supplied by the caller. The + /// timestamp is derived deterministically from the record's own device + /// timestamp, never from wall-clock, so file replay is reproducible. + pub fn to_readings(&self, band: WifiBand, channel: u8) -> CsiReadings { + let sc = self.header.num_subcarriers as usize; + let mut readings = Vec::with_capacity(self.header.num_rx as usize * self.header.num_tx as usize); + + // Interpret the on-disk u32 RSSI as two's-complement dBm (captures + // store negative dBm values in the raw register field). + let rssi_dbm = |raw: u32| raw as i32 as f64; + let rssi = rssi_dbm(self.header.rssi1).max(rssi_dbm(self.header.rssi2)); + + let mac = self.header.source_mac; + let tx_mac = format!( + "{:02X}:{:02X}:{:02X}:{:02X}:{:02X}:{:02X}", + mac[0], mac[1], mac[2], mac[3], mac[4], mac[5] + ); + + for rx in 0..self.header.num_rx { + for tx in 0..self.header.num_tx { + let pair = self + .antenna_pair(rx, tx) + .expect("indices bounded by validated header dims"); + let mut amplitudes = Vec::with_capacity(sc); + let mut phases = Vec::with_capacity(sc); + for c in pair { + amplitudes.push(c.norm()); + phases.push(c.im.atan2(c.re)); + } + readings.push(SensorCsiReading { + sensor_id: format!("feitcsi_rx{rx}_tx{tx}"), + amplitudes, + phases, + rssi, + noise_floor: -92.0, + tx_mac: Some(tx_mac.clone()), + rx_mac: None, + sequence_num: None, + }); + } + } + + // Deterministic timestamp from the device clock (microseconds since + // capture epoch); replay of the same bytes yields the same output. + let timestamp = DateTime::::from_timestamp_micros(self.header.timestamp_us as i64) + .unwrap_or_else(|| DateTime::::from_timestamp(0, 0).expect("epoch is valid")); + + CsiReadings { + timestamp, + readings, + metadata: CsiMetadata { + device_type: DeviceType::FeitCsi, + channel, + bandwidth: self.header.bandwidth.to_bandwidth(), + num_subcarriers: sc, + rssi: Some(rssi), + noise_floor: None, + fc_type: FrameControlType::Data, + wideband: Some(WidebandMeta { + band, + bandwidth_mhz: self.header.bandwidth.mhz(), + native_subcarriers: sc, + mapping: None, + }), + }, + } + } +} + +/// Validate the fixed-size header. Enforces caps and dimension/length +/// consistency BEFORE any allocation is sized from untrusted fields. +fn validate_header(h: &[u8; HEADER_LEN]) -> Result { + let u32_at = |off: usize| u32::from_le_bytes([h[off], h[off + 1], h[off + 2], h[off + 3]]); + + let csi_data_size = u32_at(0); + let ftm_clock = u32_at(8); + let timestamp_us = u64::from_le_bytes([ + h[12], h[13], h[14], h[15], h[16], h[17], h[18], h[19], + ]); + let num_rx = h[46]; + let num_tx = h[47]; + let num_subcarriers = u32_at(52); + let rssi1 = u32_at(60); + let rssi2 = u32_at(64); + let mut source_mac = [0u8; 6]; + source_mac.copy_from_slice(&h[68..74]); + let rate_n_flags = u32_at(92); + + // Zero dimensions cannot carry CSI. + if num_rx == 0 { + return Err(FeitCsiError::ZeroDimension { field: "num_rx" }); + } + if num_tx == 0 { + return Err(FeitCsiError::ZeroDimension { field: "num_tx" }); + } + if num_subcarriers == 0 { + return Err(FeitCsiError::ZeroDimension { + field: "num_subcarriers", + }); + } + + // Hard caps: corrupt lengths must not size any allocation. + if num_rx > MAX_ANTENNAS { + return Err(FeitCsiError::CapExceeded { + field: "num_rx", + value: num_rx as u64, + cap: MAX_ANTENNAS as u64, + }); + } + if num_tx > MAX_ANTENNAS { + return Err(FeitCsiError::CapExceeded { + field: "num_tx", + value: num_tx as u64, + cap: MAX_ANTENNAS as u64, + }); + } + if num_subcarriers > MAX_SUBCARRIERS { + return Err(FeitCsiError::CapExceeded { + field: "num_subcarriers", + value: num_subcarriers as u64, + cap: MAX_SUBCARRIERS as u64, + }); + } + + // Dimensions vs declared buffer length. Capped dims bound this product + // at 8 * 8 * 4096 * 4 = 1 MiB, so the arithmetic cannot overflow usize. + let expected = + num_rx as usize * num_tx as usize * num_subcarriers as usize * BYTES_PER_SAMPLE; + if csi_data_size as usize != expected { + return Err(FeitCsiError::DimensionMismatch { + declared: csi_data_size, + expected, + num_rx, + num_tx, + num_subcarriers, + }); + } + + // Format-revision check on the rate flags: reject encodings outside the + // vendored rs.h layout this parser was written against. + let mod_type = match (rate_n_flags & RATE_MCS_MOD_TYPE_MSK) >> RATE_MCS_MOD_TYPE_POS { + 0 => FeitCsiModType::Cck, + 1 => FeitCsiModType::LegacyOfdm, + 2 => FeitCsiModType::Ht, + 3 => FeitCsiModType::Vht, + 4 => FeitCsiModType::He, + 5 => FeitCsiModType::Eht, + _ => { + return Err(FeitCsiError::UnsupportedFormat { + rate_n_flags, + reason: "unknown modulation type (bits 8..11)", + }) + } + }; + let bandwidth = match (rate_n_flags & RATE_MCS_CHAN_WIDTH_MSK) >> RATE_MCS_CHAN_WIDTH_POS { + 0 => FeitCsiBandwidth::Bw20, + 1 => FeitCsiBandwidth::Bw40, + 2 => FeitCsiBandwidth::Bw80, + 3 => FeitCsiBandwidth::Bw160, + // 4 = 320 MHz (EHT); not supported by this ingest revision. + _ => { + return Err(FeitCsiError::UnsupportedFormat { + rate_n_flags, + reason: "unsupported channel width (bits 11..14; 320 MHz+ not supported)", + }) + } + }; + + Ok(FeitCsiHeader { + csi_data_size, + ftm_clock, + timestamp_us, + num_rx, + num_tx, + num_subcarriers, + rssi1, + rssi2, + source_mac, + rate_n_flags, + mod_type, + bandwidth, + }) +} + +/// Decode a validated CSI payload (interleaved little-endian i16 I/Q pairs) +/// into complex samples. The caller has already validated `payload.len()` +/// against the header dimensions, so this performs exactly one bounded +/// allocation (`chunks_exact` is an exact-size iterator, so `collect` +/// reserves the final length up front) and the conversion loop itself is +/// allocation-free. +#[inline] +fn decode_csi(payload: &[u8]) -> Vec { + payload + .chunks_exact(BYTES_PER_SAMPLE) + .map(|sample| { + let re = i16::from_le_bytes([sample[0], sample[1]]) as f64; + let im = i16::from_le_bytes([sample[2], sample[3]]) as f64; + Complex64::new(re, im) + }) + .collect() +} + +/// Parse one record from the front of `buf`. +/// +/// Returns the record and the number of bytes consumed, so callers can walk +/// a multi-record capture. All validation happens before any allocation is +/// sized from untrusted fields; malformed input yields a structured +/// [`FeitCsiError`], never a panic. The header is read in place (no copy) +/// and the payload is converted directly from the input slice, so the CSI +/// bytes are traversed exactly once. +pub fn parse_record(buf: &[u8]) -> Result<(FeitCsiRecord, usize), FeitCsiError> { + if buf.len() < HEADER_LEN { + return Err(FeitCsiError::Truncated { + needed: HEADER_LEN, + got: buf.len(), + }); + } + let header_bytes: &[u8; HEADER_LEN] = buf[..HEADER_LEN] + .try_into() + .expect("slice length checked above"); + let header = validate_header(header_bytes)?; + + let payload_len = header.csi_data_size as usize; + let total = HEADER_LEN + payload_len; + if buf.len() < total { + return Err(FeitCsiError::Truncated { + needed: total, + got: buf.len(), + }); + } + + // payload_len == validated expected size <= 1 MiB: bounded allocation. + let csi = decode_csi(&buf[HEADER_LEN..total]); + + Ok((FeitCsiRecord { header, csi }, total)) +} + +/// Read one record from a byte stream (file, pipe, socket wrapper). +/// +/// Returns `Ok(None)` on clean EOF (no bytes before end-of-stream); a +/// mid-record EOF is a [`FeitCsiError::Truncated`] error. `read_exact` +/// semantics mean a blocking pipe simply waits for the writer, so the same +/// code path serves file replay and streaming mode. +pub fn read_one_record( + reader: &mut R, +) -> Result, FeitCsiError> { + let mut scratch = Vec::new(); + read_one_record_with_scratch(reader, &mut scratch) +} + +/// [`read_one_record`] with a caller-owned scratch buffer for the raw +/// payload, so long-running replay/stream loops reuse one allocation across +/// records instead of allocating per record. The scratch is only ever +/// resized to the header-validated payload length (<= 1 MiB), never to an +/// untrusted value. +fn read_one_record_with_scratch( + reader: &mut R, + scratch: &mut Vec, +) -> Result, FeitCsiError> { + let mut header_bytes = [0u8; HEADER_LEN]; + let mut filled = 0usize; + while filled < HEADER_LEN { + let n = reader.read(&mut header_bytes[filled..])?; + if n == 0 { + if filled == 0 { + return Ok(None); // clean EOF between records + } + return Err(FeitCsiError::Truncated { + needed: HEADER_LEN, + got: filled, + }); + } + filled += n; + } + + let header = validate_header(&header_bytes)?; + let payload_len = header.csi_data_size as usize; // validated, <= 1 MiB + scratch.resize(payload_len, 0); + reader.read_exact(scratch).map_err(|e| { + if e.kind() == std::io::ErrorKind::UnexpectedEof { + FeitCsiError::Truncated { + needed: HEADER_LEN + payload_len, + got: HEADER_LEN, // header complete, payload short + } + } else { + FeitCsiError::Io(e) + } + })?; + + let csi = decode_csi(scratch); + + Ok(Some((FeitCsiRecord { header, csi }, HEADER_LEN + payload_len))) +} + +/// Streaming reader over any [`Read`] source (recorded capture file, or a +/// path/pipe an external FeitCSI process writes to). RuView never configures +/// the NIC — FeitCSI's own tooling owns capture, per least-authority. +/// +/// Holds a reusable payload scratch buffer so a long-running stream performs +/// one bounded raw-byte allocation total (plus the per-record `Vec` +/// output), rather than one raw-byte allocation per record. +pub struct FeitCsiStreamReader { + inner: R, + scratch: Vec, +} + +impl FeitCsiStreamReader { + /// Wrap a byte source. + pub fn new(inner: R) -> Self { + Self { + inner, + scratch: Vec::new(), + } + } + + /// Read the next record; `Ok(None)` on clean end-of-stream. + pub fn read_next(&mut self) -> Result, FeitCsiError> { + Ok(read_one_record_with_scratch(&mut self.inner, &mut self.scratch)?.map(|(rec, _)| rec)) + } +} + +/// Deterministic file-replay reader for recorded FeitCSI captures. +pub struct FeitCsiFileReader { + stream: FeitCsiStreamReader>, +} + +impl FeitCsiFileReader { + /// Open a recorded capture for sequential replay. + pub fn open(path: &str) -> Result { + let file = std::fs::File::open(path)?; + Ok(Self { + stream: FeitCsiStreamReader::new(std::io::BufReader::new(file)), + }) + } + + /// Read the next record; `Ok(None)` at end of capture. + pub fn read_next(&mut self) -> Result, FeitCsiError> { + self.stream.read_next() + } +} + +/// Convert wideband readings to the pipeline's subcarrier width via the +/// existing interpolation path (`wifi-densepose-signal`'s Catmull-Rom cubic +/// resampler from ADR-027), recording the native → pipeline mapping in frame +/// metadata so downstream consumers know the true spectral resolution +/// (ADR-292 §3). +/// +/// This is the ONLY sanctioned native→pipeline conversion: it is explicit, +/// and the mapping is auditable in `metadata.wideband.mapping`. +pub fn resample_readings_to_pipeline( + readings: &CsiReadings, + pipeline_subcarriers: usize, +) -> Result { + let normalizer = + wifi_densepose_signal::HardwareNormalizer::with_canonical_subcarriers(pipeline_subcarriers) + .map_err(|e| AdapterError::Config(format!("invalid pipeline width: {e}")))?; + + let native = readings.metadata.num_subcarriers; + let mut out = readings.clone(); + for reading in &mut out.readings { + reading.amplitudes = normalizer.resample_to_canonical(&reading.amplitudes); + reading.phases = normalizer.resample_to_canonical(&reading.phases); + } + out.metadata.num_subcarriers = pipeline_subcarriers; + + let mapping = SubcarrierMapping { + native, + pipeline: pipeline_subcarriers, + method: "catmull-rom-cubic", + }; + match &mut out.metadata.wideband { + Some(wb) => wb.mapping = Some(mapping), + None => { + // Preserve provenance even for frames that arrived without + // wideband metadata: native resolution is still recorded. + out.metadata.wideband = Some(WidebandMeta { + band: WifiBand::Band5GHz, + bandwidth_mhz: readings.metadata.bandwidth.mhz(), + native_subcarriers: native, + mapping: Some(mapping), + }); + } + } + Ok(out) +} + +/// Deterministic synthetic-fixture generation for tests and benchmarks. +/// +/// FeitCSI capture fixtures are always generated in code (never checked in +/// as binary files, per repo policy). CSI samples are a pure function of the +/// sample index, so round-trips are checkable and replay is reproducible. +pub mod synth { + use super::{BYTES_PER_SAMPLE, HEADER_LEN, RATE_MCS_CHAN_WIDTH_POS, RATE_MCS_MOD_TYPE_POS}; + + /// Build the bytes of one synthetic FeitCSI record with the given + /// dimensions, `rateNflag` channel-width value (0=20 MHz .. 3=160 MHz), + /// modulation-type value (4=HE), and device timestamp. + pub fn record_bytes( + num_rx: u8, + num_tx: u8, + num_subcarriers: u32, + chan_width_val: u32, + mod_type_val: u32, + timestamp_us: u64, + ) -> Vec { + let samples = num_rx as usize * num_tx as usize * num_subcarriers as usize; + let csi_data_size = (samples * BYTES_PER_SAMPLE) as u32; + + let mut h = vec![0u8; HEADER_LEN]; + h[0..4].copy_from_slice(&csi_data_size.to_le_bytes()); + h[8..12].copy_from_slice(&0xAABBCCDDu32.to_le_bytes()); // ftm_clock + h[12..20].copy_from_slice(×tamp_us.to_le_bytes()); + h[46] = num_rx; + h[47] = num_tx; + h[52..56].copy_from_slice(&num_subcarriers.to_le_bytes()); + h[60..64].copy_from_slice(&(-42i32 as u32).to_le_bytes()); // rssi1 + h[64..68].copy_from_slice(&(-45i32 as u32).to_le_bytes()); // rssi2 + h[68..74].copy_from_slice(&[0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF]); + let rate_n_flags = + (mod_type_val << RATE_MCS_MOD_TYPE_POS) | (chan_width_val << RATE_MCS_CHAN_WIDTH_POS); + h[92..96].copy_from_slice(&rate_n_flags.to_le_bytes()); + + for i in 0..samples { + let re = (i as i64 % 200 - 100) as i16; + let im = (i as i64 % 97 - 48) as i16; + h.extend_from_slice(&re.to_le_bytes()); + h.extend_from_slice(&im.to_le_bytes()); + } + h + } +} + +#[cfg(test)] +mod tests { + use super::synth::record_bytes as make_record_bytes; + use super::*; + + /// HE (802.11ax) records at 20/80/160 MHz shapes parse with correct + /// native dimensions, decoded bandwidth, and sample round-trip. + #[test] + fn test_parse_valid_he_shapes() { + // (chan_width_val, expected bandwidth, HE tone count) + let shapes = [ + (0u32, FeitCsiBandwidth::Bw20, 242u32), + (2u32, FeitCsiBandwidth::Bw80, 996u32), + (3u32, FeitCsiBandwidth::Bw160, 1992u32), + ]; + for (cw, expected_bw, sc) in shapes { + let bytes = make_record_bytes(2, 1, sc, cw, 4, 1_000_000); + let (rec, consumed) = parse_record(&bytes).expect("valid record must parse"); + assert_eq!(consumed, bytes.len()); + assert_eq!(rec.header.num_subcarriers, sc); + assert_eq!(rec.header.bandwidth, expected_bw); + assert_eq!(rec.header.mod_type, FeitCsiModType::He); + assert_eq!(rec.header.num_rx, 2); + assert_eq!(rec.header.num_tx, 1); + assert_eq!(rec.csi.len(), 2 * sc as usize); + // Deterministic sample round-trip: index 5 → re = 5-100... check + // the generator formula directly. + let i = 5usize; + assert_eq!(rec.csi[i].re, (i as i64 % 200 - 100) as f64); + assert_eq!(rec.csi[i].im, (i as i64 % 97 - 48) as f64); + // Antenna-pair accessor yields native-width slices. + assert_eq!(rec.antenna_pair(0, 0).unwrap().len(), sc as usize); + assert_eq!(rec.antenna_pair(1, 0).unwrap().len(), sc as usize); + assert!(rec.antenna_pair(2, 0).is_none()); + } + } + + /// A truncated buffer (header or payload cut short) is a structured + /// error, not a panic. + #[test] + fn test_truncated_buffer() { + let bytes = make_record_bytes(1, 1, 242, 0, 4, 0); + + // Header cut short. + let r = parse_record(&bytes[..100]); + assert!(matches!( + r, + Err(FeitCsiError::Truncated { needed, got: 100 }) if needed == HEADER_LEN + )); + + // Payload cut short. + let r = parse_record(&bytes[..bytes.len() - 1]); + assert!(matches!(r, Err(FeitCsiError::Truncated { .. }))); + + // Empty buffer. + assert!(matches!( + parse_record(&[]), + Err(FeitCsiError::Truncated { .. }) + )); + } + + /// csiDataSize that disagrees with the declared dimensions is rejected. + #[test] + fn test_dimension_mismatch() { + let mut bytes = make_record_bytes(1, 1, 242, 0, 4, 0); + // Corrupt the declared size (off by 4 bytes). + let bad = (242 * BYTES_PER_SAMPLE as u32) + 4; + bytes[0..4].copy_from_slice(&bad.to_le_bytes()); + let r = parse_record(&bytes); + assert!(matches!( + r, + Err(FeitCsiError::DimensionMismatch { + declared, + expected, + .. + }) if declared == bad && expected == 242 * BYTES_PER_SAMPLE + )); + } + + /// Unknown rate-flag encodings fail loudly (the format has no magic, so + /// this is the version/format check): 320 MHz width and out-of-range + /// modulation types are refused rather than misparsed. + #[test] + fn test_unsupported_format_fails_loudly() { + // Channel width value 4 = 320 MHz (EHT) — unsupported. + let bytes = make_record_bytes(1, 1, 242, 4, 5, 0); + assert!(matches!( + parse_record(&bytes), + Err(FeitCsiError::UnsupportedFormat { .. }) + )); + + // Modulation type 7 — outside the vendored rs.h encoding. + let bytes = make_record_bytes(1, 1, 242, 0, 7, 0); + assert!(matches!( + parse_record(&bytes), + Err(FeitCsiError::UnsupportedFormat { .. }) + )); + } + + /// Corrupt dimension fields beyond the hard caps are rejected BEFORE any + /// allocation is sized from them — a hostile length cannot cause + /// unbounded allocation. + #[test] + fn test_allocation_cap_enforcement() { + // Subcarrier count over the cap, with a consistent (huge) size field. + let mut h = vec![0u8; HEADER_LEN]; + let huge_sc: u32 = 100_000; + h[46] = 1; + h[47] = 1; + h[52..56].copy_from_slice(&huge_sc.to_le_bytes()); + h[0..4].copy_from_slice(&(huge_sc * 4).to_le_bytes()); + h[92..96].copy_from_slice(&(4u32 << RATE_MCS_MOD_TYPE_POS).to_le_bytes()); + let r = parse_record(&h); + assert!(matches!( + r, + Err(FeitCsiError::CapExceeded { + field: "num_subcarriers", + value, + cap, + }) if value == huge_sc as u64 && cap == MAX_SUBCARRIERS as u64 + )); + + // Antenna count over the cap. + let mut h = vec![0u8; HEADER_LEN]; + h[46] = 9; // num_rx > MAX_ANTENNAS + h[47] = 1; + h[52..56].copy_from_slice(&242u32.to_le_bytes()); + h[0..4].copy_from_slice(&(9 * 242 * 4u32).to_le_bytes()); + assert!(matches!( + parse_record(&h), + Err(FeitCsiError::CapExceeded { field: "num_rx", .. }) + )); + + // Zero dimension. + let mut h = vec![0u8; HEADER_LEN]; + h[46] = 0; + h[47] = 1; + h[52..56].copy_from_slice(&242u32.to_le_bytes()); + assert!(matches!( + parse_record(&h), + Err(FeitCsiError::ZeroDimension { field: "num_rx" }) + )); + } + + /// Streaming reader over an in-memory multi-record capture: reads all + /// records in order, then clean EOF. + #[test] + fn test_stream_reader_multi_record() { + let mut capture = Vec::new(); + for ts in [10u64, 20, 30] { + capture.extend_from_slice(&make_record_bytes(1, 1, 242, 0, 4, ts)); + } + let mut reader = FeitCsiStreamReader::new(std::io::Cursor::new(capture)); + let mut timestamps = Vec::new(); + while let Some(rec) = reader.read_next().expect("stream parse") { + timestamps.push(rec.header.timestamp_us); + } + assert_eq!(timestamps, vec![10, 20, 30]); + } + + /// A stream that ends mid-record reports Truncated, not clean EOF. + #[test] + fn test_stream_reader_mid_record_eof() { + let bytes = make_record_bytes(1, 1, 242, 0, 4, 0); + let cut = &bytes[..bytes.len() - 10]; + let mut reader = FeitCsiStreamReader::new(std::io::Cursor::new(cut.to_vec())); + assert!(matches!( + reader.read_next(), + Err(FeitCsiError::Truncated { .. }) + )); + } + + /// File replay is deterministic: two independent reads of the same + /// synthetic capture yield byte-identical record sequences and identical + /// converted readings (timestamps derive from the record, not wall-clock). + #[test] + fn test_replay_determinism() { + let mut capture = Vec::new(); + for ts in [1_000u64, 2_000, 3_000] { + capture.extend_from_slice(&make_record_bytes(2, 1, 996, 2, 4, ts)); + } + let path = std::env::temp_dir().join(format!( + "feitcsi_replay_test_{}.dat", + std::process::id() + )); + std::fs::write(&path, &capture).unwrap(); + + let read_all = || -> Vec { + let mut reader = FeitCsiFileReader::open(path.to_str().unwrap()).unwrap(); + let mut out = Vec::new(); + while let Some(rec) = reader.read_next().unwrap() { + out.push(rec); + } + out + }; + + let first = read_all(); + let second = read_all(); + assert_eq!(first.len(), 3); + assert_eq!(first, second, "replay must be deterministic"); + + // Converted readings are also identical, including timestamps. + let r1 = first[0].to_readings(WifiBand::Band6GHz, 37); + let r2 = second[0].to_readings(WifiBand::Band6GHz, 37); + assert_eq!(r1.timestamp, r2.timestamp); + assert_eq!(r1.readings[0].amplitudes, r2.readings[0].amplitudes); + + let _ = std::fs::remove_file(&path); + } + + /// Native → pipeline conversion goes through the explicit interpolation + /// path and records the mapping in frame metadata. + #[test] + fn test_native_to_pipeline_mapping_recorded() { + let bytes = make_record_bytes(1, 1, 1992, 3, 4, 500); + let (rec, _) = parse_record(&bytes).unwrap(); + let native = rec.to_readings(WifiBand::Band6GHz, 37); + + // Native metadata is first-class. + assert_eq!(native.metadata.num_subcarriers, 1992); + let wb = native.metadata.wideband.as_ref().expect("wideband meta"); + assert_eq!(wb.band, WifiBand::Band6GHz); + assert_eq!(wb.bandwidth_mhz, 160); + assert_eq!(wb.native_subcarriers, 1992); + assert!(wb.mapping.is_none(), "no mapping before conversion"); + + // Explicit conversion to pipeline width. + let converted = resample_readings_to_pipeline(&native, 56).unwrap(); + assert_eq!(converted.metadata.num_subcarriers, 56); + assert_eq!(converted.readings[0].amplitudes.len(), 56); + assert_eq!(converted.readings[0].phases.len(), 56); + let wb = converted.metadata.wideband.as_ref().unwrap(); + assert_eq!(wb.native_subcarriers, 1992, "true resolution preserved"); + let mapping = wb.mapping.as_ref().expect("mapping recorded"); + assert_eq!(mapping.native, 1992); + assert_eq!(mapping.pipeline, 56); + assert_eq!(mapping.method, "catmull-rom-cubic"); + + // Original frame is untouched. + assert_eq!(native.metadata.num_subcarriers, 1992); + } + + /// to_readings emits one reading per (rx, tx) pair at native width. + #[test] + fn test_to_readings_antenna_pairs() { + let bytes = make_record_bytes(2, 2, 242, 0, 4, 0); + let (rec, _) = parse_record(&bytes).unwrap(); + let readings = rec.to_readings(WifiBand::Band5GHz, 36); + assert_eq!(readings.readings.len(), 4); + for r in &readings.readings { + assert_eq!(r.amplitudes.len(), 242); + assert_eq!(r.phases.len(), 242); + } + assert_eq!( + readings.readings[0].tx_mac.as_deref(), + Some("AA:BB:CC:DD:EE:FF") + ); + assert!(matches!(readings.metadata.device_type, DeviceType::FeitCsi)); + assert_eq!(readings.metadata.bandwidth, Bandwidth::HT20); + } +} diff --git a/v2/crates/wifi-densepose-mat/src/integration/hardware_adapter.rs b/v2/crates/wifi-densepose-mat/src/integration/hardware_adapter.rs index f0c59268..fda589d7 100644 --- a/v2/crates/wifi-densepose-mat/src/integration/hardware_adapter.rs +++ b/v2/crates/wifi-densepose-mat/src/integration/hardware_adapter.rs @@ -129,6 +129,42 @@ impl HardwareConfig { } } + /// Create configuration for deterministic FeitCSI capture replay + /// (wideband 802.11ax records from Intel AX200/AX210, ADR-292). + pub fn feitcsi_replay(file_path: &str) -> Self { + Self::feitcsi(file_path, FeitCsiMode::FileReplay) + } + + /// Create configuration for streaming FeitCSI ingest from a path/pipe an + /// external FeitCSI process writes to (no NIC configuration in-crate). + pub fn feitcsi_stream(path: &str) -> Self { + Self::feitcsi(path, FeitCsiMode::Stream) + } + + fn feitcsi(path: &str, mode: FeitCsiMode) -> Self { + Self { + device_type: DeviceType::FeitCsi, + device_settings: DeviceSettings::FeitCsi(FeitCsiSettings { + path: path.to_string(), + mode, + band: WifiBand::Band5GHz, + channel: 36, + loop_playback: false, + pipeline_subcarriers: None, + }), + buffer_size: 8192, + raw_mode: false, + sample_rate_override: 0, + channel_config: ChannelConfig { + channel: 36, + bandwidth: Bandwidth::VHT160, + // Native width travels with each frame; this is only the + // configured expectation (802.11ax HE 160 MHz = 1992 tones). + num_subcarriers: 1992, + }, + } + } + /// Create configuration for UDP receiver (generic CSI) pub fn udp_receiver(bind_addr: &str, port: u16) -> Self { Self { @@ -160,6 +196,11 @@ pub enum DeviceType { UdpReceiver, /// PCAP file replay PcapFile, + /// FeitCSI wideband 802.11ax records from Intel AX200/AX210 (ADR-292): + /// file replay of a recorded capture, or a path/pipe an external FeitCSI + /// process writes to. RuView never configures the NIC — FeitCSI's own + /// tooling owns capture, per least-authority. + FeitCsi, /// Simulated device (for testing) Simulated, } @@ -186,10 +227,46 @@ pub enum DeviceSettings { Udp(UdpSettings), /// PCAP file settings Pcap(PcapSettings), + /// FeitCSI capture replay / stream settings + FeitCsi(FeitCsiSettings), /// Simulated device (no real hardware) Simulated, } +/// FeitCSI ingest mode (ADR-292). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FeitCsiMode { + /// Deterministic replay of a recorded capture file. + FileReplay, + /// Live stream read from a path/pipe an external FeitCSI process writes + /// to. RuView performs no NIC configuration; the external tool owns it. + Stream, +} + +/// FeitCSI source settings (ADR-292). +/// +/// The FeitCSI record header carries bandwidth (via the iwlwifi rate flags) +/// but not channel/band — the capture configuration owns those — so band and +/// channel are supplied here and stamped into frame metadata. +#[derive(Debug, Clone)] +pub struct FeitCsiSettings { + /// Path to the recorded capture (FileReplay) or the file/FIFO the + /// external FeitCSI process appends records to (Stream). + pub path: String, + /// Ingest mode. + pub mode: FeitCsiMode, + /// Radio band the capture was taken on (2.4/5/6 GHz). + pub band: WifiBand, + /// WiFi channel the capture was taken on. + pub channel: u8, + /// Restart from the beginning when file replay reaches the end. + pub loop_playback: bool, + /// When `Some(n)`, frames are explicitly converted from their native + /// subcarrier count to `n` via the interpolation path, and the mapping is + /// recorded in `CsiMetadata::wideband`. `None` keeps native width. + pub pipeline_subcarriers: Option, +} + /// Serial port configuration #[derive(Debug, Clone)] pub struct SerialSettings { @@ -264,6 +341,55 @@ impl Bandwidth { Bandwidth::VHT160 => 484, } } + + /// Channel bandwidth in MHz. + pub fn mhz(&self) -> u16 { + match self { + Bandwidth::HT20 => 20, + Bandwidth::HT40 => 40, + Bandwidth::VHT80 => 80, + Bandwidth::VHT160 => 160, + } + } +} + +/// WiFi radio band (first-class frame metadata per ADR-292). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WifiBand { + /// 2.4 GHz ISM band + Band2_4GHz, + /// 5 GHz band + Band5GHz, + /// 6 GHz band (802.11ax/Wi-Fi 6E and later) + Band6GHz, +} + +/// Record of an explicit native → pipeline subcarrier conversion, so +/// downstream consumers know the true spectral resolution of a frame and +/// how it was resampled (ADR-292 §3). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SubcarrierMapping { + /// Native subcarrier count as captured. + pub native: usize, + /// Pipeline subcarrier count after conversion. + pub pipeline: usize, + /// Interpolation/decimation method used (e.g. "catmull-rom-cubic"). + pub method: &'static str, +} + +/// Wideband spectral provenance metadata (ADR-292): band, bandwidth, native +/// subcarrier dimensionality, and any native → pipeline mapping applied. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WidebandMeta { + /// Radio band the frame was captured on. + pub band: WifiBand, + /// Channel bandwidth in MHz (20–160). + pub bandwidth_mhz: u16, + /// Native subcarrier count of the capture (true spectral resolution). + pub native_subcarriers: usize, + /// Native → pipeline conversion record; `None` while the frame is still + /// at native width. + pub mapping: Option, } /// Antenna configuration for MIMO @@ -376,6 +502,13 @@ enum DeviceSpecificState { driver: AtherosDriver, csi_buf_ptr: Option, }, + FeitCsi { + /// Byte offset into the capture for deterministic file replay. + replay_offset: u64, + /// Open handle for streaming mode (path/pipe written by an external + /// FeitCSI process); opened lazily on first read. + stream: Option, + }, Other, } @@ -457,6 +590,7 @@ impl HardwareAdapter { DeviceType::Atheros(driver) => self.initialize_atheros(*driver).await?, DeviceType::UdpReceiver => self.initialize_udp().await?, DeviceType::PcapFile => self.initialize_pcap().await?, + DeviceType::FeitCsi => self.initialize_feitcsi().await?, DeviceType::Simulated => self.initialize_simulated().await?, } @@ -662,6 +796,57 @@ impl HardwareAdapter { Ok(()) } + /// Initialize FeitCSI file-replay / stream ingest (ADR-292). + /// + /// No privileged operations: RuView does not configure the NIC; the + /// external FeitCSI tooling owns capture. This only validates the + /// configured path. + async fn initialize_feitcsi(&mut self) -> Result<(), AdapterError> { + let settings = match &self.config.device_settings { + DeviceSettings::FeitCsi(s) => s, + _ => { + return Err(AdapterError::Config( + "FeitCSI requires FeitCSI settings".into(), + )) + } + }; + + tracing::info!( + "Initializing FeitCSI ingest ({:?}) from {}", + settings.mode, + settings.path + ); + + match settings.mode { + FeitCsiMode::FileReplay => { + if !std::path::Path::new(&settings.path).exists() { + return Err(AdapterError::Hardware(format!( + "FeitCSI capture file not found: {}", + settings.path + ))); + } + } + FeitCsiMode::Stream => { + // The external process may create the pipe/file later; warn + // rather than fail so start order is not constrained. + if !std::path::Path::new(&settings.path).exists() { + tracing::warn!( + "FeitCSI stream path {} does not exist yet; will retry on read", + settings.path + ); + } + } + } + + let mut state = self.state.write().await; + state.device_state = DeviceSpecificState::FeitCsi { + replay_offset: 0, + stream: None, + }; + + Ok(()) + } + /// Initialize simulated device async fn initialize_simulated(&mut self) -> Result<(), AdapterError> { tracing::info!("Initializing simulated CSI device"); @@ -764,7 +949,7 @@ impl HardwareAdapter { /// Read a single CSI packet from the device async fn read_csi_packet( config: &HardwareConfig, - _state: &Arc>, + state: &Arc>, ) -> Result { match &config.device_type { DeviceType::Esp32 => Self::read_esp32_csi(config).await, @@ -772,10 +957,141 @@ impl HardwareAdapter { DeviceType::Atheros(driver) => Self::read_atheros_csi(config, *driver).await, DeviceType::UdpReceiver => Self::read_udp_csi(config).await, DeviceType::PcapFile => Self::read_pcap_csi(config).await, + DeviceType::FeitCsi => Self::read_feitcsi_csi(config, state).await, DeviceType::Simulated => Self::generate_simulated_csi(config).await, } } + /// Read one wideband CSI frame from a FeitCSI capture or stream (ADR-292). + /// + /// Frames carry their native subcarrier count, bandwidth (20–160 MHz) and + /// band (2.4/5/6 GHz) as metadata. When `pipeline_subcarriers` is + /// configured, conversion to pipeline width happens explicitly via the + /// interpolation path and the native → pipeline mapping is recorded in + /// `CsiMetadata::wideband`. + async fn read_feitcsi_csi( + config: &HardwareConfig, + state: &Arc>, + ) -> Result { + let settings = match &config.device_settings { + DeviceSettings::FeitCsi(s) => s, + _ => return Err(AdapterError::Config("Invalid settings for FeitCSI".into())), + }; + + let record = match settings.mode { + FeitCsiMode::FileReplay => Self::read_feitcsi_replay(settings, state).await?, + FeitCsiMode::Stream => Self::read_feitcsi_stream(settings, state).await?, + }; + + let readings = record.to_readings(settings.band, settings.channel); + match settings.pipeline_subcarriers { + Some(n) if n != readings.metadata.num_subcarriers => { + super::feitcsi::resample_readings_to_pipeline(&readings, n) + } + _ => Ok(readings), + } + } + + /// Deterministic file replay: reads the record at the current byte offset + /// and advances it, so the capture is walked once from start to end + /// (looping when configured). Same input file ⇒ same record sequence. + async fn read_feitcsi_replay( + settings: &FeitCsiSettings, + state: &Arc>, + ) -> Result { + let offset = { + let st = state.read().await; + match &st.device_state { + DeviceSpecificState::FeitCsi { replay_offset, .. } => *replay_offset, + _ => 0, + } + }; + + let path = settings.path.clone(); + let loop_playback = settings.loop_playback; + let (record, new_offset) = tokio::task::spawn_blocking( + move || -> Result<(super::feitcsi::FeitCsiRecord, u64), AdapterError> { + use std::io::{Seek, SeekFrom}; + let mut file = std::fs::File::open(&path).map_err(|e| { + AdapterError::Hardware(format!("Failed to open FeitCSI capture {path}: {e}")) + })?; + file.seek(SeekFrom::Start(offset)) + .map_err(AdapterError::Io)?; + match super::feitcsi::read_one_record(&mut file)? { + Some((rec, consumed)) => Ok((rec, offset + consumed as u64)), + None if loop_playback && offset != 0 => { + file.seek(SeekFrom::Start(0)).map_err(AdapterError::Io)?; + match super::feitcsi::read_one_record(&mut file)? { + Some((rec, consumed)) => Ok((rec, consumed as u64)), + None => Err(AdapterError::DataFormat(format!( + "FeitCSI capture {path} contains no records" + ))), + } + } + None => Err(AdapterError::HardwareUnavailable(format!( + "End of FeitCSI capture {path} (offset {offset})" + ))), + } + }, + ) + .await + .map_err(|e| AdapterError::Hardware(format!("FeitCSI replay task failed: {e}")))??; + + let mut st = state.write().await; + if let DeviceSpecificState::FeitCsi { replay_offset, .. } = &mut st.device_state { + *replay_offset = new_offset; + } + Ok(record) + } + + /// Streaming mode: hold the open handle across reads (a FIFO cannot be + /// reopened per record) and block until one full record arrives. The + /// blocking read runs on the blocking pool; if the surrounding stream + /// loop is shut down mid-read, the orphaned task finishes on its own and + /// the handle is reopened on the next read. + async fn read_feitcsi_stream( + settings: &FeitCsiSettings, + state: &Arc>, + ) -> Result { + let existing = { + let mut st = state.write().await; + match &mut st.device_state { + DeviceSpecificState::FeitCsi { stream, .. } => stream.take(), + _ => None, + } + }; + + let path = settings.path.clone(); + let result = tokio::task::spawn_blocking( + move || -> Result<(std::fs::File, super::feitcsi::FeitCsiRecord), AdapterError> { + let mut file = match existing { + Some(f) => f, + None => std::fs::File::open(&path).map_err(|e| { + AdapterError::HardwareUnavailable(format!( + "FeitCSI stream {path} unavailable: {e}" + )) + })?, + }; + match super::feitcsi::read_one_record(&mut file) { + Ok(Some((rec, _consumed))) => Ok((file, rec)), + Ok(None) => Err(AdapterError::HardwareUnavailable(format!( + "FeitCSI stream {path} closed (EOF)" + ))), + Err(e) => Err(e.into()), + } + }, + ) + .await + .map_err(|e| AdapterError::Hardware(format!("FeitCSI stream task failed: {e}")))?; + + let (file, record) = result?; + let mut st = state.write().await; + if let DeviceSpecificState::FeitCsi { stream, .. } = &mut st.device_state { + *stream = Some(file); + } + Ok(record) + } + /// Read CSI from ESP32 via serial. /// /// The ESP-CSI firmware emits newline-delimited `CSI_DATA,...` CSV records. @@ -1023,6 +1339,7 @@ impl HardwareAdapter { rssi: Some(-45.0), noise_floor: Some(-92.0), fc_type: FrameControlType::Data, + wideband: None, }, }) } @@ -1039,6 +1356,7 @@ impl HardwareAdapter { DeviceType::Intel5300 | DeviceType::Atheros(_) => self.discover_nic_sensors().await, DeviceType::UdpReceiver => Ok(vec![]), DeviceType::PcapFile => Ok(vec![]), + DeviceType::FeitCsi => Ok(vec![]), DeviceType::Simulated => self.discover_simulated_sensors().await, } } @@ -1165,6 +1483,7 @@ impl HardwareAdapter { rssi: None, noise_floor: None, fc_type: FrameControlType::Data, + wideband: None, }, }) } @@ -1320,6 +1639,10 @@ pub struct CsiMetadata { pub noise_floor: Option, /// Frame control type pub fc_type: FrameControlType, + /// Wideband spectral provenance (ADR-292): band, native subcarrier count + /// and any native → pipeline mapping applied. `None` for legacy + /// narrowband sources that predate wideband metadata. + pub wideband: Option, } /// WiFi frame control types @@ -1640,6 +1963,124 @@ mod tests { let _ = std::fs::remove_file(&path); } + #[test] + fn test_feitcsi_config() { + let config = HardwareConfig::feitcsi_replay("/tmp/capture.dat"); + assert!(matches!(config.device_type, DeviceType::FeitCsi)); + match &config.device_settings { + DeviceSettings::FeitCsi(s) => { + assert_eq!(s.mode, FeitCsiMode::FileReplay); + assert!(s.pipeline_subcarriers.is_none()); + } + other => panic!("unexpected settings: {other:?}"), + } + + let config = HardwareConfig::feitcsi_stream("/tmp/feitcsi.fifo"); + match &config.device_settings { + DeviceSettings::FeitCsi(s) => assert_eq!(s.mode, FeitCsiMode::Stream), + other => panic!("unexpected settings: {other:?}"), + } + } + + /// End-to-end FeitCSI file replay through the adapter read path: + /// initialize, then read the capture record-by-record. Two adapters over + /// the same synthetic capture see identical, order-preserving sequences + /// (replay determinism), and native+wideband metadata is carried. + #[tokio::test] + async fn test_feitcsi_replay_end_to_end_deterministic() { + use crate::integration::feitcsi::synth; + + // Synthetic 3-record HE 80 MHz capture, generated in code. + let mut capture = Vec::new(); + for ts in [100u64, 200, 300] { + capture.extend_from_slice(&synth::record_bytes(2, 1, 996, 2, 4, ts)); + } + let path = std::env::temp_dir().join(format!( + "feitcsi_adapter_test_{}.dat", + std::process::id() + )); + std::fs::write(&path, &capture).unwrap(); + + let run = || async { + let mut config = HardwareConfig::feitcsi_replay(path.to_str().unwrap()); + if let DeviceSettings::FeitCsi(s) = &mut config.device_settings { + s.band = WifiBand::Band6GHz; + s.channel = 37; + } + let mut adapter = HardwareAdapter::with_config(config.clone()); + adapter.initialize().await.unwrap(); + + let mut frames = Vec::new(); + for _ in 0..3 { + let readings = HardwareAdapter::read_csi_packet(&config, &adapter.state) + .await + .expect("replay read"); + frames.push(readings); + } + // Capture exhausted: typed error, not fabricated data. + let end = HardwareAdapter::read_csi_packet(&config, &adapter.state).await; + assert!(matches!(end, Err(AdapterError::HardwareUnavailable(_)))); + frames + }; + + let first = run().await; + let second = run().await; + + assert_eq!(first.len(), 3); + for (a, b) in first.iter().zip(&second) { + assert_eq!(a.timestamp, b.timestamp, "replay must be deterministic"); + assert_eq!(a.readings[0].amplitudes, b.readings[0].amplitudes); + } + + // Native wideband metadata is first-class on every frame. + let meta = &first[0].metadata; + assert!(matches!(meta.device_type, DeviceType::FeitCsi)); + assert_eq!(meta.num_subcarriers, 996); + assert_eq!(meta.bandwidth, Bandwidth::VHT80); + let wb = meta.wideband.as_ref().expect("wideband metadata"); + assert_eq!(wb.band, WifiBand::Band6GHz); + assert_eq!(wb.bandwidth_mhz, 80); + assert_eq!(wb.native_subcarriers, 996); + assert!(wb.mapping.is_none(), "native width: no mapping"); + // 2 rx * 1 tx = 2 antenna-pair readings per frame. + assert_eq!(first[0].readings.len(), 2); + + let _ = std::fs::remove_file(&path); + } + + /// FeitCSI replay with a configured pipeline width converts explicitly + /// through the interpolation path and records the mapping in metadata. + #[tokio::test] + async fn test_feitcsi_replay_pipeline_conversion() { + use crate::integration::feitcsi::synth; + + let capture = synth::record_bytes(1, 1, 1992, 3, 4, 42); + let path = std::env::temp_dir().join(format!( + "feitcsi_pipeline_test_{}.dat", + std::process::id() + )); + std::fs::write(&path, &capture).unwrap(); + + let mut config = HardwareConfig::feitcsi_replay(path.to_str().unwrap()); + if let DeviceSettings::FeitCsi(s) = &mut config.device_settings { + s.pipeline_subcarriers = Some(56); + } + let mut adapter = HardwareAdapter::with_config(config.clone()); + adapter.initialize().await.unwrap(); + + let readings = HardwareAdapter::read_csi_packet(&config, &adapter.state) + .await + .expect("replay read"); + assert_eq!(readings.metadata.num_subcarriers, 56); + assert_eq!(readings.readings[0].amplitudes.len(), 56); + let wb = readings.metadata.wideband.as_ref().unwrap(); + assert_eq!(wb.native_subcarriers, 1992, "true resolution preserved"); + let mapping = wb.mapping.as_ref().expect("mapping recorded"); + assert_eq!((mapping.native, mapping.pipeline), (1992, 56)); + + let _ = std::fs::remove_file(&path); + } + /// Honest hardware gating: Intel 5300 / Atheros return typed /// HardwareUnavailable (no device/driver), never fabricated CSI. #[tokio::test] diff --git a/v2/crates/wifi-densepose-mat/src/integration/mod.rs b/v2/crates/wifi-densepose-mat/src/integration/mod.rs index 5c8c3dee..124b6cc4 100644 --- a/v2/crates/wifi-densepose-mat/src/integration/mod.rs +++ b/v2/crates/wifi-densepose-mat/src/integration/mod.rs @@ -13,6 +13,9 @@ //! - **Intel 5300 NIC**: Using Linux CSI Tool (iwlwifi driver) //! - **Atheros NICs**: Using ath9k/ath10k/ath11k CSI patches //! - **Nexmon**: For Broadcom chips with CSI firmware +//! - **FeitCSI (Intel AX200/AX210)**: Wideband 802.11ax CSI up to 160 MHz / +//! 1992 subcarriers including 6 GHz, ingested from recorded captures or a +//! stream written by the external FeitCSI tool (ADR-292) //! //! # Example Usage //! @@ -37,6 +40,7 @@ //! ``` pub mod csi_receiver; +pub mod feitcsi; mod hardware_adapter; mod neural_adapter; mod signal_adapter; @@ -52,6 +56,9 @@ pub use hardware_adapter::{ CsiStream, DeviceSettings, DeviceType, + // FeitCSI wideband ingest settings (ADR-292) + FeitCsiMode, + FeitCsiSettings, FlowControl, FrameControlType, // Main adapter @@ -73,8 +80,18 @@ pub use hardware_adapter::{ // Serial settings SerialSettings, StreamingStats, + // Wideband spectral provenance (ADR-292) + SubcarrierMapping, // UDP settings UdpSettings, + WidebandMeta, + WifiBand, +}; + +pub use feitcsi::{ + parse_record as parse_feitcsi_record, resample_readings_to_pipeline, FeitCsiBandwidth, + FeitCsiError, FeitCsiFileReader, FeitCsiHeader, FeitCsiModType, FeitCsiRecord, + FeitCsiStreamReader, }; pub use neural_adapter::NeuralAdapter; pub use signal_adapter::SignalAdapter; diff --git a/v2/crates/wifi-densepose-sensing-server/SECURITY.md b/v2/crates/wifi-densepose-sensing-server/SECURITY.md new file mode 100644 index 00000000..f5c42775 --- /dev/null +++ b/v2/crates/wifi-densepose-sensing-server/SECURITY.md @@ -0,0 +1,61 @@ +# Security notes — wifi-densepose-sensing-server + +## UDP CSI data plane (ADR-296) + +The sensing server ingests CSI/radar frames over UDP from ESP32, MediaTek, +Qualcomm, and RTL8720F sensor nodes. A valid-shaped frame flips an +auto-detecting server into a live source state and influences +presence/vital/automation outputs. + +### Threat model + +Any host that can reach the UDP port can inject a valid-shaped frame. Prior to +ADR-296 the receiver bound `0.0.0.0` unconditionally, so on a routable +deployment the data plane was open to the entire LAN. + +The controls in ADR-296 (step one) are: + +- **`--udp-bind` (env `RUVIEW_UDP_BIND`), default `127.0.0.1`.** The receiver is + loopback-only by default and not reachable off-host. Binding to a routable + address (`0.0.0.0` or a LAN IP) is now an explicit operator choice, mirroring + the HTTP `--bind-addr` path. +- **`--udp-allow ` (env `RUVIEW_UDP_ALLOW`).** An optional source + allowlist. When set, frames from non-matching sources are dropped and counted; + loopback is always allowed. +- **`--udp-insecure-lan` (env `RUVIEW_UDP_INSECURE_LAN`).** A routable bind with + no allowlist is *refused at boot* unless this override is passed. The name + makes the residual risk legible. + +A startup security log line states the resolved bind scope and whether an +allowlist is active. + +### Residual risk — the allowlist is not authentication + +An IP/CIDR allowlist restricts *which addresses* may deliver frames. It does +**not** authenticate the sender. On a trusted LAN an attacker who can spoof a +source IP, or who controls an allowlisted host, can still inject frames. Treat a +routable bind as a soft control, not a security boundary. + +### Deferred to a follow-up ADR (step two) + +The following are **not** implemented yet and the data plane must not be +presented as authenticated: + +- per-device provisioned keys +- message authentication / AEAD (MAC over each frame) +- device identifiers +- monotonic sequence numbers +- a freshness window +- replay rejection + +Real-silicon validation of the LAN path remains required before any deployment +claim. + +### Safe deployment + +- Prefer the loopback default. Co-locate sensor decoding on the same host, or + place a trusted gateway in front. +- If you must bind routable, always pass `--udp-allow` scoped to the sensor + subnet, and segregate sensors on their own VLAN. +- Do not rely on the allowlist alone against an on-LAN adversary until step two + ships. diff --git a/v2/crates/wifi-densepose-sensing-server/benches/mqtt_throughput.rs b/v2/crates/wifi-densepose-sensing-server/benches/mqtt_throughput.rs index 7ad87de6..da2df633 100644 --- a/v2/crates/wifi-densepose-sensing-server/benches/mqtt_throughput.rs +++ b/v2/crates/wifi-densepose-sensing-server/benches/mqtt_throughput.rs @@ -136,6 +136,7 @@ fn bench_rate_limit(c: &mut Criterion) { RateLimiter::new, |mut rl| { black_box(rl.allow( + black_box("bench-node"), black_box(EntityKind::HeartRate), Duration::from_secs(0), &r, @@ -148,11 +149,12 @@ fn bench_rate_limit(c: &mut Criterion) { bench.iter_batched( || { let mut rl = RateLimiter::new(); - rl.allow(EntityKind::HeartRate, Duration::from_secs(0), &r); + rl.allow("bench-node", EntityKind::HeartRate, Duration::from_secs(0), &r); rl }, |mut rl| { black_box(rl.allow( + black_box("bench-node"), black_box(EntityKind::HeartRate), Duration::from_secs(1), &r, diff --git a/v2/crates/wifi-densepose-sensing-server/src/inference.rs b/v2/crates/wifi-densepose-sensing-server/src/inference.rs new file mode 100644 index 00000000..9d514821 --- /dev/null +++ b/v2/crates/wifi-densepose-sensing-server/src/inference.rs @@ -0,0 +1,300 @@ +//! ADR-297 — per-node inference vs. fused room inference. +//! +//! The multi-node path used to collapse two distinct concepts into one: +//! +//! 1. A **per-node** classification (what one node sees), and +//! 2. A **room aggregate** (the fused belief across all nodes). +//! +//! Because the top-level room classification was taken from the latest-arriving +//! node while other features were fused, disagreeing nodes made room presence +//! flip at packet frequency (issue #1555); and because the MQTT mapper fell back +//! to the room aggregate when a node lacked its own classification, every node +//! could publish the same value (issues #1540, #1554). +//! +//! This module separates the two types and provides a **pure, deterministic** +//! room fusion ([`fuse_room`]): identical input sets yield identical output, +//! independent of node ordering, and a node that has gone silent longer than +//! the stale window contributes nothing (it goes unavailable/stale rather than +//! holding a frozen value). Freshness is carried as milliseconds so the types +//! serialize cleanly and the logic needs no clock. + +use serde::{Deserialize, Serialize}; + +/// One node's own inference — never the room aggregate. `NodeInfo` carries this +/// so a node reports what *it* sees, with no silent fallback to the room value. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct NodeInference { + /// Coarse classification label this node reports (e.g. `"present_moving"`, + /// `"present_still"`, `"absent"`). Node-local; never overwritten by fusion. + pub classification: String, + /// Confidence in `classification`, clamped to `0.0..=1.0`. + pub confidence: f64, + /// Age of the backing frame in milliseconds. `None` when the node has never + /// reported. + #[serde(skip_serializing_if = "Option::is_none")] + pub age_ms: Option, +} + +impl NodeInference { + /// Construct a per-node inference, clamping `confidence` into range. + pub fn new(classification: impl Into, confidence: f64, age_ms: Option) -> Self { + Self { + classification: classification.into(), + confidence: clamp01(confidence), + age_ms, + } + } + + /// A node is stale when it has never reported, or its last frame is at least + /// `stale_after_ms` old. Stale nodes do not vote in [`fuse_room`]. + pub fn is_stale(&self, stale_after_ms: u64) -> bool { + match self.age_ms { + None => true, + Some(a) => a >= stale_after_ms, + } + } + + /// Freshness weight in `[0.0, 1.0]`: `1.0` when brand new, decaying linearly + /// to `0.0` at `stale_after_ms`, and exactly `0.0` once stale. Deterministic + /// and clock-free. + pub fn freshness_weight(&self, stale_after_ms: u64) -> f64 { + if stale_after_ms == 0 { + return 0.0; + } + match self.age_ms { + None => 0.0, + Some(a) if a >= stale_after_ms => 0.0, + Some(a) => { + let remaining = stale_after_ms.saturating_sub(a) as f64; + clamp01(remaining / stale_after_ms as f64) + } + } + } +} + +/// The fused room aggregate — computed explicitly from the set of per-node +/// inferences, never by overwriting any node's state. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RoomInference { + /// Winning classification across the contributing nodes. `"unavailable"` + /// when no fresh node contributed (never a frozen last value). + pub classification: String, + /// Freshness-weighted mean confidence of the nodes that voted for the + /// winning class, `0.0..=1.0`. + pub confidence: f64, + /// How many non-stale nodes contributed to this aggregate. + pub contributing_nodes: usize, +} + +impl RoomInference { + /// The state a room takes when no node currently backs it. Explicit so + /// callers never invent an online value out of nothing. + pub fn unavailable() -> Self { + Self { + classification: "unavailable".to_string(), + confidence: 0.0, + contributing_nodes: 0, + } + } + + /// Migration accessor for consumers of the pre-ADR-297 aggregate-only shape: + /// with a single node, that node's inference *is* the room aggregate. This + /// preserves single-node behavior (one node = one inference) exactly. + pub fn from_single_node(node: &NodeInference, stale_after_ms: u64) -> Self { + fuse_room(std::iter::once(node), stale_after_ms) + } +} + +/// Fuse per-node inferences into one room aggregate. +/// +/// Pure and deterministic (ADR-297): the result depends only on the *set* of +/// inputs, not on their order or on which arrived last. Each non-stale node +/// votes for its classification with its freshness weight; the class with the +/// greatest total freshness weight wins, ties broken by classification string +/// order (via the sorted `BTreeMap`) so the outcome is stable. Room confidence +/// is the freshness-weighted mean confidence of the winning class's voters. +/// +/// A node silent longer than `stale_after_ms` contributes nothing; if no node +/// contributes, the room is [`RoomInference::unavailable`] rather than a frozen +/// online value. +pub fn fuse_room<'a, I>(nodes: I, stale_after_ms: u64) -> RoomInference +where + I: IntoIterator, +{ + use std::collections::BTreeMap; + + // Per class: (sum of freshness weights, sum of freshness*confidence). + let mut tally: BTreeMap<&str, (f64, f64)> = BTreeMap::new(); + let mut contributing = 0usize; + + for n in nodes { + let w = n.freshness_weight(stale_after_ms); + if w <= 0.0 { + continue; + } + contributing += 1; + let entry = tally.entry(n.classification.as_str()).or_insert((0.0, 0.0)); + entry.0 += w; + entry.1 += w * n.confidence; + } + + if contributing == 0 { + return RoomInference::unavailable(); + } + + // BTreeMap iterates in sorted key order; `>` keeps the first (lowest-order) + // class on a tie, so the winner is order-independent and deterministic. + let mut best: Option<(&str, f64, f64)> = None; + for (&class, &(weight, conf_weight)) in &tally { + match best { + Some((_, bw, _)) if weight <= bw => {} + _ => best = Some((class, weight, conf_weight)), + } + } + + let (class, weight, conf_weight) = best.expect("contributing > 0 guarantees a winner"); + let confidence = if weight > 0.0 { clamp01(conf_weight / weight) } else { 0.0 }; + RoomInference { + classification: class.to_string(), + confidence, + contributing_nodes: contributing, + } +} + +fn clamp01(v: f64) -> f64 { + if v.is_nan() { + 0.0 + } else { + v.clamp(0.0, 1.0) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const STALE: u64 = 10_000; // 10 s + + fn node(c: &str, conf: f64, age_ms: u64) -> NodeInference { + NodeInference::new(c, conf, Some(age_ms)) + } + + // ── NodeInference basics ───────────────────────────────────────────── + + #[test] + fn new_clamps_confidence() { + assert_eq!(NodeInference::new("absent", 1.7, Some(0)).confidence, 1.0); + assert_eq!(NodeInference::new("absent", -0.5, Some(0)).confidence, 0.0); + assert_eq!(NodeInference::new("absent", f64::NAN, Some(0)).confidence, 0.0); + } + + #[test] + fn never_reported_node_is_stale() { + let n = NodeInference::new("present_moving", 0.9, None); + assert!(n.is_stale(STALE)); + assert_eq!(n.freshness_weight(STALE), 0.0); + } + + #[test] + fn fresh_node_is_not_stale_and_weighted() { + let n = node("present_moving", 0.9, 0); + assert!(!n.is_stale(STALE)); + assert_eq!(n.freshness_weight(STALE), 1.0); + // Half the window ⇒ half weight. + assert!((node("x", 1.0, STALE / 2).freshness_weight(STALE) - 0.5).abs() < 1e-9); + } + + #[test] + fn node_at_window_boundary_is_stale() { + let n = node("present_still", 0.8, STALE); + assert!(n.is_stale(STALE)); + assert_eq!(n.freshness_weight(STALE), 0.0); + } + + // ── Single-node preservation + migration accessor ──────────────────── + + #[test] + fn single_node_room_equals_that_node() { + let n = node("present_moving", 0.8, 100); + let room = fuse_room(std::iter::once(&n), STALE); + assert_eq!(room.classification, "present_moving"); + assert!((room.confidence - 0.8).abs() < 1e-9, "confidence preserved"); + assert_eq!(room.contributing_nodes, 1); + } + + #[test] + fn migration_accessor_matches_single_node_fusion() { + let n = node("present_still", 0.6, 250); + assert_eq!(RoomInference::from_single_node(&n, STALE), fuse_room([&n], STALE)); + } + + // ── Deterministic, order-independent fusion ────────────────────────── + + #[test] + fn agreeing_nodes_fuse_to_shared_class() { + let a = node("present_moving", 0.7, 0); + let b = node("present_moving", 0.9, 0); + let room = fuse_room([&a, &b], STALE); + assert_eq!(room.classification, "present_moving"); + assert_eq!(room.contributing_nodes, 2); + // Freshness-weighted mean of 0.7 and 0.9 at equal weight = 0.8. + assert!((room.confidence - 0.8).abs() < 1e-9); + } + + #[test] + fn disagreeing_equal_freshness_is_deterministic_and_order_independent() { + let a = node("present_moving", 0.9, 0); + let b = node("absent", 0.9, 0); + let one = fuse_room([&a, &b], STALE); + let two = fuse_room([&b, &a], STALE); + // Same result regardless of input order — no last-writer-wins. + assert_eq!(one, two); + // Tie broken by classification string order: "absent" < "present_moving". + assert_eq!(one.classification, "absent"); + } + + #[test] + fn fresher_node_outvotes_stale_disagreement() { + // Fresh "present_moving" vs. an older-but-still-fresh "absent". + let fresh = node("present_moving", 0.9, 0); + let older = node("absent", 0.9, STALE - 1); // weight ~0 + let room = fuse_room([&older, &fresh], STALE); + assert_eq!(room.classification, "present_moving"); + } + + // ── Stale handling: unavailable, never frozen-online ───────────────── + + #[test] + fn all_stale_nodes_yield_unavailable() { + let a = node("present_moving", 0.9, STALE + 1); + let b = NodeInference::new("present_still", 0.8, None); + let room = fuse_room([&a, &b], STALE); + assert_eq!(room, RoomInference::unavailable()); + assert_eq!(room.classification, "unavailable"); + assert_eq!(room.contributing_nodes, 0); + assert_eq!(room.confidence, 0.0); + } + + #[test] + fn stale_node_excluded_but_fresh_node_still_counts() { + let stale_node = node("absent", 0.9, STALE + 5); + let fresh_node = node("present_moving", 0.7, 10); + let room = fuse_room([&stale_node, &fresh_node], STALE); + assert_eq!(room.classification, "present_moving"); + assert_eq!(room.contributing_nodes, 1, "stale node does not vote"); + } + + #[test] + fn empty_input_is_unavailable() { + let room = fuse_room(std::iter::empty(), STALE); + assert_eq!(room, RoomInference::unavailable()); + } + + #[test] + fn node_inference_round_trips_json() { + let n = node("present_moving", 0.75, 120); + let json = serde_json::to_string(&n).unwrap(); + let back: NodeInference = serde_json::from_str(&json).unwrap(); + assert_eq!(n, back); + } +} diff --git a/v2/crates/wifi-densepose-sensing-server/src/lib.rs b/v2/crates/wifi-densepose-sensing-server/src/lib.rs index 2bcb849d..a2868464 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/lib.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/lib.rs @@ -16,11 +16,16 @@ pub mod dataset; pub mod edge_registry; pub mod error_response; pub mod host_validation; +/// ADR-297: per-node vs. fused room inference, with deterministic fusion. +pub mod inference; pub mod introspection; pub mod matter; pub mod model_format; pub mod mqtt; pub mod path_safety; +/// ADR-295: canonical source-provenance state machine (synthetic can never +/// present as live). +pub mod provenance; pub mod semantic; /// ADR-262 P3: the live RuField surface — turns the governed sensing cycle into /// signed RuField `FieldEvent`s on the additive `/api/field` + `/ws/field` @@ -32,6 +37,8 @@ pub mod semconv; pub mod telemetry; #[allow(dead_code)] pub mod trainer; +/// ADR-296: UDP data-plane bind scope decision + source IP/CIDR allowlist. +pub mod udp_bind; pub mod vital_signs; /// ADR-270 Mist and NETGEAR telemetry providers. pub mod vendor_mist_netgear; diff --git a/v2/crates/wifi-densepose-sensing-server/src/main.rs b/v2/crates/wifi-densepose-sensing-server/src/main.rs index c972bb1f..5893df17 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/main.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/main.rs @@ -38,6 +38,9 @@ use wifi_densepose_sensing_server::{ dataset, embedding, error_response, graph_transformer, rufield_surface, semconv, telemetry, trainer, }; +// ADR-295 / ADR-297: canonical provenance state + per-node/room inference. +use wifi_densepose_sensing_server::inference::{fuse_room, NodeInference, RoomInference}; +use wifi_densepose_sensing_server::provenance::SourceState; use ruvector_mincut::{DynamicMinCut, MinCutBuilder}; use std::collections::{BTreeMap, HashMap, VecDeque}; @@ -97,6 +100,26 @@ struct Args { #[arg(long, default_value = "5005")] udp_port: u16, + /// UDP bind address for the CSI receiver (ADR-296). Defaults to + /// `127.0.0.1` (loopback only). Binding to a routable address (`0.0.0.0` + /// or a LAN IP) is an explicit operator choice and requires `--udp-allow` + /// or `--udp-insecure-lan`. + #[arg(long, default_value = "127.0.0.1", env = "RUVIEW_UDP_BIND")] + udp_bind: String, + + /// Source IP/CIDR allowlist for inbound UDP CSI frames (comma-separated, + /// repeatable; env `RUVIEW_UDP_ALLOW`). When set, frames from non-matching + /// sources are dropped and counted. Loopback is always allowed. + /// Example: `--udp-allow 192.168.1.0/24,10.0.0.5`. + #[arg(long = "udp-allow", value_name = "IP/CIDR", env = "RUVIEW_UDP_ALLOW")] + udp_allow: Vec, + + /// Accept a routable UDP bind with no source allowlist, explicitly opting + /// into the LAN-spoofing risk (ADR-296). The UDP data plane is NOT + /// authenticated; see the crate SECURITY.md. + #[arg(long, env = "RUVIEW_UDP_INSECURE_LAN")] + udp_insecure_lan: bool, + /// Path to UI static files (repo `ui/`; from `v2/` use `../ui` or rely on auto-detect) #[arg(long, default_value = "../ui")] ui_path: PathBuf, @@ -325,6 +348,12 @@ struct SensingUpdate { /// Per-node feature breakdown for multi-node deployments. #[serde(skip_serializing_if = "Option::is_none")] node_features: Option>, + /// ADR-297 — the explicitly-fused room aggregate over the current per-node + /// inferences (freshness-weighted vote). Deterministic and order-independent, + /// unlike the legacy last-writer `classification`; `"unavailable"` when no + /// fresh node backs the room rather than a frozen online value. + #[serde(skip_serializing_if = "Option::is_none")] + room_inference: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -340,6 +369,12 @@ struct NodeInfo { /// `NodeState::latest_sync` and the iter 18 fps EMA. #[serde(skip_serializing_if = "Option::is_none")] sync: Option, + /// ADR-297 — this node's *own* inference (classification + confidence + + /// freshness). Distinct from the room aggregate; a node reports what it + /// sees, with no silent fallback to the room value. `None` on synthetic / + /// placeholder frames that carry no per-node classification. + #[serde(skip_serializing_if = "Option::is_none")] + node_inference: Option, } /// ADR-110 iter 23 — per-node mesh-sync snapshot embedded in NodeInfo. @@ -416,6 +451,26 @@ fn classify_vitals(motion: bool, presence: bool, presence_score: f32) -> Classif } } +/// ADR-297 — the window a node may be silent before it stops contributing to +/// the fused room aggregate (its entities go stale/unavailable rather than +/// holding a frozen online value). Mirrors the 10 s active-node filter used to +/// assemble the nodes array. +const NODE_STALE_AFTER_MS: u64 = 10_000; + +/// Build a node's *own* [`NodeInference`] from its smoothed per-node state +/// (ADR-297). Uses the node's own `current_motion_level` — never the room +/// aggregate — with a confidence from its smoothed person score and freshness +/// from its last frame time. Pure given the state snapshot + `now`. +fn node_inference_for(n: &NodeState, now: std::time::Instant) -> NodeInference { + let age_ms = n + .last_frame_time + .map(|t| now.duration_since(t).as_millis() as u64); + let present = !matches!(n.current_motion_level.as_str(), "absent"); + let score = n.smoothed_person_score.clamp(0.0, 1.0); + let confidence = if present { score } else { 1.0 - score }; + NodeInference::new(n.current_motion_level.clone(), confidence, age_ms) +} + #[cfg(test)] mod classify_vitals_tests { use super::classify_vitals; @@ -1310,6 +1365,21 @@ impl AppStateInner { } self.source.clone() } + + /// ADR-295 — canonical provenance state for the current source. Derived + /// from the freshness-gated [`effective_source`](Self::effective_source) + /// label so ambiguity can never collapse to "live": a synthetic source is + /// always `Synthetic`, an `":offline"` label is `Disconnected`, and a fresh + /// hardware feed is `LiveUnverified` — never `LiveVerified`, since this path + /// carries no attestation. `effective_source()` has already applied the + /// freshness gate, so a non-offline live label means a fresh frame. + fn source_state(&self) -> SourceState { + SourceState::from_source_label( + &self.effective_source(), + Some(Duration::ZERO), + ESP32_OFFLINE_TIMEOUT, + ) + } } /// Number of frames retained in `frame_history` for temporal analysis. @@ -2801,6 +2871,7 @@ async fn windows_wifi_task(state: SharedState, tick_ms: u64) { amplitude: multi_ap_frame.amplitudes, subcarrier_count: obs_count, sync: None, // multi-BSSID scan path — no mesh peer + node_inference: None, // single aggregate frame; no per-node split }], features, classification, @@ -2827,6 +2898,7 @@ async fn windows_wifi_task(state: SharedState, tick_ms: u64) { None }, node_features: None, + room_inference: None, }; // Populate persons from the sensing update (Kalman-smoothed via tracker). @@ -2961,6 +3033,7 @@ async fn windows_wifi_fallback_tick(state: &SharedState, seq: u32) { amplitude: vec![signal_pct], subcarrier_count: 1, sync: None, // synthetic-RSSI fallback path — no mesh peer + node_inference: None, // synthetic fallback; no per-node inference }], features, classification, @@ -2987,6 +3060,7 @@ async fn windows_wifi_fallback_tick(state: &SharedState, seq: u32) { None }, node_features: None, + room_inference: None, }; let raw_persons = derive_pose_from_sensing(&update); @@ -4599,6 +4673,9 @@ async fn health_ready(State(state): State) -> Json Html { // ── UDP receiver task ──────────────────────────────────────────────────────── -async fn udp_receiver_task(state: SharedState, udp_port: u16) { - let addr = format!("0.0.0.0:{udp_port}"); +async fn udp_receiver_task( + state: SharedState, + bind_ip: std::net::IpAddr, + udp_port: u16, + allowlist: std::sync::Arc, +) { + let addr = format!("{bind_ip}:{udp_port}"); let socket = match UdpSocket::bind(&addr).await { Ok(s) => { info!("UDP listening on {addr} for ESP32, MediaTek, Qualcomm CSI, and RTL8720F radar frames"); @@ -5719,6 +5801,15 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) { loop { match socket.recv_from(&mut buf).await { Ok((len, src)) => { + // ADR-296: drop frames from sources outside the allowlist + // (loopback is always admitted). Counted for observability. + if !allowlist.admit(src.ip()) { + debug!( + "Dropped UDP frame from disallowed source {src} (allowlist active; total dropped={})", + allowlist.dropped() + ); + continue; + } if len > 0 && buf[0] == b'{' { match serde_json::from_slice::(&buf[..len]) .map_err(|error| error.to_string()) @@ -5950,9 +6041,19 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) { // Vitals-only path; still expose the sync snapshot // if the node also speaks ESP-NOW. sync: n.sync_snapshot(), + // ADR-297 — each node carries its own inference. + node_inference: Some(node_inference_for(n, now)), }) .collect(); + // ADR-297 — explicit, deterministic room aggregate over the + // per-node inferences (freshness-weighted vote). Not the + // latest-writer classification (issue #1555). + let room_inference = fuse_room( + active_nodes.iter().filter_map(|ni| ni.node_inference.as_ref()), + NODE_STALE_AFTER_MS, + ); + let features = FeatureInfo { mean_rssi: vitals.rssi as f64, variance: vitals.motion_energy as f64, @@ -6041,6 +6142,7 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) { // can implement model-wake gating without round- // tripping back to the server. node_features: build_node_features(&s.node_states, now), + room_inference: Some(room_inference), }; let raw_persons = derive_pose_from_sensing(&update); @@ -6439,9 +6541,18 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) { }, // ADR-110 iter 23 / iter 30 — single source of truth. sync: n.sync_snapshot(), + // ADR-297 — each node carries its own inference. + node_inference: Some(node_inference_for(n, now)), }) .collect(); + // ADR-297 — explicit deterministic room aggregate over the + // per-node inferences (not last-writer; issue #1555). + let room_inference = fuse_room( + active_nodes.iter().filter_map(|ni| ni.node_inference.as_ref()), + NODE_STALE_AFTER_MS, + ); + let mut update = SensingUpdate { msg_type: "sensing_update".to_string(), timestamp: chrono::Utc::now().timestamp_millis() as f64 / 1000.0, @@ -6478,6 +6589,7 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) { // can implement model-wake gating without round- // tripping back to the server. node_features: build_node_features(&s.node_states, now), + room_inference: Some(room_inference), }; let raw_persons = derive_pose_from_sensing(&update); @@ -6699,6 +6811,7 @@ async fn simulated_data_task(state: SharedState, tick_ms: u64) { amplitude: frame_amplitudes, subcarrier_count: frame_n_sub as usize, sync: None, // simulated frame path — no mesh peer + node_inference: None, // simulated frame; source is synthetic }], features: features.clone(), classification, @@ -6735,6 +6848,7 @@ async fn simulated_data_task(state: SharedState, tick_ms: u64) { None }, node_features: None, + room_inference: None, }; // Populate persons from the sensing update (Kalman-smoothed via tracker). @@ -7720,7 +7834,7 @@ async fn main() { info!("WiFi-DensePose Sensing Server (Rust + Axum + RuVector)"); info!(" HTTP: http://localhost:{}", args.http_port); info!(" WebSocket: ws://localhost:{}/ws/sensing", args.ws_port); - info!(" UDP: 0.0.0.0:{} (ESP32 CSI)", args.udp_port); + info!(" UDP: {}:{} (ESP32 CSI)", args.udp_bind, args.udp_port); info!(" UI path: {}", args.ui_path.display()); info!(" Source: {}", args.source); @@ -8098,7 +8212,48 @@ async fn main() { // promoted — see `simulated_data_task`). Explicit `--source simulated` has // `bind_udp = false`, so it serves simulated data only, with no live binding. if plan.bind_udp { - tokio::spawn(udp_receiver_task(state.clone(), args.udp_port)); + // ADR-296: resolve the UDP bind scope + source allowlist and fail closed + // on an unguarded routable bind, mirroring the OAuth boot refusal below. + use wifi_densepose_sensing_server::udp_bind; + let udp_bind_ip: std::net::IpAddr = match args.udp_bind.parse() { + Ok(ip) => ip, + Err(_) => { + error!( + "Invalid --udp-bind '{}' (use 127.0.0.1 or 0.0.0.0)", + args.udp_bind + ); + std::process::exit(1); + } + }; + let udp_allowlist = match udp_bind::UdpSourceAllowlist::parse(args.udp_allow.iter()) { + Ok(a) => std::sync::Arc::new(a), + Err(e) => { + error!("Invalid --udp-allow: {e}"); + std::process::exit(1); + } + }; + match udp_bind::decide_udp_bind( + udp_bind_ip, + udp_allowlist.is_active(), + args.udp_insecure_lan, + ) { + Ok(decision) => { + info!( + "UDP data plane security: {}", + udp_bind::startup_summary(decision, udp_bind_ip, args.udp_port, &udp_allowlist) + ); + } + Err(e) => { + error!("{e}"); + std::process::exit(1); + } + } + tokio::spawn(udp_receiver_task( + state.clone(), + udp_bind_ip, + args.udp_port, + udp_allowlist, + )); tokio::spawn(broadcast_tick_task(state.clone(), args.tick_ms)); } if plan.run_wifi { @@ -8526,6 +8681,7 @@ mod node_sync_snapshot_serialization_tests { amplitude: vec![], subcarrier_count: 0, sync, + node_inference: None, } } @@ -9247,6 +9403,7 @@ mod observatory_persons_field_position_tests { persons: None, estimated_persons: Some(1), node_features: None, + room_inference: None, } } diff --git a/v2/crates/wifi-densepose-sensing-server/src/mqtt/publisher.rs b/v2/crates/wifi-densepose-sensing-server/src/mqtt/publisher.rs index 6c3e3285..769cc0e4 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/mqtt/publisher.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/mqtt/publisher.rs @@ -332,15 +332,17 @@ async fn publish_snapshot( } } - // Numeric rate-limited entities. + // Numeric rate-limited entities. Rate limiting is per (node, entity) + // (ADR-297, issue #1541) so nodes never starve one another. + let node = snap.node_id.as_str(); for (entity, allowed) in [ - (EntityKind::PersonCount, rl.allow(EntityKind::PersonCount, elapsed, &cfg.rates)), - (EntityKind::HeartRate, !cfg.privacy_mode && rl.allow(EntityKind::HeartRate, elapsed, &cfg.rates)), - (EntityKind::BreathingRate, !cfg.privacy_mode && rl.allow(EntityKind::BreathingRate, elapsed, &cfg.rates)), - (EntityKind::MotionLevel, rl.allow(EntityKind::MotionLevel, elapsed, &cfg.rates)), - (EntityKind::MotionEnergy, rl.allow(EntityKind::MotionEnergy, elapsed, &cfg.rates)), - (EntityKind::PresenceScore, rl.allow(EntityKind::PresenceScore, elapsed, &cfg.rates)), - (EntityKind::Rssi, rl.allow(EntityKind::Rssi, elapsed, &cfg.rates)), + (EntityKind::PersonCount, rl.allow(node, EntityKind::PersonCount, elapsed, &cfg.rates)), + (EntityKind::HeartRate, !cfg.privacy_mode && rl.allow(node, EntityKind::HeartRate, elapsed, &cfg.rates)), + (EntityKind::BreathingRate, !cfg.privacy_mode && rl.allow(node, EntityKind::BreathingRate, elapsed, &cfg.rates)), + (EntityKind::MotionLevel, rl.allow(node, EntityKind::MotionLevel, elapsed, &cfg.rates)), + (EntityKind::MotionEnergy, rl.allow(node, EntityKind::MotionEnergy, elapsed, &cfg.rates)), + (EntityKind::PresenceScore, rl.allow(node, EntityKind::PresenceScore, elapsed, &cfg.rates)), + (EntityKind::Rssi, rl.allow(node, EntityKind::Rssi, elapsed, &cfg.rates)), ] { if !allowed { continue; diff --git a/v2/crates/wifi-densepose-sensing-server/src/mqtt/state.rs b/v2/crates/wifi-densepose-sensing-server/src/mqtt/state.rs index fd3a02a7..86d2ca43 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/mqtt/state.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/mqtt/state.rs @@ -58,24 +58,38 @@ impl StateMessage { } } -/// Sample-rate-limit decisions, per entity. Tracks the last-emitted -/// instant per entity and gates further emissions accordingly. Time is -/// supplied by the caller so the limiter is testable without a clock. +/// Sample-rate-limit decisions, per `(node, entity)`. Tracks the +/// last-emitted instant for each entity *on each node* and gates further +/// emissions accordingly. Time is supplied by the caller so the limiter is +/// testable without a clock. +/// +/// ADR-297 (issue #1541): the key is `(NodeId, EntityKind)`, not `EntityKind` +/// alone. With an entity-only key one node consumed the numeric publish slot +/// and every other node was suppressed until the interval expired — while +/// availability still reported them online. Keying by node keeps each node's +/// per-entity budget independent so nodes no longer starve one another. #[derive(Debug, Default)] pub struct RateLimiter { - last: HashMap, + last: HashMap<(String, EntityKind), Duration>, } impl RateLimiter { - /// Build a fresh limiter with no per-entity history. + /// Build a fresh limiter with no per-`(node, entity)` history. pub fn new() -> Self { Self { last: HashMap::new() } } - /// Decide whether a sample for `entity` is allowed to publish at - /// `now`, given the configured `rates`. Returns true to publish - /// (and updates last-emitted state); false to drop. - pub fn allow(&mut self, entity: EntityKind, now: Duration, rates: &PublishRates) -> bool { + /// Decide whether a sample for `entity` on node `node_id` is allowed to + /// publish at `now`, given the configured `rates`. Returns true to publish + /// (and updates last-emitted state); false to drop. Each node's budget for + /// an entity is independent of every other node's (ADR-297). + pub fn allow( + &mut self, + node_id: &str, + entity: EntityKind, + now: Duration, + rates: &PublishRates, + ) -> bool { let min_gap = match rate_hz_for(entity, rates) { // Zero / negative Hz → emit only on change (caller path). // Here we treat it as "always allow" because the caller is @@ -83,13 +97,15 @@ impl RateLimiter { rate if rate <= 0.0 => return true, rate => Duration::from_secs_f64(1.0 / rate), }; - match self.last.get(&entity) { - Some(&prev) if now.saturating_sub(prev) < min_gap => false, - _ => { - self.last.insert(entity, now); - true + // Borrow the key without allocating on the hot lookup path; only + // allocate the owned `String` when inserting a new node/entity slot. + if let Some(&prev) = self.last.get(&(node_id.to_string(), entity)) { + if now.saturating_sub(prev) < min_gap { + return false; } } + self.last.insert((node_id.to_string(), entity), now); + true } /// Reset all per-entity history. Used after a reconnect so the first @@ -352,10 +368,12 @@ mod tests { // ─── Rate limiter ──────────────────────────────────────────────── + const NODE: &str = "node-a"; + #[test] fn rate_limiter_first_sample_always_passes() { let mut rl = RateLimiter::new(); - assert!(rl.allow(EntityKind::HeartRate, Duration::ZERO, &rates())); + assert!(rl.allow(NODE, EntityKind::HeartRate, Duration::ZERO, &rates())); } #[test] @@ -363,27 +381,27 @@ mod tests { let mut rl = RateLimiter::new(); let r = rates(); // 0.2 Hz → 5 s gap. - assert!(rl.allow(EntityKind::HeartRate, Duration::from_secs(0), &r)); - assert!(!rl.allow(EntityKind::HeartRate, Duration::from_secs(1), &r)); - assert!(!rl.allow(EntityKind::HeartRate, Duration::from_secs(4), &r)); + assert!(rl.allow(NODE, EntityKind::HeartRate, Duration::from_secs(0), &r)); + assert!(!rl.allow(NODE, EntityKind::HeartRate, Duration::from_secs(1), &r)); + assert!(!rl.allow(NODE, EntityKind::HeartRate, Duration::from_secs(4), &r)); } #[test] fn rate_limiter_allows_after_gap() { let mut rl = RateLimiter::new(); let r = rates(); - assert!(rl.allow(EntityKind::HeartRate, Duration::from_secs(0), &r)); + assert!(rl.allow(NODE, EntityKind::HeartRate, Duration::from_secs(0), &r)); // 5 s gap met → allow. - assert!(rl.allow(EntityKind::HeartRate, Duration::from_secs(5), &r)); + assert!(rl.allow(NODE, EntityKind::HeartRate, Duration::from_secs(5), &r)); } #[test] fn rate_limiter_per_entity_independent() { let mut rl = RateLimiter::new(); let r = rates(); - assert!(rl.allow(EntityKind::HeartRate, Duration::from_secs(0), &r)); + assert!(rl.allow(NODE, EntityKind::HeartRate, Duration::from_secs(0), &r)); // Different entity, same instant → independent budget. - assert!(rl.allow(EntityKind::MotionLevel, Duration::from_secs(0), &r)); + assert!(rl.allow(NODE, EntityKind::MotionLevel, Duration::from_secs(0), &r)); } #[test] @@ -392,7 +410,7 @@ mod tests { let r = rates(); // Presence is change-only → rate=0 → unlimited; caller does change detection. for s in 0..3 { - assert!(rl.allow(EntityKind::Presence, Duration::from_secs(s), &r)); + assert!(rl.allow(NODE, EntityKind::Presence, Duration::from_secs(s), &r)); } } @@ -400,11 +418,27 @@ mod tests { fn rate_limiter_reset_re_enables_immediate_publish() { let mut rl = RateLimiter::new(); let r = rates(); - assert!(rl.allow(EntityKind::HeartRate, Duration::from_secs(0), &r)); - assert!(!rl.allow(EntityKind::HeartRate, Duration::from_secs(1), &r)); + assert!(rl.allow(NODE, EntityKind::HeartRate, Duration::from_secs(0), &r)); + assert!(!rl.allow(NODE, EntityKind::HeartRate, Duration::from_secs(1), &r)); rl.reset(); // Post-reset: first sample passes. - assert!(rl.allow(EntityKind::HeartRate, Duration::from_secs(1), &r)); + assert!(rl.allow(NODE, EntityKind::HeartRate, Duration::from_secs(1), &r)); + } + + #[test] + fn rate_limiter_nodes_do_not_starve_each_other() { + // ADR-297 (issue #1541): with an entity-only key, node-a consuming the + // slot suppressed node-b. Keyed by (node, entity), both publish. + let mut rl = RateLimiter::new(); + let r = rates(); + assert!(rl.allow("node-a", EntityKind::PersonCount, Duration::from_secs(0), &r)); + assert!( + rl.allow("node-b", EntityKind::PersonCount, Duration::from_secs(0), &r), + "second node must not be starved by the first" + ); + // Each node still rate-limits itself. + assert!(!rl.allow("node-a", EntityKind::PersonCount, Duration::from_millis(100), &r)); + assert!(!rl.allow("node-b", EntityKind::PersonCount, Duration::from_millis(100), &r)); } // ─── Boolean / binary_sensor encoder ───────────────────────────── diff --git a/v2/crates/wifi-densepose-sensing-server/src/provenance.rs b/v2/crates/wifi-densepose-sensing-server/src/provenance.rs new file mode 100644 index 00000000..fee1f1ca --- /dev/null +++ b/v2/crates/wifi-densepose-sensing-server/src/provenance.rs @@ -0,0 +1,295 @@ +//! ADR-295 — canonical source-provenance state machine. +//! +//! Source state used to be a boolean (`live` vs. not), so any ambiguous +//! condition — an unauthenticated status-endpoint error, a simulator that has +//! not yet produced a frame — collapsed to "live". Two release-path defects +//! (issues #1526, #1557) both trace to that collapse. +//! +//! This module defines one canonical, mutually-exclusive [`SourceState`] and a +//! **pure** transition function of `(last_frame_age, auth_status, source_kind)`. +//! It touches no clock and no socket, so every rule below is unit-testable: +//! +//! - `Unknown` is not a state. An ambiguous condition resolves to +//! `LiveUnverified`, `Stale`, or `Disconnected` — never `LiveVerified`. +//! - A status-endpoint error resolves to `Disconnected`/`LiveUnverified`, +//! never live-verified (issue #1526). +//! - A `Synthetic` source can never transition to any `Live*` state without +//! being reconstructed as a live source *and* presenting a verified frame +//! (issue #1557). +//! - `Synthetic` is watermarked in every export ([`SourceState::export_watermark`]). + +use std::time::Duration; + +/// Where a source's data originates. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SourceKind { + /// Generated data — the simulator, or replay of synthetic fixtures. + Synthetic, + /// A real external capture source (hardware or a network vendor feed). + Live, +} + +/// Result of authenticating / attesting the source, e.g. from a status-endpoint +/// probe. Deliberately distinguishes "not confirmed yet" from "the probe itself +/// errored" so an authorization failure can never masquerade as verified. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AuthStatus { + /// Source authenticated and attested. + Verified, + /// Reachable, but provenance is not yet confirmed. + Unverified, + /// The auth / status probe itself failed (401/403, unreachable, malformed). + Error, + /// No information available yet. + Unknown, +} + +/// Canonical, mutually-exclusive source state (ADR-295). There is intentionally +/// no `Unknown` / `Live` boolean — every ambiguous input maps to one of these. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SourceState { + /// Generated data. Always watermarked; never presented as real. + Synthetic, + /// Fresh frames from an authenticated, attested source. + LiveVerified, + /// Fresh frames arriving, but provenance not yet confirmed. + LiveUnverified, + /// Last frame is older than the freshness window. + Stale, + /// No source / no frame. + Disconnected, +} + +impl SourceState { + /// Pure transition. Resolves the canonical state from the only three inputs + /// that may influence it — no clock, no socket. + /// + /// * `last_frame_age` — age of the most recent frame; `None` when no frame + /// has ever been observed. + /// * `auth` — outcome of the most recent auth/attestation probe. + /// * `kind` — whether the source is synthetic or a live capture. + /// * `freshness_window` — maximum age a frame may have and still count as + /// fresh. + pub fn resolve( + last_frame_age: Option, + auth: AuthStatus, + kind: SourceKind, + freshness_window: Duration, + ) -> SourceState { + // A synthetic source is *always* synthetic. It can only become live by + // being reconstructed as `SourceKind::Live` presenting a verified frame, + // never by a transition here (ADR-295, issue #1557). + if kind == SourceKind::Synthetic { + return SourceState::Synthetic; + } + + match last_frame_age { + // No frame ever ⇒ no source. An auth error with no frame is + // Disconnected, never live (issue #1526). + None => SourceState::Disconnected, + // A frame exists but is older than the freshness window. + Some(age) if age > freshness_window => SourceState::Stale, + // A fresh frame is present. Only an explicitly `Verified` auth + // status may promote to `LiveVerified`; `Unknown` / `Error` / + // `Unverified` can never be verified-live (issue #1526). + Some(_) => match auth { + AuthStatus::Verified => SourceState::LiveVerified, + AuthStatus::Unverified | AuthStatus::Error | AuthStatus::Unknown => { + SourceState::LiveUnverified + } + }, + } + } + + /// Compatibility accessor for callers migrating off a boolean source flag. + /// True only for the two states that represent real, arriving frames. + pub fn is_live(self) -> bool { + matches!(self, SourceState::LiveVerified | SourceState::LiveUnverified) + } + + /// True when this state must be watermarked as synthetic in every view and + /// export (ADR-295). + pub fn is_synthetic(self) -> bool { + matches!(self, SourceState::Synthetic) + } + + /// Short, stable, machine-readable label for wire/JSON surfaces. + pub fn as_str(self) -> &'static str { + match self { + SourceState::Synthetic => "synthetic", + SourceState::LiveVerified => "live_verified", + SourceState::LiveUnverified => "live_unverified", + SourceState::Stale => "stale", + SourceState::Disconnected => "disconnected", + } + } + + /// Watermark that must accompany a synthetic export so generated data can + /// never be mistaken for a real capture. `None` for non-synthetic states. + pub fn export_watermark(self) -> Option<&'static str> { + if self.is_synthetic() { + Some("SYNTHETIC") + } else { + None + } + } + + /// Adapter that maps this server's free-form `source` label plus freshness + /// into a canonical [`SourceState`], so the existing string surface can + /// report the honest enum instead of a boolean collapse. + /// + /// Hardware / vendor feeds resolve to `LiveUnverified` — frames arrive but + /// provenance is not attested on this path — never `LiveVerified`. An + /// `":offline"`-suffixed label (issue #1004) resolves to `Disconnected`. + pub fn from_source_label( + source: &str, + last_frame_age: Option, + freshness_window: Duration, + ) -> SourceState { + if source.ends_with(":offline") { + return SourceState::Disconnected; + } + let kind = match source { + "simulated" | "simulate" | "synthetic" | "test" => SourceKind::Synthetic, + _ => SourceKind::Live, + }; + // This path carries no attestation, so the best a live feed earns is + // `Unverified` — the honest label. `resolve` still short-circuits a + // synthetic kind to `Synthetic` regardless of frame age. + SourceState::resolve(last_frame_age, AuthStatus::Unverified, kind, freshness_window) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const WINDOW: Duration = Duration::from_secs(5); + fn fresh() -> Option { + Some(Duration::from_millis(100)) + } + fn old() -> Option { + Some(WINDOW + Duration::from_secs(1)) + } + + // ── Core honesty rules ─────────────────────────────────────────────── + + #[test] + fn verified_fresh_live_source_is_live_verified() { + assert_eq!( + SourceState::resolve(fresh(), AuthStatus::Verified, SourceKind::Live, WINDOW), + SourceState::LiveVerified + ); + } + + #[test] + fn auth_error_never_resolves_to_live_verified() { + // Fresh frame but the status probe errored ⇒ unverified, not verified. + assert_eq!( + SourceState::resolve(fresh(), AuthStatus::Error, SourceKind::Live, WINDOW), + SourceState::LiveUnverified + ); + // No frame and an auth error ⇒ Disconnected, never live (issue #1526). + assert_eq!( + SourceState::resolve(None, AuthStatus::Error, SourceKind::Live, WINDOW), + SourceState::Disconnected + ); + } + + #[test] + fn unknown_never_resolves_to_live_verified() { + let s = SourceState::resolve(fresh(), AuthStatus::Unknown, SourceKind::Live, WINDOW); + assert_eq!(s, SourceState::LiveUnverified); + assert_ne!(s, SourceState::LiveVerified); + } + + #[test] + fn synthetic_never_becomes_live_even_with_verified_fresh_frame() { + // Issue #1557: the simulator constructs Synthetic and cannot transition + // to any Live* state without being reconstructed as a live source. + assert_eq!( + SourceState::resolve(fresh(), AuthStatus::Verified, SourceKind::Synthetic, WINDOW), + SourceState::Synthetic + ); + assert!(!SourceState::resolve(fresh(), AuthStatus::Verified, SourceKind::Synthetic, WINDOW) + .is_live()); + } + + #[test] + fn freshness_expiry_is_stale() { + assert_eq!( + SourceState::resolve(old(), AuthStatus::Verified, SourceKind::Live, WINDOW), + SourceState::Stale + ); + } + + #[test] + fn no_frame_is_disconnected() { + assert_eq!( + SourceState::resolve(None, AuthStatus::Verified, SourceKind::Live, WINDOW), + SourceState::Disconnected + ); + } + + // ── Watermark / compat accessors ───────────────────────────────────── + + #[test] + fn synthetic_export_is_watermarked() { + assert_eq!(SourceState::Synthetic.export_watermark(), Some("SYNTHETIC")); + assert!(SourceState::Synthetic.is_synthetic()); + assert_eq!(SourceState::LiveVerified.export_watermark(), None); + assert_eq!(SourceState::Disconnected.export_watermark(), None); + } + + #[test] + fn is_live_only_for_live_states() { + assert!(SourceState::LiveVerified.is_live()); + assert!(SourceState::LiveUnverified.is_live()); + assert!(!SourceState::Synthetic.is_live()); + assert!(!SourceState::Stale.is_live()); + assert!(!SourceState::Disconnected.is_live()); + } + + // ── Label adapter ──────────────────────────────────────────────────── + + #[test] + fn simulated_label_is_synthetic_regardless_of_frames() { + // Even with fresh frames arriving, a simulated source stays synthetic. + let s = SourceState::from_source_label("simulated", fresh(), WINDOW); + assert_eq!(s, SourceState::Synthetic); + assert!(!s.is_live()); + assert_eq!(SourceState::from_source_label("simulate", None, WINDOW), SourceState::Synthetic); + } + + #[test] + fn hardware_label_is_live_unverified_never_verified() { + let s = SourceState::from_source_label("esp32", fresh(), WINDOW); + assert_eq!(s, SourceState::LiveUnverified); + assert_ne!(s, SourceState::LiveVerified); + } + + #[test] + fn offline_label_is_disconnected() { + assert_eq!( + SourceState::from_source_label("esp32:offline", fresh(), WINDOW), + SourceState::Disconnected + ); + } + + #[test] + fn stale_hardware_label_is_stale() { + assert_eq!( + SourceState::from_source_label("wifi:home", old(), WINDOW), + SourceState::Stale + ); + } + + #[test] + fn as_str_is_stable() { + assert_eq!(SourceState::Synthetic.as_str(), "synthetic"); + assert_eq!(SourceState::LiveVerified.as_str(), "live_verified"); + assert_eq!(SourceState::LiveUnverified.as_str(), "live_unverified"); + assert_eq!(SourceState::Stale.as_str(), "stale"); + assert_eq!(SourceState::Disconnected.as_str(), "disconnected"); + } +} diff --git a/v2/crates/wifi-densepose-sensing-server/src/udp_bind.rs b/v2/crates/wifi-densepose-sensing-server/src/udp_bind.rs new file mode 100644 index 00000000..0218a060 --- /dev/null +++ b/v2/crates/wifi-densepose-sensing-server/src/udp_bind.rs @@ -0,0 +1,404 @@ +//! ADR-296: sensor data-plane bind hardening — UDP bind scope + source allowlist. +//! +//! The CSI UDP receiver historically bound `0.0.0.0` unconditionally, with no +//! equivalent of the HTTP `--bind-addr` flag, no source allowlist, and no +//! message authentication. Any host that could reach the port could inject a +//! valid-shaped frame and influence presence/vital/automation outputs. +//! +//! This module supplies the *step one* controls: a pure, socket-free bind +//! decision (loopback default, fail-closed on an unguarded routable bind, +//! mirroring the HTTP/OAuth refusal pattern) and a dependency-free source +//! IP/CIDR allowlist with a drop counter. +//! +//! # Threat model & residual risk +//! +//! An IP/CIDR allowlist restricts *which addresses* may deliver frames; it does +//! **not** authenticate the sender. On a trusted LAN an attacker who can spoof a +//! source address, or who controls an allowlisted host, can still inject frames. +//! The safe default is therefore loopback-only. Binding to a routable address +//! is an explicit operator choice and requires either `--udp-allow` (restrict +//! sources) or `--udp-insecure-lan` (accept the residual risk explicitly). +//! +//! **Deferred to a follow-up ADR (step two):** per-device provisioned keys, +//! MAC/AEAD, device identifiers, monotonic sequence numbers, a freshness +//! window, and replay rejection. Until those land the UDP data plane is NOT +//! authenticated. See the crate `SECURITY.md`. + +use std::net::IpAddr; +use std::sync::atomic::{AtomicU64, Ordering}; + +/// Upper bound on parsed allowlist entries. The list comes from operator CLI +/// input, but we cap it anyway to keep allocation bounded at the boundary. +const MAX_ENTRIES: usize = 4096; + +/// Outcome of evaluating the requested UDP bind scope against the allowlist and +/// override flags. Pure and socket-free so the decision can be unit-tested +/// without binding a real socket. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UdpBindDecision { + /// Bind is loopback-only (`127.0.0.1` / `::1`); not reachable off-host. + Loopback, + /// Bind is routable and a source allowlist is enforced. + RoutableAllowlisted, + /// Bind is routable with no allowlist, explicitly accepted via + /// `--udp-insecure-lan`. + RoutableInsecure, +} + +/// Decide whether the requested UDP bind is permitted. +/// +/// Fail-closed, mirroring the HTTP/OAuth boot refusals: a routable bind with no +/// source allowlist is rejected unless the operator passes the explicit +/// `--udp-insecure-lan` override. Loopback is always fine. +pub fn decide_udp_bind( + bind: IpAddr, + has_allowlist: bool, + insecure_lan: bool, +) -> Result { + if bind.is_loopback() { + return Ok(UdpBindDecision::Loopback); + } + if has_allowlist { + return Ok(UdpBindDecision::RoutableAllowlisted); + } + if insecure_lan { + return Ok(UdpBindDecision::RoutableInsecure); + } + Err(format!( + "Refusing to bind the UDP CSI receiver to routable address {bind} with no \ + source allowlist. Pass --udp-allow to restrict sources, or \ + --udp-insecure-lan to accept the LAN-spoofing risk explicitly. The default \ + is loopback (127.0.0.1); see the crate SECURITY.md (ADR-296)." + )) +} + +/// One-line startup security summary describing the resolved bind scope and +/// allowlist state, for the boot log. +pub fn startup_summary( + decision: UdpBindDecision, + bind: IpAddr, + port: u16, + allowlist: &UdpSourceAllowlist, +) -> String { + let scope = match decision { + UdpBindDecision::Loopback => "loopback-only (not reachable off-host)", + UdpBindDecision::RoutableAllowlisted => "ROUTABLE, source allowlist enforced", + UdpBindDecision::RoutableInsecure => { + "ROUTABLE, NO allowlist (--udp-insecure-lan; data plane is UNAUTHENTICATED)" + } + }; + if allowlist.is_active() { + format!( + "bind {bind}:{port} — {scope}; allowlist active ({} entr{}), loopback always allowed", + allowlist.len(), + if allowlist.len() == 1 { "y" } else { "ies" } + ) + } else { + format!("bind {bind}:{port} — {scope}; no source allowlist") + } +} + +/// A single parsed IP or CIDR allowlist entry. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct CidrEntry { + network: IpAddr, + prefix_len: u8, +} + +impl CidrEntry { + /// Whether `ip` falls inside this network. Family mismatches never match. + fn contains(&self, ip: IpAddr) -> bool { + match (self.network, ip) { + (IpAddr::V4(net), IpAddr::V4(addr)) => { + let mask = v4_mask(self.prefix_len); + (u32::from(net) & mask) == (u32::from(addr) & mask) + } + (IpAddr::V6(net), IpAddr::V6(addr)) => { + let mask = v6_mask(self.prefix_len); + (u128::from(net) & mask) == (u128::from(addr) & mask) + } + _ => false, + } + } +} + +fn v4_mask(prefix: u8) -> u32 { + match prefix { + 0 => 0, + p if p >= 32 => u32::MAX, + p => u32::MAX << (32 - p), + } +} + +fn v6_mask(prefix: u8) -> u128 { + match prefix { + 0 => 0, + p if p >= 128 => u128::MAX, + p => u128::MAX << (128 - p), + } +} + +/// Parse one `IP` or `IP/prefix` entry. Never panics on malformed input. +fn parse_entry(raw: &str) -> Result { + let s = raw.trim(); + if let Some((ip_str, pfx_str)) = s.split_once('/') { + let ip: IpAddr = ip_str + .trim() + .parse() + .map_err(|_| format!("invalid IP address in allowlist entry '{s}'"))?; + let max = if ip.is_ipv4() { 32u8 } else { 128u8 }; + let pfx: u8 = pfx_str + .trim() + .parse() + .map_err(|_| format!("invalid prefix length in allowlist entry '{s}'"))?; + if pfx > max { + return Err(format!( + "prefix /{pfx} exceeds maximum /{max} for {ip} in allowlist entry '{s}'" + )); + } + Ok(CidrEntry { + network: ip, + prefix_len: pfx, + }) + } else { + let ip: IpAddr = s + .parse() + .map_err(|_| format!("invalid IP address in allowlist entry '{s}'"))?; + let prefix_len = if ip.is_ipv4() { 32 } else { 128 }; + Ok(CidrEntry { + network: ip, + prefix_len, + }) + } +} + +/// Source IP/CIDR allowlist for inbound UDP CSI frames. +/// +/// When no entries are configured the allowlist is *inactive* and accepts every +/// source (the bind decision, not this filter, gates an unguarded routable +/// bind). When entries are present, only loopback and matching sources are +/// allowed; everything else is dropped and counted. +#[derive(Debug, Default)] +pub struct UdpSourceAllowlist { + entries: Vec, + dropped: AtomicU64, +} + +impl UdpSourceAllowlist { + /// Parse an allowlist from CLI/env specs. Each item may itself be a + /// comma-separated list; whitespace and empty items are ignored. Returns an + /// error on the first malformed entry rather than silently dropping it. + pub fn parse(specs: I) -> Result + where + I: IntoIterator, + S: AsRef, + { + let mut entries: Vec = Vec::new(); + for spec in specs { + for part in spec.as_ref().split(',') { + let part = part.trim(); + if part.is_empty() { + continue; + } + if entries.len() >= MAX_ENTRIES { + return Err(format!( + "too many allowlist entries (limit {MAX_ENTRIES})" + )); + } + entries.push(parse_entry(part)?); + } + } + Ok(Self { + entries, + dropped: AtomicU64::new(0), + }) + } + + /// Whether any allowlist entries are configured (i.e. filtering is on). + pub fn is_active(&self) -> bool { + !self.entries.is_empty() + } + + /// Number of configured entries. + pub fn len(&self) -> usize { + self.entries.len() + } + + /// Whether the allowlist has no configured entries. + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + /// Whether `src` is permitted. Loopback is always allowed; an inactive + /// allowlist accepts everything. Does not touch the drop counter. + pub fn is_allowed(&self, src: IpAddr) -> bool { + if src.is_loopback() { + return true; + } + if self.entries.is_empty() { + return true; + } + self.entries.iter().any(|e| e.contains(src)) + } + + /// Like [`is_allowed`](Self::is_allowed) but records a drop when the source + /// is rejected. Use this on the hot receive path. + pub fn admit(&self, src: IpAddr) -> bool { + let ok = self.is_allowed(src); + if !ok { + self.dropped.fetch_add(1, Ordering::Relaxed); + } + ok + } + + /// Total frames dropped due to a non-matching source. + pub fn dropped(&self) -> u64 { + self.dropped.load(Ordering::Relaxed) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::net::{Ipv4Addr, Ipv6Addr}; + + fn v4(a: u8, b: u8, c: u8, d: u8) -> IpAddr { + IpAddr::V4(Ipv4Addr::new(a, b, c, d)) + } + + #[test] + fn default_bind_is_loopback() { + let d = decide_udp_bind(IpAddr::V4(Ipv4Addr::LOCALHOST), false, false).unwrap(); + assert_eq!(d, UdpBindDecision::Loopback); + // IPv6 loopback too. + let d6 = decide_udp_bind(IpAddr::V6(Ipv6Addr::LOCALHOST), false, false).unwrap(); + assert_eq!(d6, UdpBindDecision::Loopback); + } + + #[test] + fn routable_bind_without_allowlist_is_refused() { + // 0.0.0.0 is not loopback → refused with no allowlist and no override. + let err = decide_udp_bind(IpAddr::V4(Ipv4Addr::UNSPECIFIED), false, false).unwrap_err(); + assert!(err.contains("Refusing"), "{err}"); + let err_lan = decide_udp_bind(v4(192, 168, 1, 10), false, false).unwrap_err(); + assert!(err_lan.contains("--udp-allow"), "{err_lan}"); + } + + #[test] + fn routable_bind_allowed_with_allowlist_or_override() { + assert_eq!( + decide_udp_bind(v4(0, 0, 0, 0), true, false).unwrap(), + UdpBindDecision::RoutableAllowlisted + ); + assert_eq!( + decide_udp_bind(v4(0, 0, 0, 0), false, true).unwrap(), + UdpBindDecision::RoutableInsecure + ); + } + + #[test] + fn loopback_bind_ignores_missing_allowlist() { + // Even without allowlist/override, loopback is never refused. + assert_eq!( + decide_udp_bind(IpAddr::V4(Ipv4Addr::LOCALHOST), false, false).unwrap(), + UdpBindDecision::Loopback + ); + } + + #[test] + fn allowlist_accept_drop_and_count() { + let a = UdpSourceAllowlist::parse(["192.168.1.0/24"]).unwrap(); + assert!(a.is_active()); + assert!(a.admit(v4(192, 168, 1, 42))); + assert!(!a.admit(v4(10, 0, 0, 1))); + assert!(!a.admit(v4(192, 168, 2, 1))); + assert_eq!(a.dropped(), 2); + // Accepts did not touch the counter. + assert!(a.admit(v4(192, 168, 1, 200))); + assert_eq!(a.dropped(), 2); + } + + #[test] + fn loopback_source_always_allowed() { + let a = UdpSourceAllowlist::parse(["10.0.0.0/8"]).unwrap(); + assert!(a.admit(IpAddr::V4(Ipv4Addr::LOCALHOST))); + assert!(a.admit(IpAddr::V6(Ipv6Addr::LOCALHOST))); + assert_eq!(a.dropped(), 0); + // A non-loopback outside the list is still dropped. + assert!(!a.admit(v4(192, 168, 0, 1))); + assert_eq!(a.dropped(), 1); + } + + #[test] + fn inactive_allowlist_accepts_everything() { + let a = UdpSourceAllowlist::parse(Vec::::new()).unwrap(); + assert!(!a.is_active()); + assert!(a.admit(v4(203, 0, 113, 7))); + assert_eq!(a.dropped(), 0); + } + + #[test] + fn exact_ip_entry_matches_only_itself() { + let a = UdpSourceAllowlist::parse(["10.0.0.5"]).unwrap(); + assert!(a.is_allowed(v4(10, 0, 0, 5))); + assert!(!a.is_allowed(v4(10, 0, 0, 6))); + } + + #[test] + fn comma_and_multi_spec_parsing() { + let a = UdpSourceAllowlist::parse(["192.168.1.0/24, 10.0.0.5", "172.16.0.0/12"]).unwrap(); + assert_eq!(a.len(), 3); + assert!(a.is_allowed(v4(172, 20, 5, 5))); + assert!(a.is_allowed(v4(10, 0, 0, 5))); + assert!(!a.is_allowed(v4(8, 8, 8, 8))); + } + + #[test] + fn ipv6_cidr_matching() { + let a = UdpSourceAllowlist::parse(["2001:db8::/32"]).unwrap(); + assert!(a.is_allowed("2001:db8:1234::1".parse().unwrap())); + assert!(!a.is_allowed("2001:dead::1".parse().unwrap())); + // v4 source never matches a v6 entry. + assert!(!a.is_allowed(v4(192, 168, 1, 1))); + } + + #[test] + fn malformed_entries_error_without_panic() { + assert!(UdpSourceAllowlist::parse(["not-an-ip"]).is_err()); + assert!(UdpSourceAllowlist::parse(["192.168.1.0/33"]).is_err()); + assert!(UdpSourceAllowlist::parse(["10.0.0.0/x"]).is_err()); + assert!(UdpSourceAllowlist::parse(["::1/129"]).is_err()); + } + + #[test] + fn prefix_zero_matches_all_of_family() { + let a = UdpSourceAllowlist::parse(["0.0.0.0/0"]).unwrap(); + assert!(a.is_allowed(v4(1, 2, 3, 4))); + assert!(a.is_allowed(v4(203, 0, 113, 9))); + // But not IPv6 — different family. + assert!(!a.is_allowed("2001:db8::1".parse().unwrap())); + } + + #[test] + fn startup_summary_mentions_scope_and_allowlist() { + let a = UdpSourceAllowlist::parse(["192.168.1.0/24"]).unwrap(); + let s = startup_summary( + UdpBindDecision::RoutableAllowlisted, + v4(0, 0, 0, 0), + 5005, + &a, + ); + assert!(s.contains("allowlist active"), "{s}"); + assert!(s.contains("loopback always allowed"), "{s}"); + + let empty = UdpSourceAllowlist::default(); + let loop_s = startup_summary( + UdpBindDecision::Loopback, + IpAddr::V4(Ipv4Addr::LOCALHOST), + 5005, + &empty, + ); + assert!(loop_s.contains("loopback-only"), "{loop_s}"); + assert!(loop_s.contains("no source allowlist"), "{loop_s}"); + } +} diff --git a/v2/crates/wifi-densepose-train/Cargo.toml b/v2/crates/wifi-densepose-train/Cargo.toml index ddfeeb1a..ed0b6823 100644 --- a/v2/crates/wifi-densepose-train/Cargo.toml +++ b/v2/crates/wifi-densepose-train/Cargo.toml @@ -107,3 +107,9 @@ ndarray-npy.workspace = true [[bench]] name = "training_bench" harness = false + +# ADR-291 — bfee-parser throughput and split-assignment benchmarks on +# synthetic, code-generated corpora (no dataset files). +[[bench]] +name = "benchmark_harness" +harness = false diff --git a/v2/crates/wifi-densepose-train/benches/benchmark_harness.rs b/v2/crates/wifi-densepose-train/benches/benchmark_harness.rs new file mode 100644 index 00000000..3aa60080 --- /dev/null +++ b/v2/crates/wifi-densepose-train/benches/benchmark_harness.rs @@ -0,0 +1,127 @@ +//! ADR-291 benchmarks: bfee parser throughput and split assignment over +//! synthetic, code-generated corpora (no dataset files are read or written). + +use criterion::{black_box, criterion_group, criterion_main, Criterion, Throughput}; +use wifi_densepose_train::dataset::widar::{encode_bfee_frame, parse_bfee_bytes, WIDAR_SUBCARRIERS}; +use wifi_densepose_train::protocols::leakage::LeakageAudit; +use wifi_densepose_train::protocols::{SampleMeta, SplitPlan, SplitProtocol, SplitSide}; + +/// Deterministic synthetic bfee log: `num_records` framed 3×3 records. +fn synthetic_log(num_records: usize) -> Vec { + let (n_rx, n_tx) = (3u8, 3u8); + let pairs = WIDAR_SUBCARRIERS * n_rx as usize * n_tx as usize; + let mut bytes = Vec::new(); + for t in 0..num_records { + let csi: Vec<(i16, i16)> = (0..pairs) + .map(|i| { + let re = ((t * 37 + i * 13) % 1024) as i16 - 512; + let im = ((t * 17 + i * 7) % 1024) as i16 - 512; + (re, im) + }) + .collect(); + bytes.extend_from_slice(&encode_bfee_frame(t as u32, t as u16, n_rx, n_tx, &csi)); + } + bytes +} + +/// Deterministic synthetic metadata corpus. +fn synthetic_metas(n: usize) -> Vec { + (0..n) + .map(|i| SampleMeta { + subject_id: 1 + (i % 17) as u32, + environment_id: 1 + (i % 3) as u32, + orientation_id: 1 + (i % 5) as u32, + gesture_id: 1 + (i % 6) as u32, + recording_id: (i / 50) as u64, + window_index: (i % 50) as u64, + }) + .collect() +} + +/// Deterministic synthetic corpus whose domain attributes are constant per +/// recording (as real datasets are), so protocol splits keep recordings whole +/// and the leakage audit exercises its full passing path. +fn synthetic_recording_metas(n: usize, windows_per_recording: usize) -> Vec { + (0..n) + .map(|i| { + let recording = (i / windows_per_recording) as u64; + SampleMeta { + subject_id: 1 + (recording % 17) as u32, + environment_id: 1 + (recording % 3) as u32, + orientation_id: 1 + (recording % 5) as u32, + gesture_id: 1 + (recording % 6) as u32, + recording_id: recording, + window_index: (i % windows_per_recording) as u64, + } + }) + .collect() +} + +fn bench_bfee_parser(c: &mut Criterion) { + let bytes = synthetic_log(500); + let mut group = c.benchmark_group("widar_bfee_parse"); + group.throughput(Throughput::Bytes(bytes.len() as u64)); + group.bench_function("500_records_3x3", |b| { + b.iter(|| { + let parse = parse_bfee_bytes(black_box(&bytes)); + assert_eq!(parse.records.len(), 500); + parse + }) + }); + group.finish(); +} + +fn bench_split_assignment(c: &mut Criterion) { + let metas = synthetic_metas(10_000); + let mut group = c.benchmark_group("split_assignment"); + group.throughput(Throughput::Elements(metas.len() as u64)); + for protocol in [ + SplitProtocol::CrossSubject, + SplitProtocol::CrossEnvironment, + SplitProtocol::CrossOrientation, + SplitProtocol::RandomBaseline, + ] { + let plan = SplitPlan::new(protocol, 42, 0.3).expect("valid fraction"); + group.bench_function(protocol.tag(), |b| { + b.iter(|| plan.partition(black_box(&metas))) + }); + } + group.finish(); +} + +fn bench_leakage_audit(c: &mut Criterion) { + // ~10k windows in 200 recordings; a clean cross-subject split so the + // audit runs every check (recording crossing + claimed disjointness) to + // completion instead of failing fast. + let metas = synthetic_recording_metas(10_000, 50); + let plan = SplitPlan::new(SplitProtocol::CrossSubject, 42, 0.3).expect("valid fraction"); + let mut train = Vec::new(); + let mut test = Vec::new(); + for meta in &metas { + match plan.assign(meta) { + SplitSide::Train => train.push(*meta), + SplitSide::Test => test.push(*meta), + } + } + assert!(!train.is_empty() && !test.is_empty(), "degenerate corpus"); + + let audit = LeakageAudit::for_protocol(SplitProtocol::CrossSubject); + let mut group = c.benchmark_group("leakage_audit"); + group.throughput(Throughput::Elements(metas.len() as u64)); + group.bench_function("cross_subject_10k_windows", |b| { + b.iter(|| { + audit + .audit(black_box(&train), black_box(&test)) + .expect("clean split must pass") + }) + }); + group.finish(); +} + +criterion_group!( + benches, + bench_bfee_parser, + bench_split_assignment, + bench_leakage_audit +); +criterion_main!(benches); diff --git a/v2/crates/wifi-densepose-train/src/dataset.rs b/v2/crates/wifi-densepose-train/src/dataset.rs index d13e8329..1da84e70 100644 --- a/v2/crates/wifi-densepose-train/src/dataset.rs +++ b/v2/crates/wifi-densepose-train/src/dataset.rs @@ -40,6 +40,10 @@ //! assert_eq!(sample.amplitude.shape(), &[100, 3, 3, 56]); //! ``` +/// Widar3.0 ingest — Intel 5300 `.dat` "bfee" parser and [`CsiDataset`] +/// adapter with split-protocol metadata (ADR-291 §1). +pub mod widar; + use ndarray::{Array1, Array2, Array4}; use ruvector_temporal_tensor::segment as tt_segment; use ruvector_temporal_tensor::{TemporalTensorCompressor, TierPolicy}; diff --git a/v2/crates/wifi-densepose-train/src/dataset/widar.rs b/v2/crates/wifi-densepose-train/src/dataset/widar.rs new file mode 100644 index 00000000..867f6705 --- /dev/null +++ b/v2/crates/wifi-densepose-train/src/dataset/widar.rs @@ -0,0 +1,1200 @@ +//! Widar3.0 ingest — Intel 5300 `.dat` "bfee" CSI log parser and dataset +//! adapter (ADR-291 §1). +//! +//! The Widar3.0 raw distribution ships CSI captured with the Intel 5300 NIC +//! and the Linux 802.11n CSI Tool, stored as framed binary `.dat` logs. This +//! module provides: +//! +//! - [`parse_bfee_bytes`] — a bounded, panic-free parser for the framed +//! "bfee" record stream. Invalid records are **skipped with a warning**, +//! never a panic: `.dat` files are untrusted input and are validated at the +//! boundary (CLAUDE.md). +//! - [`WidarDataset`] — a [`CsiDataset`] implementation that maps each `.dat` +//! recording into windowed [`CsiSample`]s (with subcarrier interpolation to +//! the training pipeline's target count) and exposes per-window +//! [`SampleMeta`] for the ADR-291 split protocols. +//! - [`encode_bfee_frame`] — a deterministic synthetic-fixture encoder used by +//! unit tests and benches, so no binary dataset files are ever checked in. +//! +//! # Binary record layout (ADR-291) +//! +//! ```text +//! frame : u16 LE field_len | u8 code (code 0xBB = bfee record) +//! field_len counts the code byte plus the payload, so the next +//! frame starts field_len + 2 bytes later. +//! payload : 20-byte bfee header +//! [0..4) timestamp_low u32 LE +//! [4..6) bfee_count u16 LE +//! [6..8) reserved (2 bytes, ignored) +//! [8] n_rx u8 (1..=3) +//! [9] n_tx u8 (1..=3) +//! [10..13) rssi_a/b/c u8 each +//! [13] noise i8 +//! [14] agc u8 +//! [15] antenna_sel u8 +//! [16..18) len u16 LE (packed CSI byte count) +//! [18..20) rate u16 LE +//! then `len` bytes of packed CSI. +//! csi : 10-bit two's-complement components, packed LSB-first with no +//! inter-field padding, in order +//! for sc in 0..30 { for rx in 0..n_rx { for tx in 0..n_tx { +//! real; imag; } } } +//! `len` must equal ceil(30 * n_rx * n_tx * 2 * 10 / 8). +//! ``` +//! +//! This is the layout specified by ADR-291. Note the original Linux CSI Tool +//! writes 8-bit components with per-group shift bits and a big-endian frame +//! length; if raw upstream logs are ingested unconverted, records fail the +//! `len` consistency check and are skipped with a warning rather than being +//! silently misdecoded. +//! +//! # Widar3.0 naming convention (assumed, tolerant) +//! +//! The Widar3.0 site was not reachable from this build environment, so the +//! convention below is **assumed** from the Widar3.0 paper/release notes and +//! the parser is deliberately tolerant (missing fields parse as `0`): +//! +//! ```text +//! /[room1/]/user1/user1-3-1-1-2-r5.dat +//! │ │ │ │ │ └ receiver id (optional) +//! │ │ │ │ └ repetition number +//! │ │ │ └ face orientation (1..=5) +//! │ │ └ torso location (1..=5) +//! │ └ gesture type +//! └ user id +//! ``` +//! +//! The environment/room id is taken from the nearest ancestor directory named +//! `room` (case-insensitive); when absent (the raw release groups by +//! capture date instead) it defaults to `0` and cross-environment splits over +//! such a tree will fail the leakage audit rather than silently pass. + +use ndarray::{Array1, Array2, Array3, Array4}; +use num_complex::Complex; +use std::path::{Path, PathBuf}; +use tracing::{debug, info, warn}; + +use crate::dataset::{CsiDataset, CsiSample}; +use crate::error::DatasetError; +use crate::protocols::SampleMeta; +use crate::subcarrier::interpolate_subcarriers; + +/// Complex CSI component type used by the parser. +pub type Complex32 = Complex; + +// --------------------------------------------------------------------------- +// Format constants +// --------------------------------------------------------------------------- + +/// Record code identifying a beamforming-feedback ("bfee") CSI record. +pub const BFEE_CODE: u8 = 0xBB; + +/// Bytes in the per-frame header (`u16` length + `u8` code). +const FRAME_HEADER_LEN: usize = 3; + +/// Bytes in the fixed bfee header that precedes the packed CSI payload. +const BFEE_HEADER_LEN: usize = 20; + +/// Number of subcarrier groups reported by the Intel 5300 (30 groups over a +/// 20/40 MHz channel). +pub const WIDAR_SUBCARRIERS: usize = 30; + +/// Bits per packed CSI component (10-bit two's complement). +const CSI_COMPONENT_BITS: usize = 10; + +/// Maximum antenna count on either side (Intel 5300 has 3 antennas). +const MAX_ANTENNAS: usize = 3; + +/// Upper bound on a single frame's `field_len`, derived from the largest +/// possible record (3×3 CSI ≈ 695 bytes) with generous slack. A larger value +/// means framing is lost; the parser stops instead of allocating unboundedly. +const MAX_FIELD_LEN: usize = 4096; + +/// Upper bound on a `.dat` file accepted by [`WidarDataset::discover`]. +/// Bounded allocation at the file boundary; larger files are skipped with a +/// warning. +const MAX_DAT_FILE_BYTES: u64 = 512 * 1024 * 1024; + +/// Number of COCO keypoints emitted in [`CsiSample`]s. Widar is a gesture +/// dataset with no pose ground truth, so keypoints are zero with visibility +/// `0` (COCO "not labelled"). +const NUM_KEYPOINTS: usize = 17; + +/// Packed CSI byte length for a record with the given antenna counts: +/// `ceil(30 × n_rx × n_tx × 2 × 10 / 8)`. +#[must_use] +pub fn packed_csi_len(n_rx: usize, n_tx: usize) -> usize { + (WIDAR_SUBCARRIERS * n_rx * n_tx * 2 * CSI_COMPONENT_BITS).div_ceil(8) +} + +// --------------------------------------------------------------------------- +// BfeeRecord + parser +// --------------------------------------------------------------------------- + +/// One decoded bfee CSI record. +#[derive(Debug, Clone)] +pub struct BfeeRecord { + /// Low 32 bits of the NIC's 1 MHz clock at capture time. + pub timestamp_low: u32, + /// Running count of bfee measurements delivered by the NIC. + pub bfee_count: u16, + /// Number of receive antennas (1..=3). + pub n_rx: u8, + /// Number of transmit antennas (1..=3). + pub n_tx: u8, + /// RSSI at antenna A (dB above an internal reference). + pub rssi_a: u8, + /// RSSI at antenna B. + pub rssi_b: u8, + /// RSSI at antenna C. + pub rssi_c: u8, + /// Noise floor estimate in dBm. + pub noise: i8, + /// Automatic gain control setting. + pub agc: u8, + /// Antenna selection / permutation bits. + pub antenna_sel: u8, + /// Rate/flags field as logged by the driver. + pub rate: u16, + /// Complex CSI, shape `[n_tx, n_rx, 30]`. + pub csi: Array3, +} + +/// Outcome of parsing a byte buffer of framed bfee records. +#[derive(Debug, Clone)] +pub struct BfeeParse { + /// Successfully decoded records, in file order. + pub records: Vec, + /// Number of records skipped because they were truncated or corrupt. + pub skipped: usize, + /// Number of well-framed records with a non-bfee code (ignored, not an + /// error — real logs interleave other record types). + pub non_bfee: usize, +} + +/// Parse a buffer of framed Intel 5300 bfee records (ADR-291 layout — see the +/// module docs for the exact binary format). +/// +/// The parser never panics on malformed input: invalid or truncated records +/// are skipped with a `warn!` and counted in [`BfeeParse::skipped`]. When +/// framing is irrecoverably lost (a `field_len` beyond [`MAX_FIELD_LEN`] or a +/// record extending past the end of the buffer) parsing stops at that point. +#[must_use] +pub fn parse_bfee_bytes(bytes: &[u8]) -> BfeeParse { + // Conservative lower-bound estimate (largest possible frame) so a clean + // log skips the early Vec doublings without ever over-reserving. + let max_frame = 2 + 1 + BFEE_HEADER_LEN + packed_csi_len(MAX_ANTENNAS, MAX_ANTENNAS); + let mut records = Vec::with_capacity(bytes.len() / max_frame); + let mut skipped = 0usize; + let mut non_bfee = 0usize; + let mut cursor = 0usize; + + while cursor + FRAME_HEADER_LEN <= bytes.len() { + let field_len = u16::from_le_bytes([bytes[cursor], bytes[cursor + 1]]) as usize; + if field_len == 0 { + warn!("bfee frame at byte {cursor}: zero field_len, skipping frame header"); + skipped += 1; + cursor += FRAME_HEADER_LEN; + continue; + } + if field_len > MAX_FIELD_LEN { + warn!( + "bfee frame at byte {cursor}: field_len {field_len} exceeds bound \ + {MAX_FIELD_LEN}; framing lost, abandoning remainder of buffer" + ); + skipped += 1; + break; + } + let frame_end = cursor + 2 + field_len; + if frame_end > bytes.len() { + warn!( + "bfee frame at byte {cursor}: truncated (needs {} bytes, {} remain)", + field_len + 2, + bytes.len() - cursor + ); + skipped += 1; + break; + } + + let code = bytes[cursor + 2]; + let payload = &bytes[cursor + FRAME_HEADER_LEN..frame_end]; + cursor = frame_end; + + if code != BFEE_CODE { + debug!("skipping non-bfee record code {code:#04x}"); + non_bfee += 1; + continue; + } + + match parse_bfee_payload(payload) { + Ok(record) => records.push(record), + Err(reason) => { + warn!("skipping corrupt bfee record: {reason}"); + skipped += 1; + } + } + } + + let tail = bytes.len().saturating_sub(cursor); + if tail > 0 && tail < FRAME_HEADER_LEN { + // A dangling partial frame header at EOF is a truncation, not silence. + warn!("bfee buffer ends with {tail} dangling byte(s) (truncated frame header)"); + skipped += 1; + } + + BfeeParse { + records, + skipped, + non_bfee, + } +} + +/// Decode the 20-byte bfee header + packed CSI payload of a single record. +fn parse_bfee_payload(payload: &[u8]) -> Result { + if payload.len() < BFEE_HEADER_LEN { + return Err(format!( + "payload too short: {} < {BFEE_HEADER_LEN} header bytes", + payload.len() + )); + } + + // Header slices are in-bounds by the length check above. + let timestamp_low = u32::from_le_bytes([payload[0], payload[1], payload[2], payload[3]]); + let bfee_count = u16::from_le_bytes([payload[4], payload[5]]); + // payload[6..8] reserved. + let n_rx = payload[8]; + let n_tx = payload[9]; + let rssi_a = payload[10]; + let rssi_b = payload[11]; + let rssi_c = payload[12]; + let noise = payload[13] as i8; + let agc = payload[14]; + let antenna_sel = payload[15]; + let csi_len = u16::from_le_bytes([payload[16], payload[17]]) as usize; + let rate = u16::from_le_bytes([payload[18], payload[19]]); + + if !(1..=MAX_ANTENNAS).contains(&(n_rx as usize)) { + return Err(format!("n_rx {n_rx} out of range 1..=3")); + } + if !(1..=MAX_ANTENNAS).contains(&(n_tx as usize)) { + return Err(format!("n_tx {n_tx} out of range 1..=3")); + } + let expected = packed_csi_len(n_rx as usize, n_tx as usize); + if csi_len != expected { + return Err(format!( + "csi len field {csi_len} does not match {expected} expected for \ + n_rx={n_rx}, n_tx={n_tx}" + )); + } + let body = &payload[BFEE_HEADER_LEN..]; + if body.len() < csi_len { + return Err(format!( + "packed CSI truncated: {} bytes present, {csi_len} declared", + body.len() + )); + } + let body = &body[..csi_len]; + + // Unpack: for sc { for rx { for tx { real; imag } } }, 10 bits each, + // LSB-first. A streaming bit accumulator reads each payload byte exactly + // once (instead of re-assembling a 3-byte window per component), and the + // components are written through the contiguous backing slice — the + // `[n_tx, n_rx, 30]` array is standard C order, so the destination index + // is `(tx * n_rx + rx) * 30 + sc`. + let (n_rx_u, n_tx_u) = (n_rx as usize, n_tx as usize); + let mut csi = Array3::::zeros((n_tx_u, n_rx_u, WIDAR_SUBCARRIERS)); + let flat = csi + .as_slice_mut() + .expect("freshly allocated Array3 is contiguous"); + let mut bits = BitReader::new(body); + for sc in 0..WIDAR_SUBCARRIERS { + for rx in 0..n_rx_u { + for tx in 0..n_tx_u { + let re = bits.next_i10(); + let im = bits.next_i10(); + flat[(tx * n_rx_u + rx) * WIDAR_SUBCARRIERS + sc] = + Complex32::new(re as f32, im as f32); + } + } + } + + Ok(BfeeRecord { + timestamp_low, + bfee_count, + n_rx, + n_tx, + rssi_a, + rssi_b, + rssi_c, + noise, + agc, + antenna_sel, + rate, + csi, + }) +} + +/// Streaming LSB-first bit reader over a packed CSI payload. +/// +/// Each payload byte is loaded into the accumulator exactly once; reads past +/// the slice end yield zero bits — callers bound the total bit count via the +/// `csi_len` consistency check, so that is belt-and-braces, not a format +/// feature. The accumulator never holds more than 17 bits, so `u32` cannot +/// overflow. +struct BitReader<'a> { + body: &'a [u8], + pos: usize, + acc: u32, + acc_bits: u32, +} + +impl<'a> BitReader<'a> { + fn new(body: &'a [u8]) -> Self { + BitReader { + body, + pos: 0, + acc: 0, + acc_bits: 0, + } + } + + /// Next 10-bit two's-complement integer (branchless sign extension). + #[inline] + fn next_i10(&mut self) -> i16 { + while self.acc_bits < CSI_COMPONENT_BITS as u32 { + let byte = self.body.get(self.pos).copied().unwrap_or(0); + self.pos += 1; + self.acc |= (byte as u32) << self.acc_bits; + self.acc_bits += 8; + } + let v = self.acc & 0x3FF; + self.acc >>= CSI_COMPONENT_BITS; + self.acc_bits -= CSI_COMPONENT_BITS as u32; + // Shift the 10-bit value to the top of an i32 and arithmetic-shift + // back down: sign extension without a branch. + (((v << 22) as i32) >> 22) as i16 + } +} + +/// Write a 10-bit two's-complement integer at `bit_off` into a zeroed buffer. +fn write_i10(buf: &mut [u8], bit_off: usize, value: i16) { + let v = (value as i32 & 0x3FF) as u32; + let byte = bit_off >> 3; + let shift = bit_off & 7; + let merged = v << shift; + buf[byte] |= (merged & 0xFF) as u8; + if byte + 1 < buf.len() { + buf[byte + 1] |= ((merged >> 8) & 0xFF) as u8; + } + if byte + 2 < buf.len() { + buf[byte + 2] |= ((merged >> 16) & 0xFF) as u8; + } +} + +/// Encode one framed bfee record from synthetic CSI values — the fixture +/// generator used by unit tests and benches (ADR-291: fixtures are generated +/// in code, never checked in as binary files). +/// +/// `csi` is `(real, imag)` pairs in the packing order +/// `for sc { for rx { for tx { .. } } }` and must contain exactly +/// `30 × n_rx × n_tx` entries with each component in `-512..=511`. +/// +/// # Panics +/// +/// Panics on programmer error: antenna counts outside `1..=3`, a wrong `csi` +/// length, or out-of-range components. This is a fixture builder for trusted +/// test inputs, not a boundary parser. +#[must_use] +pub fn encode_bfee_frame( + timestamp_low: u32, + bfee_count: u16, + n_rx: u8, + n_tx: u8, + csi: &[(i16, i16)], +) -> Vec { + assert!( + (1..=MAX_ANTENNAS).contains(&(n_rx as usize)), + "n_rx must be 1..=3" + ); + assert!( + (1..=MAX_ANTENNAS).contains(&(n_tx as usize)), + "n_tx must be 1..=3" + ); + let expected_pairs = WIDAR_SUBCARRIERS * n_rx as usize * n_tx as usize; + assert_eq!( + csi.len(), + expected_pairs, + "csi must contain 30 × n_rx × n_tx complex pairs" + ); + for &(re, im) in csi { + assert!( + (-512..=511).contains(&re) && (-512..=511).contains(&im), + "10-bit components must be in -512..=511" + ); + } + + let csi_len = packed_csi_len(n_rx as usize, n_tx as usize); + let mut packed = vec![0u8; csi_len]; + let mut bit_off = 0usize; + for &(re, im) in csi { + write_i10(&mut packed, bit_off, re); + bit_off += CSI_COMPONENT_BITS; + write_i10(&mut packed, bit_off, im); + bit_off += CSI_COMPONENT_BITS; + } + + let field_len = 1 + BFEE_HEADER_LEN + csi_len; // code + header + payload + let mut frame = Vec::with_capacity(2 + field_len); + frame.extend_from_slice(&(field_len as u16).to_le_bytes()); + frame.push(BFEE_CODE); + frame.extend_from_slice(×tamp_low.to_le_bytes()); + frame.extend_from_slice(&bfee_count.to_le_bytes()); + frame.extend_from_slice(&[0, 0]); // reserved + frame.push(n_rx); + frame.push(n_tx); + frame.extend_from_slice(&[33, 34, 35]); // rssi a/b/c + frame.push((-92i8) as u8); // noise + frame.push(30); // agc + frame.push(0b0000_0110); // antenna_sel + frame.extend_from_slice(&(csi_len as u16).to_le_bytes()); + frame.extend_from_slice(&0x4404u16.to_le_bytes()); // rate + frame.extend_from_slice(&packed); + frame +} + +// --------------------------------------------------------------------------- +// Widar naming convention +// --------------------------------------------------------------------------- + +/// Domain metadata parsed from a Widar3.0 `.dat` path (see the module docs +/// for the assumed naming convention). Fields the path does not encode are +/// `0`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct WidarFileMeta { + /// User (subject) id, e.g. `1` for `user1-…`. + pub user: u32, + /// Gesture type id (second dash field). + pub gesture: u32, + /// Torso location id (third dash field). + pub location: u32, + /// Face orientation id (fourth dash field). + pub orientation: u32, + /// Repetition number (fifth dash field). + pub repetition: u32, + /// Receiver id from a trailing `-r` field; `0` when absent. + pub receiver: u32, + /// Room/environment id from a `room` ancestor directory; `0` when the + /// tree does not encode one. + pub room: u32, +} + +/// Parse Widar3.0 domain metadata from a `.dat` path. Tolerant: returns +/// `None` only when the file stem yields no user id at all; any other missing +/// field parses as `0`. +#[must_use] +pub fn parse_widar_path(path: &Path) -> Option { + let stem = path.file_stem()?.to_str()?; + let mut fields = stem.split('-'); + + // First field: "user1" / "id1" / bare digits — take the numeric suffix. + let user = trailing_number(fields.next()?)?; + + let mut meta = WidarFileMeta { + user, + ..WidarFileMeta::default() + }; + + let positional: [&mut u32; 4] = [ + &mut meta.gesture, + &mut meta.location, + &mut meta.orientation, + &mut meta.repetition, + ]; + let mut pos = 0usize; + for field in fields { + let lower_r = field.len() >= 2 + && (field.starts_with('r') || field.starts_with('R')) + && field[1..].chars().all(|c| c.is_ascii_digit()); + if lower_r { + meta.receiver = field[1..].parse().unwrap_or(0); + continue; + } + if pos < positional.len() { + *positional[pos] = field.parse().unwrap_or(0); + pos += 1; + } + } + + // Room from the nearest `room` ancestor directory (case-insensitive). + for ancestor in path.ancestors().skip(1) { + if let Some(name) = ancestor.file_name().and_then(|n| n.to_str()) { + let lower = name.to_ascii_lowercase(); + if let Some(digits) = lower.strip_prefix("room") { + if let Ok(room) = digits.parse::() { + meta.room = room; + break; + } + } + } + } + + Some(meta) +} + +/// Numeric suffix of a token like `user1` → `1` (also accepts bare digits). +fn trailing_number(token: &str) -> Option { + let digits: String = token.chars().skip_while(|c| !c.is_ascii_digit()).collect(); + digits.parse().ok() +} + +// --------------------------------------------------------------------------- +// WidarDataset +// --------------------------------------------------------------------------- + +/// An indexed `.dat` recording in the Widar scan. +#[derive(Debug, Clone)] +struct WidarEntry { + path: PathBuf, + meta: WidarFileMeta, + /// Antenna dims established by the first valid record of the file. + n_tx: usize, + n_rx: usize, + /// Number of valid records with matching antenna dims. + num_frames: usize, + window_frames: usize, +} + +impl WidarEntry { + /// Number of stride-1 windows this recording contributes. + fn num_windows(&self) -> usize { + if self.num_frames < self.window_frames { + 0 + } else { + self.num_frames - self.window_frames + 1 + } + } +} + +/// Dataset adapter for Widar3.0 `.dat` recordings (ADR-291 §1). +/// +/// Scanning parses every file once at construction to count valid records; +/// [`CsiDataset::get`] re-reads the file lazily and cuts the requested +/// stride-1 window. Each `.dat` file is treated as **one continuous +/// recording** for the leakage audit ([`crate::protocols::leakage`]): its +/// [`SampleMeta::recording_id`] is the file's index in the sorted scan. +/// +/// Widar has no pose ground truth, so [`CsiSample::keypoints`] are zeros with +/// visibility `0` ("not labelled"); `subject_id` carries the user id and +/// `action_id` the gesture id. +pub struct WidarDataset { + entries: Vec, + /// Prefix-sum of window counts (length = entries.len() + 1). + cumulative: Vec, + window_frames: usize, + target_subcarriers: usize, + /// Root directory stored for display / debug purposes. + #[allow(dead_code)] + root: PathBuf, +} + +impl WidarDataset { + /// Scan `root` recursively for `.dat` recordings and build a window index. + /// + /// Unreadable, oversized, or record-free files are skipped with a + /// warning; a root with no usable recordings is an error. + /// + /// # Errors + /// + /// [`DatasetError::DataNotFound`] when `root` does not exist or yields no + /// usable recording; I/O errors for filesystem access failures. + pub fn discover( + root: &Path, + window_frames: usize, + target_subcarriers: usize, + ) -> Result { + if window_frames == 0 { + return Err(DatasetError::invalid_format( + root, + "window_frames must be > 0", + )); + } + if !root.exists() { + return Err(DatasetError::not_found( + root, + "Widar root directory not found", + )); + } + + let mut dat_paths: Vec = walkdir::WalkDir::new(root) + .into_iter() + .filter_map(|e| e.ok()) + .filter(|e| e.file_type().is_file()) + .map(|e| e.into_path()) + .filter(|p| { + p.extension() + .and_then(|e| e.to_str()) + .map(|e| e.eq_ignore_ascii_case("dat")) + .unwrap_or(false) + }) + .collect(); + dat_paths.sort(); + + let mut entries = Vec::new(); + for path in dat_paths { + match Self::scan_file(&path, window_frames) { + Ok(Some(entry)) => entries.push(entry), + Ok(None) => {} + Err(e) => warn!("Skipping {}: {e}", path.display()), + } + } + + if entries.is_empty() { + return Err(DatasetError::not_found( + root, + "no usable Widar .dat recordings found under root", + )); + } + + let mut cumulative = vec![0usize; entries.len() + 1]; + for (i, e) in entries.iter().enumerate() { + cumulative[i + 1] = cumulative[i] + e.num_windows(); + } + + info!( + "WidarDataset: scanned {} recordings, {} total windows (root={})", + entries.len(), + cumulative.last().copied().unwrap_or(0), + root.display() + ); + + Ok(WidarDataset { + entries, + cumulative, + window_frames, + target_subcarriers, + root: root.to_path_buf(), + }) + } + + /// Scan one `.dat` file: size bound, record count, antenna dims, + /// path metadata. `Ok(None)` means "valid scan, nothing usable". + fn scan_file(path: &Path, window_frames: usize) -> Result, DatasetError> { + let file_len = std::fs::metadata(path) + .map_err(|e| DatasetError::io_error(path, e))? + .len(); + if file_len > MAX_DAT_FILE_BYTES { + warn!( + "Skipping {}: {file_len} bytes exceeds the {MAX_DAT_FILE_BYTES}-byte bound", + path.display() + ); + return Ok(None); + } + + let meta = match parse_widar_path(path) { + Some(m) => m, + None => { + warn!( + "{}: file name does not follow the Widar convention; using zeroed metadata", + path.display() + ); + WidarFileMeta::default() + } + }; + + let bytes = std::fs::read(path).map_err(|e| DatasetError::io_error(path, e))?; + let parse = parse_bfee_bytes(&bytes); + if parse.skipped > 0 { + warn!( + "{}: skipped {} invalid record(s) ({} valid)", + path.display(), + parse.skipped, + parse.records.len() + ); + } + let Some(first) = parse.records.first() else { + warn!("Skipping {}: no valid bfee records", path.display()); + return Ok(None); + }; + let (n_tx, n_rx) = (first.n_tx as usize, first.n_rx as usize); + let num_frames = parse + .records + .iter() + .filter(|r| r.n_tx as usize == n_tx && r.n_rx as usize == n_rx) + .count(); + if num_frames < parse.records.len() { + warn!( + "{}: dropped {} record(s) with antenna dims differing from the first \ + ({n_tx}×{n_rx})", + path.display(), + parse.records.len() - num_frames + ); + } + if num_frames < window_frames { + debug!( + "{}: {} frame(s) < window {window_frames}; contributes no windows", + path.display(), + num_frames + ); + } + Ok(Some(WidarEntry { + path: path.to_path_buf(), + meta, + n_tx, + n_rx, + num_frames, + window_frames, + })) + } + + /// Resolve a global window index to `(entry_index, frame_offset)`. + fn locate(&self, idx: usize) -> Option<(usize, usize)> { + let total = self.cumulative.last().copied().unwrap_or(0); + if idx >= total { + return None; + } + let entry_idx = self + .cumulative + .partition_point(|&c| c <= idx) + .saturating_sub(1); + Some((entry_idx, idx - self.cumulative[entry_idx])) + } + + /// Split-protocol metadata for the window at `idx` (ADR-291 §2): user → + /// subject, room → environment, plus orientation/gesture, and the owning + /// `.dat` file as the continuous `recording_id`. + /// + /// # Errors + /// + /// [`DatasetError::IndexOutOfBounds`] when `idx >= self.len()`. + pub fn sample_meta(&self, idx: usize) -> Result { + let (entry_idx, offset) = self.locate(idx).ok_or(DatasetError::IndexOutOfBounds { + idx, + len: self.cumulative.last().copied().unwrap_or(0), + })?; + let m = &self.entries[entry_idx].meta; + Ok(SampleMeta { + subject_id: m.user, + environment_id: m.room, + orientation_id: m.orientation, + gesture_id: m.gesture, + recording_id: entry_idx as u64, + window_index: offset as u64, + }) + } + + /// [`SampleMeta`] for every window, in index order — the input to + /// [`crate::protocols::SplitPlan::partition`]. + pub fn sample_metas(&self) -> Vec { + (0..self.len()) + .map(|i| { + self.sample_meta(i) + .expect("index < len is always locatable") + }) + .collect() + } + + /// Number of `.dat` recordings behind this dataset. + #[must_use] + pub fn num_recordings(&self) -> usize { + self.entries.len() + } +} + +impl CsiDataset for WidarDataset { + fn len(&self) -> usize { + self.cumulative.last().copied().unwrap_or(0) + } + + fn get(&self, idx: usize) -> Result { + let total = self.len(); + let (entry_idx, offset) = self + .locate(idx) + .ok_or(DatasetError::IndexOutOfBounds { idx, len: total })?; + let entry = &self.entries[entry_idx]; + + let bytes = + std::fs::read(&entry.path).map_err(|e| DatasetError::io_error(&entry.path, e))?; + let parse = parse_bfee_bytes(&bytes); + let records: Vec<&BfeeRecord> = parse + .records + .iter() + .filter(|r| r.n_tx as usize == entry.n_tx && r.n_rx as usize == entry.n_rx) + .collect(); + + let t_end = offset + self.window_frames; + if t_end > records.len() { + // The file changed on disk since discovery. + return Err(DatasetError::invalid_format( + &entry.path, + format!( + "window [{offset}, {t_end}) exceeds {} valid frame(s); \ + file changed since scan?", + records.len() + ), + )); + } + + let (n_tx, n_rx) = (entry.n_tx, entry.n_rx); + let mut amplitude = + Array4::::zeros((self.window_frames, n_tx, n_rx, WIDAR_SUBCARRIERS)); + let mut phase = Array4::::zeros((self.window_frames, n_tx, n_rx, WIDAR_SUBCARRIERS)); + for (t, record) in records[offset..t_end].iter().enumerate() { + for tx in 0..n_tx { + for rx in 0..n_rx { + for sc in 0..WIDAR_SUBCARRIERS { + let c = record.csi[[tx, rx, sc]]; + amplitude[[t, tx, rx, sc]] = c.norm(); + phase[[t, tx, rx, sc]] = c.arg(); + } + } + } + } + + let amplitude = if WIDAR_SUBCARRIERS != self.target_subcarriers { + interpolate_subcarriers(&litude, self.target_subcarriers) + } else { + amplitude + }; + let phase = if WIDAR_SUBCARRIERS != self.target_subcarriers { + interpolate_subcarriers(&phase, self.target_subcarriers) + } else { + phase + }; + + Ok(CsiSample { + amplitude, + phase, + keypoints: Array2::zeros((NUM_KEYPOINTS, 2)), + keypoint_visibility: Array1::zeros(NUM_KEYPOINTS), + subject_id: entry.meta.user, + action_id: entry.meta.gesture, + frame_id: offset as u64, + }) + } + + fn name(&self) -> &str { + "WidarDataset" + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use approx::assert_abs_diff_eq; + + /// Deterministic synthetic CSI pattern for record `t`: values derived + /// from the pair index, folded into the 10-bit range. + fn synthetic_csi(t: usize, n_rx: usize, n_tx: usize) -> Vec<(i16, i16)> { + (0..WIDAR_SUBCARRIERS * n_rx * n_tx) + .map(|i| { + let re = ((t * 37 + i * 13) % 1024) as i16 - 512; + let im = ((t * 17 + i * 7) % 1024) as i16 - 512; + (re, im) + }) + .collect() + } + + fn synthetic_file(num_records: usize, n_rx: u8, n_tx: u8) -> Vec { + let mut bytes = Vec::new(); + for t in 0..num_records { + let csi = synthetic_csi(t, n_rx as usize, n_tx as usize); + bytes.extend_from_slice(&encode_bfee_frame( + 1000 + t as u32, + t as u16, + n_rx, + n_tx, + &csi, + )); + } + bytes + } + + // ----- parser: valid fixtures ------------------------------------------ + + #[test] + fn parse_roundtrips_valid_records() { + let bytes = synthetic_file(5, 3, 2); + let parse = parse_bfee_bytes(&bytes); + assert_eq!(parse.records.len(), 5); + assert_eq!(parse.skipped, 0); + assert_eq!(parse.non_bfee, 0); + + let r = &parse.records[2]; + assert_eq!(r.timestamp_low, 1002); + assert_eq!(r.bfee_count, 2); + assert_eq!(r.n_rx, 3); + assert_eq!(r.n_tx, 2); + assert_eq!(r.noise, -92); + assert_eq!(r.rate, 0x4404); + assert_eq!(r.csi.shape(), &[2, 3, WIDAR_SUBCARRIERS]); + + // Bit-exact roundtrip of every component, including negatives. + let csi = synthetic_csi(2, 3, 2); + let mut i = 0usize; + for sc in 0..WIDAR_SUBCARRIERS { + for rx in 0..3 { + for tx in 0..2 { + let (re, im) = csi[i]; + assert_abs_diff_eq!(r.csi[[tx, rx, sc]].re, re as f32, epsilon = 0.0); + assert_abs_diff_eq!(r.csi[[tx, rx, sc]].im, im as f32, epsilon = 0.0); + i += 1; + } + } + } + } + + #[test] + fn parse_sign_extends_extremes() { + let n = WIDAR_SUBCARRIERS; + let mut csi = vec![(0i16, 0i16); n]; + csi[0] = (-512, 511); + csi[n - 1] = (-1, 1); + let bytes = encode_bfee_frame(7, 1, 1, 1, &csi); + let parse = parse_bfee_bytes(&bytes); + assert_eq!(parse.records.len(), 1); + let r = &parse.records[0]; + assert_eq!(r.csi[[0, 0, 0]], Complex32::new(-512.0, 511.0)); + assert_eq!(r.csi[[0, 0, n - 1]], Complex32::new(-1.0, 1.0)); + } + + #[test] + fn parse_is_deterministic() { + let bytes = synthetic_file(3, 2, 2); + let a = parse_bfee_bytes(&bytes); + let b = parse_bfee_bytes(&bytes); + assert_eq!(a.records.len(), b.records.len()); + for (ra, rb) in a.records.iter().zip(&b.records) { + assert_eq!(ra.csi, rb.csi); + } + } + + // ----- parser: truncated / corrupt fixtures ---------------------------- + + #[test] + fn parse_empty_buffer_is_empty() { + let parse = parse_bfee_bytes(&[]); + assert!(parse.records.is_empty()); + assert_eq!(parse.skipped, 0); + } + + #[test] + fn parse_truncated_record_is_skipped_not_panic() { + let mut bytes = synthetic_file(2, 2, 1); + // Chop the last record mid-payload. + let cut = bytes.len() - 10; + bytes.truncate(cut); + let parse = parse_bfee_bytes(&bytes); + assert_eq!(parse.records.len(), 1); + assert_eq!(parse.skipped, 1); + } + + #[test] + fn parse_dangling_header_bytes_counted() { + let mut bytes = synthetic_file(1, 1, 1); + bytes.extend_from_slice(&[0x07, 0x00]); // 2 dangling bytes < frame header + let parse = parse_bfee_bytes(&bytes); + assert_eq!(parse.records.len(), 1); + assert_eq!(parse.skipped, 1); + } + + #[test] + fn parse_zero_field_len_resyncs() { + let mut bytes = vec![0u8, 0u8, 0xBB]; // zero-length frame + bytes.extend_from_slice(&synthetic_file(1, 1, 1)); + let parse = parse_bfee_bytes(&bytes); + assert_eq!(parse.records.len(), 1); + assert_eq!(parse.skipped, 1); + } + + #[test] + fn parse_oversized_field_len_stops_bounded() { + let mut bytes = Vec::new(); + bytes.extend_from_slice(&u16::MAX.to_le_bytes()); + bytes.push(BFEE_CODE); + bytes.extend_from_slice(&vec![0u8; 64]); + let parse = parse_bfee_bytes(&bytes); + assert!(parse.records.is_empty()); + assert_eq!(parse.skipped, 1); + } + + #[test] + fn parse_non_bfee_code_is_ignored() { + let mut bytes = Vec::new(); + // A well-framed record with a different code. + bytes.extend_from_slice(&4u16.to_le_bytes()); + bytes.push(0xC1); + bytes.extend_from_slice(&[1, 2, 3]); + bytes.extend_from_slice(&synthetic_file(1, 1, 1)); + let parse = parse_bfee_bytes(&bytes); + assert_eq!(parse.records.len(), 1); + assert_eq!(parse.non_bfee, 1); + assert_eq!(parse.skipped, 0); + } + + #[test] + fn parse_corrupt_antenna_count_is_skipped() { + let mut bytes = synthetic_file(2, 2, 2); + // First frame: corrupt n_rx (payload byte 8 → frame offset 3 + 8). + bytes[3 + 8] = 9; + let parse = parse_bfee_bytes(&bytes); + assert_eq!(parse.records.len(), 1); + assert_eq!(parse.skipped, 1); + } + + #[test] + fn parse_len_field_mismatch_is_skipped() { + let mut bytes = synthetic_file(1, 1, 1); + // Corrupt the csi len field (payload bytes 16..18 → frame offset 19). + bytes[3 + 16] = 0xFF; + let parse = parse_bfee_bytes(&bytes); + assert!(parse.records.is_empty()); + assert_eq!(parse.skipped, 1); + } + + #[test] + fn packed_len_matches_formula() { + // 30 × n_rx × n_tx × 2 comps × 10 bits, ceil to bytes. + assert_eq!(packed_csi_len(1, 1), 75); + assert_eq!(packed_csi_len(3, 1), 225); + assert_eq!(packed_csi_len(3, 3), 675); + } + + // ----- naming convention ----------------------------------------------- + + #[test] + fn parses_full_widar_name() { + let m = parse_widar_path(Path::new("/data/room2/20181130/user1/user1-3-1-4-2-r5.dat")) + .unwrap(); + assert_eq!( + m, + WidarFileMeta { + user: 1, + gesture: 3, + location: 1, + orientation: 4, + repetition: 2, + receiver: 5, + room: 2, + } + ); + } + + #[test] + fn parses_name_without_receiver_or_room() { + let m = parse_widar_path(Path::new("user12/user12-6-2-3-1.dat")).unwrap(); + assert_eq!(m.user, 12); + assert_eq!(m.gesture, 6); + assert_eq!(m.orientation, 3); + assert_eq!(m.receiver, 0); + assert_eq!(m.room, 0); + } + + #[test] + fn tolerates_short_names() { + let m = parse_widar_path(Path::new("user3-2.dat")).unwrap(); + assert_eq!(m.user, 3); + assert_eq!(m.gesture, 2); + assert_eq!(m.orientation, 0); + assert!(parse_widar_path(Path::new("nodigits.dat")).is_none()); + } + + // ----- WidarDataset end-to-end on synthetic files ---------------------- + + fn write_synthetic_tree(root: &Path) { + // Two users, one recording each, in room1/room2. + for (user, room) in [(1u32, 1u32), (2, 2)] { + let dir = root.join(format!("room{room}")).join(format!("user{user}")); + std::fs::create_dir_all(&dir).unwrap(); + let file = dir.join(format!("user{user}-1-1-{user}-1-r1.dat")); + std::fs::write(&file, synthetic_file(6, 2, 1)).unwrap(); + } + } + + #[test] + fn widar_dataset_discovers_and_windows() { + let tmp = tempfile::tempdir().unwrap(); + write_synthetic_tree(tmp.path()); + + let ds = WidarDataset::discover(tmp.path(), 4, 56).unwrap(); + assert_eq!(ds.num_recordings(), 2); + // 6 frames, window 4 ⇒ 3 windows per recording. + assert_eq!(ds.len(), 6); + + let s = ds.get(0).unwrap(); + assert_eq!(s.amplitude.shape(), &[4, 1, 2, 56]); + assert_eq!(s.phase.shape(), &[4, 1, 2, 56]); + assert_eq!(s.keypoints.shape(), &[17, 2]); + assert_eq!(s.subject_id, 1); + assert_eq!(s.action_id, 1); + + // Second recording's windows carry the second user's metadata. + let s2 = ds.get(3).unwrap(); + assert_eq!(s2.subject_id, 2); + assert_eq!(s2.frame_id, 0); + + // Out of bounds is an error, not a panic. + assert!(matches!( + ds.get(6), + Err(DatasetError::IndexOutOfBounds { idx: 6, len: 6 }) + )); + } + + #[test] + fn widar_dataset_native_subcarriers_skip_interpolation() { + let tmp = tempfile::tempdir().unwrap(); + write_synthetic_tree(tmp.path()); + let ds = WidarDataset::discover(tmp.path(), 4, WIDAR_SUBCARRIERS).unwrap(); + let s = ds.get(0).unwrap(); + assert_eq!(s.amplitude.shape(), &[4, 1, 2, WIDAR_SUBCARRIERS]); + // Amplitude of the first component must equal |re + j·im| of the fixture. + let csi = synthetic_csi(0, 2, 1); + let (re, im) = csi[0]; + let expected = ((re as f32).powi(2) + (im as f32).powi(2)).sqrt(); + assert_abs_diff_eq!(s.amplitude[[0, 0, 0, 0]], expected, epsilon = 1e-4); + } + + #[test] + fn widar_sample_meta_maps_domains() { + let tmp = tempfile::tempdir().unwrap(); + write_synthetic_tree(tmp.path()); + let ds = WidarDataset::discover(tmp.path(), 4, 56).unwrap(); + + let metas = ds.sample_metas(); + assert_eq!(metas.len(), ds.len()); + // Windows 0..3 belong to recording 0 (user1, room1, orientation 1). + assert_eq!(metas[0].subject_id, 1); + assert_eq!(metas[0].environment_id, 1); + assert_eq!(metas[0].orientation_id, 1); + assert_eq!(metas[0].recording_id, 0); + assert_eq!(metas[2].window_index, 2); + // Windows 3..6 belong to recording 1 (user2, room2, orientation 2). + assert_eq!(metas[3].subject_id, 2); + assert_eq!(metas[3].environment_id, 2); + assert_eq!(metas[3].orientation_id, 2); + assert_eq!(metas[3].recording_id, 1); + + assert!(ds.sample_meta(999).is_err()); + } + + #[test] + fn widar_dataset_skips_corrupt_file_keeps_valid() { + let tmp = tempfile::tempdir().unwrap(); + write_synthetic_tree(tmp.path()); + // A garbage .dat file must not abort discovery. + std::fs::write(tmp.path().join("user9-1-1-1-1.dat"), [0xFFu8; 64]).unwrap(); + let ds = WidarDataset::discover(tmp.path(), 4, 56).unwrap(); + assert_eq!(ds.num_recordings(), 2); + } + + #[test] + fn widar_dataset_missing_root_errors() { + assert!(matches!( + WidarDataset::discover(Path::new("/nonexistent/widar"), 4, 56), + Err(DatasetError::DataNotFound { .. }) + )); + } +} diff --git a/v2/crates/wifi-densepose-train/src/error.rs b/v2/crates/wifi-densepose-train/src/error.rs index 2a4f824c..04c758b4 100644 --- a/v2/crates/wifi-densepose-train/src/error.rs +++ b/v2/crates/wifi-densepose-train/src/error.rs @@ -12,7 +12,8 @@ //! ├── ConfigError (config validation / file loading) //! ├── DatasetError (data loading, I/O, format) //! ├── SubcarrierError (frequency-axis resampling) -//! └── MaeError (MAE patchify / masking — ADR-152 §2.3) +//! ├── MaeError (MAE patchify / masking — ADR-152 §2.3) +//! └── ProtocolError (split protocols / leakage audit — ADR-291) //! ``` use std::path::PathBuf; @@ -49,6 +50,10 @@ pub enum TrainError { #[error("MAE pretraining error: {0}")] Mae(#[from] MaeError), + /// A split-protocol / leakage-audit error (ADR-291). + #[error("Protocol error: {0}")] + Protocol(#[from] ProtocolError), + /// JSON (de)serialization error. #[error("JSON error: {0}")] Json(#[from] serde_json::Error), @@ -466,3 +471,98 @@ pub enum MaeError { value: f32, }, } + +// --------------------------------------------------------------------------- +// ProtocolError +// --------------------------------------------------------------------------- + +/// Errors produced by the public-benchmark split protocols and leakage guards +/// ([`crate::protocols`], ADR-291). +/// +/// Every leakage-audit failure is an `Err`, never a warning: a split that +/// leaks subjects, environments, or windows of a continuous recording across +/// the train/test boundary must not be usable for reporting. +#[derive(Debug, Error)] +pub enum ProtocolError { + /// The requested held-out fraction is not a finite value strictly inside + /// `(0, 1)`. + #[error("Invalid test fraction {value}: must be finite and strictly inside (0, 1)")] + InvalidTestFraction { + /// The offending fraction. + value: f64, + }, + + /// A split side contains no samples — a degenerate split cannot support + /// any claim. + #[error("The {side} partition is empty")] + EmptyPartition { + /// Which side is empty (`"train"` or `"test"`). + side: &'static str, + }, + + /// A subject appears on both sides of a split that claims + /// subject-disjointness. + #[error("Subject {subject_id} appears in both train and test (subject leakage)")] + SubjectOverlap { + /// The leaked subject id. + subject_id: u32, + }, + + /// An environment/room appears on both sides of a split that claims + /// environment-disjointness. + #[error("Environment {environment_id} appears in both train and test (environment leakage)")] + EnvironmentOverlap { + /// The leaked environment id. + environment_id: u32, + }, + + /// An orientation appears on both sides of a split that claims + /// orientation-disjointness. + #[error("Orientation {orientation_id} appears in both train and test (orientation leakage)")] + OrientationOverlap { + /// The leaked orientation id. + orientation_id: u32, + }, + + /// Two windows cut from the same continuous recording ended up on + /// opposite sides of the split. Overlapping/adjacent windows are + /// near-identical, so this is window-level leakage regardless of the + /// protocol (the 2024–2025 leakage reckoning; ADR-291 §Context). + #[error( + "Recording {recording_id} has windows on both sides of the split \ + (window-level leakage from a continuous recording)" + )] + RecordingCrossesSplit { + /// The recording whose windows straddle the boundary. + recording_id: u64, + }, + + /// The mean-pose baseline cannot be fitted because the training split + /// contributed no poses. + #[error("Cannot fit mean-pose baseline: the training split contains no poses")] + EmptyTrainingPoses, + + /// A pose array has a different shape from the first pose seen. + #[error("Pose shape mismatch: expected {expected:?}, got {actual:?}")] + PoseShapeMismatch { + /// Shape established by the first pose. + expected: Vec, + /// Offending shape. + actual: Vec, + }, + + /// A `MEASURED` evidence grade was requested without a reproducer + /// command. CLAUDE.md: accuracy statements tagged `MEASURED` require a + /// reproducer; anything else must be `SYNTHETIC` or `CLAIMED`. + #[error("MEASURED evidence requires a non-empty reproducer command string")] + MissingReproducer, + + /// A reported metric is NaN or ±inf. + #[error("Metric `{name}` is not finite: {value}")] + NonFiniteMetric { + /// Name of the offending metric. + name: String, + /// The non-finite value. + value: f64, + }, +} diff --git a/v2/crates/wifi-densepose-train/src/lib.rs b/v2/crates/wifi-densepose-train/src/lib.rs index 31745f85..50897945 100644 --- a/v2/crates/wifi-densepose-train/src/lib.rs +++ b/v2/crates/wifi-densepose-train/src/lib.rs @@ -59,6 +59,16 @@ pub mod mae; /// `oks_canonical`, available **without** the `tch-backend` feature so the /// single metric definition is reachable from the workspace test gate. pub mod metrics_core; +/// Model release sanity gates (ADR-298) — block degenerate and mislabeled +/// classifier artifacts (unreachable decision boundary, constant output, +/// degenerate class balance, missing baseline, metric-name provenance) before +/// release. Prevention only; withdraws nothing already published. +pub mod model_gates; +/// Public-benchmark split protocols and leakage guards (ADR-291 §2–3) — +/// deterministic cross-subject / cross-environment / cross-orientation +/// assignment plus the structural [`protocols::leakage::LeakageAudit`], +/// mean-pose baseline, and evidence-graded evaluation reports. +pub mod protocols; pub mod rapid_adapt; pub mod ruview_metrics; pub mod signal_features; @@ -103,7 +113,21 @@ pub use config::TrainingConfig; pub use dataset::{ CsiDataset, CsiSample, DataLoader, MmFiDataset, SyntheticConfig, SyntheticCsiDataset, }; -pub use error::{ConfigError, DatasetError, MaeError, SubcarrierError, TrainError}; +// ADR-291 — Widar3.0 ingest, split protocols, and leakage guards. +pub use dataset::widar::{parse_bfee_bytes, BfeeParse, BfeeRecord, WidarDataset, WidarFileMeta}; +pub use protocols::leakage::{ + EvaluationReport, EvidenceGrade, LeakageAudit, LeakageClaims, MeanPoseBaseline, +}; +pub use protocols::{SampleMeta, SplitPlan, SplitProtocol, SplitSide}; + +// ADR-298 — model release sanity gates. +pub use model_gates::{ + check_baseline, check_class_balance, check_constant_output, check_metric_provenance, + check_unreachable_boundary, evaluate_linear_head, GateError, GateFailure, GateOutcomeError, + LabeledMetric, LinearHead, MetricKind, ModelGateReport, ProbeSet, +}; + +pub use error::{ConfigError, DatasetError, MaeError, ProtocolError, SubcarrierError, TrainError}; // TrainResult is the generic Result alias from error.rs; the concrete // TrainResult struct from trainer.rs is accessed via trainer::TrainResult. pub use error::TrainResult as TrainResultAlias; diff --git a/v2/crates/wifi-densepose-train/src/model_gates.rs b/v2/crates/wifi-densepose-train/src/model_gates.rs new file mode 100644 index 00000000..2fd34e93 --- /dev/null +++ b/v2/crates/wifi-densepose-train/src/model_gates.rs @@ -0,0 +1,987 @@ +//! Model release sanity gates (ADR-298) — block degenerate and mislabeled +//! classifier artifacts before they can ship. +//! +//! # Why this module exists +//! +//! The external review (corroborating issue 1521) showed the published presence +//! head is mathematically degenerate: with L2-normalized embeddings, a weight +//! norm `‖w‖ ≈ 3.67` against a bias `≈ 8.19` makes the *smallest possible* +//! logit positive (`8.19 − 3.67 = 4.52 > 0`), 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 caught any of it. +//! +//! This module adds structural machine checks for those failure shapes: +//! +//! - [`check_unreachable_boundary`] — for a normalized-embedding linear head, +//! fail when `‖bias‖` dominates `‖weight‖` so the logit cannot change sign. +//! - [`check_constant_output`] — fail when the head's output variance across a +//! diverse, L2-normalized probe set is below a threshold. +//! - [`check_class_balance`] — fail when the predicted-positive rate on a +//! balanced probe set sits at/above a ceiling (e.g. `> 99 %`). +//! - [`check_baseline`] — fail a report with no paired mean-pose/majority +//! baseline (ties into the ADR-291 [`EvaluationReport`]). +//! - [`check_metric_provenance`] — a metric carries its computed +//! [`MetricKind`] and its display label derives from it, so a temporal-triplet +//! metric can never be surfaced under the `presence` task name. +//! +//! Each gate emits a structured, human-readable [`GateFailure`] naming the +//! defect and the offending numbers. +//! +//! # This is prevention, not a correctness proof +//! +//! The gates are heuristic. They catch the *known* failure shapes above, not +//! all bad models (ADR-298 §Consequences). This module does **not** withdraw +//! any already-published artifact — an outward-facing action requiring +//! maintainer sign-off — it prevents recurrence. +//! +//! All checks are deterministic: probe sets are generated in code from a linear +//! head with no RNG state, and the sweep exercises the full reachable logit +//! range so the constant-output verdict is dimension-robust (random unit +//! vectors concentrate near-orthogonal to the weight in high dimensions and +//! would hide a live boundary). + +use thiserror::Error; + +use crate::protocols::leakage::EvaluationReport; + +// --------------------------------------------------------------------------- +// Tunable thresholds (documented defaults; every gate also takes an explicit +// argument so a workflow can tighten them). +// --------------------------------------------------------------------------- + +/// Default population-variance floor for [`check_constant_output`]. Below this, +/// the head's probability output is treated as effectively constant. The +/// issue-1521 head sits far under it (all logits in `[4.52, 11.86]` ⇒ sigmoid in +/// `[0.989, 1.0)`); a head that sweeps `[-‖w‖, ‖w‖]` around a reachable boundary +/// sits far above it. +pub const DEFAULT_MIN_OUTPUT_VARIANCE: f64 = 1e-4; + +/// Default predicted-positive-rate ceiling for [`check_class_balance`]. A head +/// that predicts positive for `> 99 %` of a balanced probe is degenerate. +pub const DEFAULT_MAX_POSITIVE_RATE: f64 = 0.99; + +/// Default number of probe points swept across the reachable logit range. +pub const DEFAULT_PROBE_POINTS: usize = 64; + +// --------------------------------------------------------------------------- +// GateError — malformed input at the construction boundary (never a panic). +// --------------------------------------------------------------------------- + +/// Errors from constructing a gate input from untrusted numbers. +/// +/// These are *malformed artifact* errors (empty weight vector, non-finite +/// parameters), distinct from a [`GateFailure`] which is a well-formed head +/// that a gate rejected. +#[derive(Debug, Error, PartialEq)] +pub enum GateError { + /// The weight vector was empty; a linear head needs at least one dimension. + #[error("linear head weight vector is empty")] + EmptyWeight, + + /// A weight or bias value was NaN or ±inf; corrupted parameters must be + /// rejected upstream, never scored. + #[error("non-finite parameter `{what}`: {value}")] + NonFiniteParameter { + /// Which parameter (`"weight[i]"` or `"bias"`). + what: String, + /// The offending value. + value: f64, + }, + + /// A probe set was empty. + #[error("probe set is empty")] + EmptyProbe, + + /// A probe embedding length did not match the head dimension. + #[error("probe embedding has dimension {actual}, expected {expected}")] + ProbeDimMismatch { + /// Head dimension. + expected: usize, + /// Probe embedding length. + actual: usize, + }, + + /// A metric value was NaN or ±inf. + #[error("metric value is not finite: {0}")] + NonFiniteMetric(f64), +} + +// --------------------------------------------------------------------------- +// GateFailure — a well-formed head/report that a gate rejected. +// --------------------------------------------------------------------------- + +/// A structured, human-readable gate failure carrying the offending numbers. +#[derive(Debug, Error, Clone, PartialEq)] +pub enum GateFailure { + /// The decision boundary (`logit = 0`) is analytically unreachable: with + /// L2-normalized embeddings the logit is confined to + /// `[bias − ‖w‖, bias + ‖w‖]`, and this interval does not straddle zero. + #[error( + "unreachable decision boundary: L2-normalized logit is confined to \ + [{min_logit:.4}, {max_logit:.4}] (bias {bias:.4}, ‖weight‖ {weight_norm:.4}); \ + it never crosses 0, so the classifier is effectively constant" + )] + UnreachableBoundary { + /// `‖weight‖₂`. + weight_norm: f64, + /// Head bias. + bias: f64, + /// Minimum achievable logit (`bias − ‖w‖`). + min_logit: f64, + /// Maximum achievable logit (`bias + ‖w‖`). + max_logit: f64, + }, + + /// The output variance across the probe set is below the threshold — the + /// head is effectively constant. + #[error( + "constant output: probability variance {variance:.3e} across {probe_size} \ + probes is below threshold {threshold:.3e} (outputs in [{min_output:.4}, \ + {max_output:.4}])" + )] + ConstantOutput { + /// Population variance of the probability outputs. + variance: f64, + /// Variance floor that was violated. + threshold: f64, + /// Minimum probability output over the probe set. + min_output: f64, + /// Maximum probability output over the probe set. + max_output: f64, + /// Number of probes evaluated. + probe_size: usize, + }, + + /// The predicted-positive rate on a balanced probe is at/above the ceiling. + #[error( + "degenerate class balance: predicted-positive rate {positive_rate:.4} on \ + {probe_size} balanced probes is at/above ceiling {ceiling:.4}" + )] + ClassBalance { + /// Fraction of probes classified positive (`logit ≥ 0`). + positive_rate: f64, + /// Ceiling that was violated. + ceiling: f64, + /// Number of probes evaluated. + probe_size: usize, + }, + + /// A report was surfaced without a paired baseline (ADR-291). + #[error("missing baseline: {reason}")] + MissingBaseline { + /// Why the baseline is considered missing/blank. + reason: String, + }, + + /// A metric computed under one kind was surfaced under another task name. + #[error( + "metric-name provenance mismatch: metric computed as `{computed_kind}` \ + ({computed_label}) may not be surfaced as `{surfaced_kind}` \ + ({surfaced_label})" + )] + MetricProvenance { + /// Kind the metric was actually computed under. + computed_kind: &'static str, + /// Display label of the computed kind. + computed_label: &'static str, + /// Kind the metric was being surfaced under. + surfaced_kind: &'static str, + /// Display label of the surfaced kind. + surfaced_label: &'static str, + }, +} + +// --------------------------------------------------------------------------- +// LinearHead +// --------------------------------------------------------------------------- + +/// A single-logit linear classification head `logit(x) = weight · x + bias`, +/// the shape of the published presence head. Kept parameter-only (no `tch`) so +/// the gates run on the workspace test gate without libtorch. +#[derive(Debug, Clone, PartialEq)] +pub struct LinearHead { + weight: Vec, + bias: f32, +} + +impl LinearHead { + /// Build a head from raw parameters, validating them at the boundary. + /// + /// # Errors + /// + /// - [`GateError::EmptyWeight`] when `weight` is empty. + /// - [`GateError::NonFiniteParameter`] when any weight or the bias is + /// NaN/±inf. + pub fn new(weight: Vec, bias: f32) -> Result { + if weight.is_empty() { + return Err(GateError::EmptyWeight); + } + for (i, &w) in weight.iter().enumerate() { + if !w.is_finite() { + return Err(GateError::NonFiniteParameter { + what: format!("weight[{i}]"), + value: w as f64, + }); + } + } + if !bias.is_finite() { + return Err(GateError::NonFiniteParameter { + what: "bias".to_string(), + value: bias as f64, + }); + } + Ok(LinearHead { weight, bias }) + } + + /// Embedding dimension. + #[must_use] + pub fn dim(&self) -> usize { + self.weight.len() + } + + /// Head bias as `f64`. + #[must_use] + pub fn bias(&self) -> f64 { + self.bias as f64 + } + + /// `‖weight‖₂`. + #[must_use] + pub fn weight_norm(&self) -> f64 { + self.weight + .iter() + .map(|&w| (w as f64) * (w as f64)) + .sum::() + .sqrt() + } + + /// Minimum achievable logit for a unit-norm embedding (`bias − ‖w‖`). + #[must_use] + pub fn min_logit_normalized(&self) -> f64 { + self.bias() - self.weight_norm() + } + + /// Maximum achievable logit for a unit-norm embedding (`bias + ‖w‖`). + #[must_use] + pub fn max_logit_normalized(&self) -> f64 { + self.bias() + self.weight_norm() + } + + /// Compute the logit `weight · embedding + bias`. + /// + /// # Errors + /// + /// [`GateError::ProbeDimMismatch`] when `embedding.len() != self.dim()`. + pub fn logit(&self, embedding: &[f32]) -> Result { + if embedding.len() != self.dim() { + return Err(GateError::ProbeDimMismatch { + expected: self.dim(), + actual: embedding.len(), + }); + } + let dot: f64 = self + .weight + .iter() + .zip(embedding) + .map(|(&w, &x)| (w as f64) * (x as f64)) + .sum(); + Ok(dot + self.bias()) + } + + /// Presence probability `σ(logit)`, numerically stable. + /// + /// # Errors + /// + /// [`GateError::ProbeDimMismatch`] when `embedding.len() != self.dim()`. + pub fn presence_prob(&self, embedding: &[f32]) -> Result { + Ok(sigmoid(self.logit(embedding)?)) + } +} + +/// Numerically stable logistic sigmoid. +fn sigmoid(x: f64) -> f64 { + if x >= 0.0 { + 1.0 / (1.0 + (-x).exp()) + } else { + let e = x.exp(); + e / (1.0 + e) + } +} + +// --------------------------------------------------------------------------- +// ProbeSet +// --------------------------------------------------------------------------- + +/// A deterministic set of L2-normalized embeddings used to probe a head. +/// +/// [`ProbeSet::sweep_for_head`] sweeps the embedding whose projection onto the +/// weight direction ranges over `[-1, 1]`, so the logit ranges over the full +/// reachable interval `[bias − ‖w‖, bias + ‖w‖]`. This is the degenerate +/// normalized-embedding probe from issue 1521: every embedding is unit norm, +/// and the sweep is exactly what exposes an unreachable boundary or a constant +/// output, independent of embedding dimension. +#[derive(Debug, Clone, PartialEq)] +pub struct ProbeSet { + embeddings: Vec>, +} + +impl ProbeSet { + /// Build a probe set from explicit embeddings, validating shape/finiteness. + /// + /// # Errors + /// + /// - [`GateError::EmptyProbe`] when `embeddings` is empty. + /// - [`GateError::NonFiniteParameter`] when any coordinate is NaN/±inf. + pub fn from_embeddings(embeddings: Vec>) -> Result { + if embeddings.is_empty() { + return Err(GateError::EmptyProbe); + } + for row in &embeddings { + if row.is_empty() { + return Err(GateError::EmptyProbe); + } + for (i, &v) in row.iter().enumerate() { + if !v.is_finite() { + return Err(GateError::NonFiniteParameter { + what: format!("probe[{i}]"), + value: v as f64, + }); + } + } + } + Ok(ProbeSet { embeddings }) + } + + /// Sweep the reachable logit range with `num` L2-normalized embeddings. + /// + /// Each embedding is `x = c·û_w + √(1−c²)·û⊥` for a cosine `c` linearly + /// spaced over `[-1, 1]`, where `û_w` is the weight direction and `û⊥` is a + /// fixed unit vector orthogonal to it — so `‖x‖ = 1` and the projection + /// onto `w` is exactly `c·‖w‖`. `num` is clamped to at least 2. When the + /// weight norm is ~0 (a genuinely constant head) the sweep falls back to + /// signed basis vectors, which still expose the constant output. + #[must_use] + pub fn sweep_for_head(head: &LinearHead, num: usize) -> Self { + let num = num.max(2); + let dim = head.dim(); + let norm = head.weight_norm(); + + // Degenerate weight: no direction to sweep. Use signed basis vectors; + // the head is constant regardless of input, which the gate will catch. + if norm < 1e-12 { + let mut embeddings = Vec::with_capacity(num); + for k in 0..num { + let mut e = vec![0.0f32; dim]; + let idx = k % dim; + e[idx] = if k % 2 == 0 { 1.0 } else { -1.0 }; + embeddings.push(e); + } + return ProbeSet { embeddings }; + } + + let u_w: Vec = head.weight.iter().map(|&w| (w as f64) / norm).collect(); + + // dim == 1: the only unit embeddings are ±1. + if dim == 1 { + return ProbeSet { + embeddings: vec![vec![-1.0f32], vec![1.0f32]], + }; + } + + // Pick the axis least aligned with the weight for a stable orthogonal + // direction; project it off u_w and normalize. + let k = argmin_abs(&u_w); + let dot_k = u_w[k]; + let perp_norm = (1.0 - dot_k * dot_k).sqrt(); + let u_perp: Vec = (0..dim) + .map(|i| { + let e_i = if i == k { 1.0 } else { 0.0 }; + (e_i - dot_k * u_w[i]) / perp_norm + }) + .collect(); + + let mut embeddings = Vec::with_capacity(num); + for j in 0..num { + let c = -1.0 + 2.0 * (j as f64) / ((num - 1) as f64); + let s = (1.0 - c * c).max(0.0).sqrt(); + let x: Vec = (0..dim) + .map(|i| (c * u_w[i] + s * u_perp[i]) as f32) + .collect(); + embeddings.push(x); + } + ProbeSet { embeddings } + } + + /// Number of probes. + #[must_use] + pub fn len(&self) -> usize { + self.embeddings.len() + } + + /// Whether the probe set is empty (never true after construction). + #[must_use] + pub fn is_empty(&self) -> bool { + self.embeddings.is_empty() + } + + /// Probability outputs of `head` over every probe. + /// + /// # Errors + /// + /// [`GateError::ProbeDimMismatch`] when a probe length differs from the head + /// dimension. + pub fn probabilities(&self, head: &LinearHead) -> Result, GateError> { + self.embeddings + .iter() + .map(|e| head.presence_prob(e)) + .collect() + } +} + +/// Index of the smallest-magnitude entry (ties resolved to the first). +fn argmin_abs(v: &[f64]) -> usize { + let mut best = 0usize; + let mut best_abs = f64::INFINITY; + for (i, &x) in v.iter().enumerate() { + let a = x.abs(); + if a < best_abs { + best_abs = a; + best = i; + } + } + best +} + +// --------------------------------------------------------------------------- +// Gates +// --------------------------------------------------------------------------- + +/// Fail when the decision boundary is analytically unreachable for a +/// normalized-embedding linear head. +/// +/// With `‖x‖ = 1`, Cauchy–Schwarz confines the logit to +/// `[bias − ‖w‖, bias + ‖w‖]`. If this interval does not straddle `0` (i.e. +/// `|bias| ≥ ‖w‖`), the sign of the logit is fixed and the classifier is +/// effectively constant. The issue-1521 head (`‖w‖ ≈ 3.67`, `bias ≈ 8.19`) has +/// `min_logit ≈ 4.52 > 0` and fails here. +/// +/// # Errors +/// +/// [`GateFailure::UnreachableBoundary`] with the confining interval. +pub fn check_unreachable_boundary(head: &LinearHead) -> Result<(), GateFailure> { + let min_logit = head.min_logit_normalized(); + let max_logit = head.max_logit_normalized(); + // The boundary is reachable only if the interval straddles zero. At the + // exact touch (`min_logit == 0` or `max_logit == 0`) it is reachable at a + // single antipodal point only — treat as unreachable. + if min_logit >= 0.0 || max_logit <= 0.0 { + return Err(GateFailure::UnreachableBoundary { + weight_norm: head.weight_norm(), + bias: head.bias(), + min_logit, + max_logit, + }); + } + Ok(()) +} + +/// Fail when the head's probability output has variance below `min_variance` +/// across the probe set — an effectively constant classifier. +/// +/// # Errors +/// +/// - [`GateError`] when a probe length is wrong. +/// - [`GateFailure::ConstantOutput`] when the variance is below `min_variance`. +pub fn check_constant_output( + head: &LinearHead, + probe: &ProbeSet, + min_variance: f64, +) -> Result<(), GateOutcomeError> { + let probs = probe.probabilities(head).map_err(GateOutcomeError::Input)?; + let n = probs.len() as f64; + let mean = probs.iter().sum::() / n; + let variance = probs.iter().map(|p| (p - mean).powi(2)).sum::() / n; + if variance < min_variance { + let min_output = probs.iter().cloned().fold(f64::INFINITY, f64::min); + let max_output = probs.iter().cloned().fold(f64::NEG_INFINITY, f64::max); + return Err(GateOutcomeError::Failure(GateFailure::ConstantOutput { + variance, + threshold: min_variance, + min_output, + max_output, + probe_size: probs.len(), + })); + } + Ok(()) +} + +/// Fail when the predicted-positive rate (`logit ≥ 0`) on a balanced probe set +/// is at/above `max_positive_rate`. +/// +/// # Errors +/// +/// - [`GateError`] when a probe length is wrong. +/// - [`GateFailure::ClassBalance`] when the positive rate is at/above the +/// ceiling. +pub fn check_class_balance( + head: &LinearHead, + probe: &ProbeSet, + max_positive_rate: f64, +) -> Result<(), GateOutcomeError> { + let probs = probe.probabilities(head).map_err(GateOutcomeError::Input)?; + let positives = probs.iter().filter(|&&p| p >= 0.5).count(); + let positive_rate = positives as f64 / probs.len() as f64; + if positive_rate >= max_positive_rate { + return Err(GateOutcomeError::Failure(GateFailure::ClassBalance { + positive_rate, + ceiling: max_positive_rate, + probe_size: probs.len(), + })); + } + Ok(()) +} + +/// Fail when a metric is surfaced without a paired baseline (ADR-291). +/// +/// A well-formed [`EvaluationReport`] structurally carries its `baseline_metric` +/// (a model number can never be built without one), so this gate's job is to +/// reject the *absence* of a report and any non-finite baseline slipped in from +/// elsewhere. +/// +/// # Errors +/// +/// [`GateFailure::MissingBaseline`] when `report` is `None` or its baseline is +/// not finite. +pub fn check_baseline(report: Option<&EvaluationReport>) -> Result<(), GateFailure> { + match report { + None => Err(GateFailure::MissingBaseline { + reason: "no EvaluationReport was provided; a model number must be \ + paired with a mean-pose/majority baseline (ADR-291)" + .to_string(), + }), + Some(r) if !r.baseline_metric.is_finite() => Err(GateFailure::MissingBaseline { + reason: format!( + "baseline for `{}` is not finite ({})", + r.metric_name, r.baseline_metric + ), + }), + Some(_) => Ok(()), + } +} + +/// The result of running a gate that consumes a probe set: either a malformed +/// input ([`GateError`]) or a well-formed head that the gate rejected +/// ([`GateFailure`]). +#[derive(Debug, Error)] +pub enum GateOutcomeError { + /// Malformed input reached the gate. + #[error("gate input error: {0}")] + Input(#[from] GateError), + + /// The gate rejected a well-formed artifact. + #[error("gate failed: {0}")] + Failure(#[from] GateFailure), +} + +// --------------------------------------------------------------------------- +// Metric-name provenance +// --------------------------------------------------------------------------- + +/// The computed kind of a scalar metric. The kind is the source of truth; the +/// display label ([`MetricKind::label`]) derives from it, so a metric computed +/// as [`MetricKind::TemporalTriplet`] can never present itself as +/// [`MetricKind::Presence`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MetricKind { + /// Binary presence-detection accuracy. + Presence, + /// Temporal-triplet (representation-ordering) accuracy — an ordering task, + /// **not** presence detection. + TemporalTriplet, + /// Percentage of correct keypoints. + Pck, + /// Mean per-joint position error. + Mpjpe, +} + +impl MetricKind { + /// Stable short kind name (kebab-case), for machine comparison. + #[must_use] + pub fn name(&self) -> &'static str { + match self { + MetricKind::Presence => "presence", + MetricKind::TemporalTriplet => "temporal-triplet", + MetricKind::Pck => "pck", + MetricKind::Mpjpe => "mpjpe", + } + } + + /// Human display label derived from the kind — the *only* way a metric is + /// named, so the label always matches how the number was computed. + #[must_use] + pub fn label(&self) -> &'static str { + match self { + MetricKind::Presence => "presence accuracy", + MetricKind::TemporalTriplet => "temporal-triplet accuracy", + MetricKind::Pck => "PCK", + MetricKind::Mpjpe => "MPJPE", + } + } +} + +/// A scalar metric that carries its computed [`MetricKind`]. There is no +/// constructor that accepts a free-form label — the label is always derived +/// from `kind` — so a temporal-triplet number cannot be built as "presence". +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct LabeledMetric { + kind: MetricKind, + value: f64, +} + +impl LabeledMetric { + /// Build a metric of a given kind, validating the value is finite. + /// + /// # Errors + /// + /// [`GateError::NonFiniteMetric`] when `value` is NaN/±inf. + pub fn new(kind: MetricKind, value: f64) -> Result { + if !value.is_finite() { + return Err(GateError::NonFiniteMetric(value)); + } + Ok(LabeledMetric { kind, value }) + } + + /// The computed kind (source of truth). + #[must_use] + pub fn kind(&self) -> MetricKind { + self.kind + } + + /// The scalar value. + #[must_use] + pub fn value(&self) -> f64 { + self.value + } + + /// Display label derived from [`MetricKind::label`]. + #[must_use] + pub fn label(&self) -> &'static str { + self.kind.label() + } + + /// One-line self-describing string, e.g. `"temporal-triplet accuracy: 0.9000"`. + #[must_use] + pub fn describe(&self) -> String { + format!("{}: {:.4}", self.label(), self.value) + } +} + +/// Fail when a metric would be surfaced under a task name that does not match +/// the kind it was computed as (temporal-triplet ≠ presence). +/// +/// # Errors +/// +/// [`GateFailure::MetricProvenance`] when `metric.kind() != surfaced_as`. +pub fn check_metric_provenance( + metric: &LabeledMetric, + surfaced_as: MetricKind, +) -> Result<(), GateFailure> { + if metric.kind() != surfaced_as { + return Err(GateFailure::MetricProvenance { + computed_kind: metric.kind().name(), + computed_label: metric.kind().label(), + surfaced_kind: surfaced_as.name(), + surfaced_label: surfaced_as.label(), + }); + } + Ok(()) +} + +// --------------------------------------------------------------------------- +// Aggregate report — a single entry point for the CI model-check gate. +// --------------------------------------------------------------------------- + +/// Aggregated verdict of the head-level release gates. Runs +/// [`check_unreachable_boundary`], [`check_constant_output`], and +/// [`check_class_balance`] against a swept probe set, plus [`check_baseline`] +/// when a report is supplied. Collects *every* failure rather than short- +/// circuiting so a maintainer sees all defects at once. +#[derive(Debug, Clone, Default)] +pub struct ModelGateReport { + /// Failures collected across the gates. + pub failures: Vec, +} + +impl ModelGateReport { + /// Whether every gate passed. + #[must_use] + pub fn passed(&self) -> bool { + self.failures.is_empty() + } + + /// Human-readable multi-line summary of the failures (or a pass line). + #[must_use] + pub fn summary(&self) -> String { + if self.passed() { + return "model release gates: PASS".to_string(); + } + let mut s = format!("model release gates: FAIL ({} issue(s))", self.failures.len()); + for f in &self.failures { + s.push_str("\n - "); + s.push_str(&f.to_string()); + } + s + } +} + +/// Run the head-level release gates and collect all failures. +/// +/// Uses [`DEFAULT_MIN_OUTPUT_VARIANCE`], [`DEFAULT_MAX_POSITIVE_RATE`], and a +/// [`DEFAULT_PROBE_POINTS`] sweep. Malformed probe evaluation is impossible here +/// because the sweep is generated to match the head dimension. +#[must_use] +pub fn evaluate_linear_head( + head: &LinearHead, + baseline: Option<&EvaluationReport>, +) -> ModelGateReport { + let mut failures = Vec::new(); + + if let Err(f) = check_unreachable_boundary(head) { + failures.push(f); + } + + let probe = ProbeSet::sweep_for_head(head, DEFAULT_PROBE_POINTS); + if let Err(GateOutcomeError::Failure(f)) = + check_constant_output(head, &probe, DEFAULT_MIN_OUTPUT_VARIANCE) + { + failures.push(f); + } + if let Err(GateOutcomeError::Failure(f)) = + check_class_balance(head, &probe, DEFAULT_MAX_POSITIVE_RATE) + { + failures.push(f); + } + + if let Err(f) = check_baseline(baseline) { + failures.push(f); + } + + ModelGateReport { failures } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use crate::protocols::SplitProtocol; + + /// The issue-1521 presence head: `‖w‖ ≈ 3.67`, `bias ≈ 8.19`. We build a + /// weight vector whose norm is 3.67 by spreading it over 4 dims. + fn issue_1521_head() -> LinearHead { + // 4 equal components with norm 3.67 ⇒ each = 3.67 / 2 = 1.835. + let c = 3.67f32 / 2.0; + LinearHead::new(vec![c, c, c, c], 8.19).unwrap() + } + + /// A healthy synthetic head: reachable boundary, balanced, diverse output. + fn healthy_head() -> LinearHead { + // ‖w‖ = 4, bias = 0 ⇒ logit sweeps [-4, 4], sigmoid [0.018, 0.982]. + LinearHead::new(vec![2.0, 2.0, 2.0, 2.0], 0.0).unwrap() + } + + #[test] + fn issue_1521_norm_and_bias_match_the_review() { + let h = issue_1521_head(); + assert!((h.weight_norm() - 3.67).abs() < 1e-4, "norm {}", h.weight_norm()); + assert!((h.bias() - 8.19).abs() < 1e-6); + // Smallest achievable logit stays positive — the degenerate signature. + assert!(h.min_logit_normalized() > 0.0); + } + + #[test] + fn issue_1521_fails_unreachable_boundary() { + let h = issue_1521_head(); + let err = check_unreachable_boundary(&h).unwrap_err(); + match err { + GateFailure::UnreachableBoundary { min_logit, max_logit, .. } => { + assert!(min_logit > 0.0); + assert!(max_logit > 0.0); + } + other => panic!("expected UnreachableBoundary, got {other:?}"), + } + // The failure message must carry the offending numbers. + let msg = check_unreachable_boundary(&h).unwrap_err().to_string(); + assert!(msg.contains("unreachable decision boundary"), "{msg}"); + } + + #[test] + fn issue_1521_fails_constant_output() { + let h = issue_1521_head(); + let probe = ProbeSet::sweep_for_head(&h, DEFAULT_PROBE_POINTS); + let err = check_constant_output(&h, &probe, DEFAULT_MIN_OUTPUT_VARIANCE).unwrap_err(); + match err { + GateOutcomeError::Failure(GateFailure::ConstantOutput { + variance, + threshold, + min_output, + .. + }) => { + assert!(variance < threshold); + // Even the minimum output is a near-certain positive. + assert!(min_output > 0.98, "min_output {min_output}"); + } + other => panic!("expected ConstantOutput, got {other:?}"), + } + } + + #[test] + fn issue_1521_aggregate_fails_boundary_and_constant() { + let h = issue_1521_head(); + let report = evaluate_linear_head(&h, None); + assert!(!report.passed()); + let has_boundary = report + .failures + .iter() + .any(|f| matches!(f, GateFailure::UnreachableBoundary { .. })); + let has_constant = report + .failures + .iter() + .any(|f| matches!(f, GateFailure::ConstantOutput { .. })); + assert!(has_boundary, "expected unreachable-boundary failure"); + assert!(has_constant, "expected constant-output failure"); + } + + #[test] + fn healthy_head_passes_head_gates() { + let h = healthy_head(); + assert!(check_unreachable_boundary(&h).is_ok()); + + let probe = ProbeSet::sweep_for_head(&h, DEFAULT_PROBE_POINTS); + assert!(check_constant_output(&h, &probe, DEFAULT_MIN_OUTPUT_VARIANCE).is_ok()); + assert!(check_class_balance(&h, &probe, DEFAULT_MAX_POSITIVE_RATE).is_ok()); + + // With a baseline report supplied, the aggregate passes entirely. + let rep = EvaluationReport::synthetic( + SplitProtocol::CrossSubject, + "presence", + 0.71, + 0.50, + ) + .unwrap(); + let gate = evaluate_linear_head(&h, Some(&rep)); + assert!(gate.passed(), "{}", gate.summary()); + } + + #[test] + fn constant_weight_head_fails_constant_output() { + // Zero weight ⇒ logit == bias for every input ⇒ genuinely constant. + let h = LinearHead::new(vec![0.0, 0.0, 0.0], 0.3).unwrap(); + let probe = ProbeSet::sweep_for_head(&h, DEFAULT_PROBE_POINTS); + let err = check_constant_output(&h, &probe, DEFAULT_MIN_OUTPUT_VARIANCE).unwrap_err(); + assert!(matches!( + err, + GateOutcomeError::Failure(GateFailure::ConstantOutput { .. }) + )); + } + + #[test] + fn degenerate_class_balance_fails() { + // Issue-1521 head classifies 100% positive. + let h = issue_1521_head(); + let probe = ProbeSet::sweep_for_head(&h, DEFAULT_PROBE_POINTS); + let err = check_class_balance(&h, &probe, DEFAULT_MAX_POSITIVE_RATE).unwrap_err(); + match err { + GateOutcomeError::Failure(GateFailure::ClassBalance { positive_rate, .. }) => { + assert!((positive_rate - 1.0).abs() < 1e-9); + } + other => panic!("expected ClassBalance, got {other:?}"), + } + } + + #[test] + fn missing_baseline_fails_and_present_passes() { + assert!(check_baseline(None).is_err()); + let rep = EvaluationReport::synthetic( + SplitProtocol::CrossSubject, + "pck@0.2", + 0.61, + 0.41, + ) + .unwrap(); + assert!(check_baseline(Some(&rep)).is_ok()); + } + + #[test] + fn temporal_triplet_cannot_be_labeled_presence() { + let m = LabeledMetric::new(MetricKind::TemporalTriplet, 0.90).unwrap(); + // The label derives from the computed kind — it is NOT "presence accuracy". + assert_eq!(m.label(), "temporal-triplet accuracy"); + assert_ne!(m.label(), MetricKind::Presence.label()); + assert!(m.describe().contains("temporal-triplet accuracy")); + + // Surfacing it under the presence task name is a structural error. + let err = check_metric_provenance(&m, MetricKind::Presence).unwrap_err(); + match err { + GateFailure::MetricProvenance { + computed_kind, + surfaced_kind, + .. + } => { + assert_eq!(computed_kind, "temporal-triplet"); + assert_eq!(surfaced_kind, "presence"); + } + other => panic!("expected MetricProvenance, got {other:?}"), + } + } + + #[test] + fn matching_metric_provenance_passes() { + let m = LabeledMetric::new(MetricKind::Presence, 0.88).unwrap(); + assert!(check_metric_provenance(&m, MetricKind::Presence).is_ok()); + } + + #[test] + fn malformed_inputs_are_errors_not_panics() { + assert_eq!(LinearHead::new(vec![], 0.0).unwrap_err(), GateError::EmptyWeight); + assert!(matches!( + LinearHead::new(vec![f32::NAN], 0.0).unwrap_err(), + GateError::NonFiniteParameter { .. } + )); + assert!(matches!( + LinearHead::new(vec![1.0], f32::INFINITY).unwrap_err(), + GateError::NonFiniteParameter { .. } + )); + assert!(matches!( + LabeledMetric::new(MetricKind::Pck, f64::NAN).unwrap_err(), + GateError::NonFiniteMetric(_) + )); + assert_eq!( + ProbeSet::from_embeddings(vec![]).unwrap_err(), + GateError::EmptyProbe + ); + } + + #[test] + fn logit_rejects_dimension_mismatch() { + let h = healthy_head(); // dim 4 + assert!(matches!( + h.logit(&[1.0, 2.0]).unwrap_err(), + GateError::ProbeDimMismatch { expected: 4, actual: 2 } + )); + } + + #[test] + fn sweep_embeddings_are_unit_norm() { + let h = healthy_head(); + let probe = ProbeSet::sweep_for_head(&h, 16); + for e in &probe.embeddings { + let n: f64 = e.iter().map(|&x| (x as f64) * (x as f64)).sum::().sqrt(); + assert!((n - 1.0).abs() < 1e-5, "norm {n}"); + } + } +} diff --git a/v2/crates/wifi-densepose-train/src/protocols.rs b/v2/crates/wifi-densepose-train/src/protocols.rs new file mode 100644 index 00000000..81e3a776 --- /dev/null +++ b/v2/crates/wifi-densepose-train/src/protocols.rs @@ -0,0 +1,388 @@ +//! Standard public-benchmark split protocols (ADR-291 §2). +//! +//! The field's documented leakage failure is the window-level random split: +//! adjacent windows cut from one continuous recording are near-identical, so +//! splitting them across train/test inflates accuracy (one dataset's F1 +//! collapsed from ~90% to ~22% under subject-disjoint splits — ADR-291 +//! §Context). This module expresses the standard leaderboard evaluations as a +//! [`SplitProtocol`] whose assignment is a **pure function of sample metadata +//! plus a seed** — no RNG state, no iteration-order dependence, byte-identical +//! across runs and platforms. +//! +//! - [`SplitProtocol::CrossSubject`] — MM-Fi-style: held-out subjects. +//! - [`SplitProtocol::CrossEnvironment`] — held-out rooms/environments. +//! - [`SplitProtocol::CrossOrientation`] — Widar-style: held-out orientations. +//! - [`SplitProtocol::RandomBaseline`] — window-level random split, kept +//! **only** as the explicitly leakage-prone comparison point; it makes no +//! disjointness claim and will normally fail the +//! [`leakage::LeakageAudit`]. +//! +//! Structural verification of a produced split lives in [`leakage`]. + +pub mod leakage; + +use serde::{Deserialize, Serialize}; + +use crate::error::ProtocolError; + +// --------------------------------------------------------------------------- +// SampleMeta +// --------------------------------------------------------------------------- + +/// Loader-agnostic per-window metadata consumed by split assignment and the +/// leakage audit. Produced by e.g. +/// [`WidarDataset::sample_meta`](crate::dataset::widar::WidarDataset::sample_meta). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct SampleMeta { + /// Subject/user id. + pub subject_id: u32, + /// Environment/room id (`0` when the dataset tree does not encode one). + pub environment_id: u32, + /// Orientation id (Widar face orientation; `0` when unknown). + pub orientation_id: u32, + /// Gesture/action id. + pub gesture_id: u32, + /// Identifier of the continuous recording this window was cut from. + /// Windows sharing a `recording_id` are temporally correlated and must + /// never straddle a train/test boundary. + pub recording_id: u64, + /// Window offset within the recording. + pub window_index: u64, +} + +// --------------------------------------------------------------------------- +// SplitProtocol +// --------------------------------------------------------------------------- + +/// Which side of a train/test split a sample is assigned to. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum SplitSide { + /// Training partition. + Train, + /// Held-out test partition. + Test, +} + +/// A standard evaluation protocol determining *what* is held out. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum SplitProtocol { + /// Hold out whole subjects (MM-Fi cross-subject protocol). + CrossSubject, + /// Hold out whole environments/rooms (MM-Fi cross-environment protocol). + CrossEnvironment, + /// Hold out whole orientations (Widar3.0 cross-orientation protocol). + CrossOrientation, + /// Window-level random split. **Leakage-prone by construction** — kept + /// only so leaderboard-style numbers can be contrasted against a leaky + /// baseline; it claims no disjointness and normally fails the audit. + RandomBaseline, +} + +impl SplitProtocol { + /// Stable lowercase tag for logs/reports. + #[must_use] + pub fn tag(self) -> &'static str { + match self { + SplitProtocol::CrossSubject => "cross-subject", + SplitProtocol::CrossEnvironment => "cross-environment", + SplitProtocol::CrossOrientation => "cross-orientation", + SplitProtocol::RandomBaseline => "random-baseline-leaky", + } + } + + /// Disjointness this protocol claims and the audit must verify. + #[must_use] + pub fn claims(self) -> leakage::LeakageClaims { + match self { + SplitProtocol::CrossSubject => leakage::LeakageClaims { + subject_disjoint: true, + environment_disjoint: false, + orientation_disjoint: false, + }, + SplitProtocol::CrossEnvironment => leakage::LeakageClaims { + subject_disjoint: false, + environment_disjoint: true, + orientation_disjoint: false, + }, + SplitProtocol::CrossOrientation => leakage::LeakageClaims { + subject_disjoint: false, + environment_disjoint: false, + orientation_disjoint: true, + }, + SplitProtocol::RandomBaseline => leakage::LeakageClaims { + subject_disjoint: false, + environment_disjoint: false, + orientation_disjoint: false, + }, + } + } +} + +// --------------------------------------------------------------------------- +// SplitPlan +// --------------------------------------------------------------------------- + +/// A concrete, seeded instantiation of a [`SplitProtocol`]. +/// +/// [`SplitPlan::assign`] is a pure function: the same `(protocol, seed, +/// test_fraction, meta)` always yields the same side, independent of call +/// order, thread, or platform. +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +pub struct SplitPlan { + /// The evaluation protocol. + pub protocol: SplitProtocol, + /// Seed mixed into every assignment hash. + pub seed: u64, + /// Target fraction of held-out *units* (subjects / environments / + /// orientations / windows, per protocol), strictly inside `(0, 1)`. + pub test_fraction: f64, +} + +impl SplitPlan { + /// Create a plan, validating `test_fraction`. + /// + /// # Errors + /// + /// [`ProtocolError::InvalidTestFraction`] when the fraction is not finite + /// or not strictly inside `(0, 1)`. + pub fn new( + protocol: SplitProtocol, + seed: u64, + test_fraction: f64, + ) -> Result { + if !test_fraction.is_finite() || test_fraction <= 0.0 || test_fraction >= 1.0 { + return Err(ProtocolError::InvalidTestFraction { + value: test_fraction, + }); + } + Ok(SplitPlan { + protocol, + seed, + test_fraction, + }) + } + + /// Assign one sample to a side — pure, deterministic, stateless. + /// + /// The protocol's held-out *unit* (subject, environment, orientation, or + /// individual window) is hashed together with a protocol-specific domain + /// tag and the seed; the unit lands in the test set when its hash falls + /// below `test_fraction` of the hash space. All windows of one unit + /// therefore always land on the same side (except under + /// [`SplitProtocol::RandomBaseline`], which hashes per window — that is + /// its documented leak). + #[must_use] + pub fn assign(&self, meta: &SampleMeta) -> SplitSide { + // Distinct domain tags keep e.g. subject 3 and orientation 3 from + // sharing a hash under the same seed. + const DOMAIN_SUBJECT: u64 = 0x5355424a; // "SUBJ" + const DOMAIN_ENVIRONMENT: u64 = 0x454e5652; // "ENVR" + const DOMAIN_ORIENTATION: u64 = 0x4f524e54; // "ORNT" + const DOMAIN_RANDOM: u64 = 0x524e444d; // "RNDM" + + let unit = match self.protocol { + SplitProtocol::CrossSubject => { + mix2(DOMAIN_SUBJECT, meta.subject_id as u64) + } + SplitProtocol::CrossEnvironment => { + mix2(DOMAIN_ENVIRONMENT, meta.environment_id as u64) + } + SplitProtocol::CrossOrientation => { + mix2(DOMAIN_ORIENTATION, meta.orientation_id as u64) + } + SplitProtocol::RandomBaseline => mix2( + mix2(DOMAIN_RANDOM, meta.recording_id), + meta.window_index, + ), + }; + let h = splitmix64(unit ^ splitmix64(self.seed)); + + // Integer threshold comparison — no float accumulation, identical on + // every platform. + let threshold = (self.test_fraction * (1u128 << 64) as f64) as u128; + if (h as u128) < threshold { + SplitSide::Test + } else { + SplitSide::Train + } + } + + /// Partition metadata into `(train_indices, test_indices)` by + /// [`Self::assign`], preserving input order within each side. + #[must_use] + pub fn partition(&self, metas: &[SampleMeta]) -> (Vec, Vec) { + let mut train = Vec::new(); + let mut test = Vec::new(); + for (i, meta) in metas.iter().enumerate() { + match self.assign(meta) { + SplitSide::Train => train.push(i), + SplitSide::Test => test.push(i), + } + } + (train, test) + } +} + +/// SplitMix64 finalizer — a well-distributed 64-bit mixing function. +fn splitmix64(mut x: u64) -> u64 { + x = x.wrapping_add(0x9E37_79B9_7F4A_7C15); + x = (x ^ (x >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + x = (x ^ (x >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + x ^ (x >> 31) +} + +/// Order-sensitive combination of two words through SplitMix64. +fn mix2(a: u64, b: u64) -> u64 { + splitmix64(splitmix64(a) ^ b.rotate_left(32)) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + /// 4 subjects × 2 environments × 4 orientations, 2 recordings each with + /// 5 windows — a deterministic synthetic corpus. + fn corpus() -> Vec { + let mut metas = Vec::new(); + let mut recording = 0u64; + for subject in 1..=4u32 { + for environment in 1..=2u32 { + for orientation in 1..=4u32 { + for _ in 0..2 { + for window in 0..5u64 { + metas.push(SampleMeta { + subject_id: subject, + environment_id: environment, + orientation_id: orientation, + gesture_id: 1 + (recording % 6) as u32, + recording_id: recording, + window_index: window, + }); + } + recording += 1; + } + } + } + } + metas + } + + #[test] + fn plan_rejects_bad_fractions() { + for bad in [0.0, 1.0, -0.2, 1.7, f64::NAN, f64::INFINITY] { + assert!(matches!( + SplitPlan::new(SplitProtocol::CrossSubject, 1, bad), + Err(ProtocolError::InvalidTestFraction { .. }) + )); + } + assert!(SplitPlan::new(SplitProtocol::CrossSubject, 1, 0.25).is_ok()); + } + + #[test] + fn assignment_is_deterministic_across_calls_and_order() { + let metas = corpus(); + let plan = SplitPlan::new(SplitProtocol::CrossSubject, 42, 0.3).unwrap(); + let (tr1, te1) = plan.partition(&metas); + let (tr2, te2) = plan.partition(&metas); + assert_eq!(tr1, tr2); + assert_eq!(te1, te2); + + // Pure per-sample function: reversing iteration order changes nothing. + let reversed: Vec = metas.iter().rev().copied().collect(); + for (meta, rev) in metas.iter().zip(reversed.iter().rev()) { + assert_eq!(plan.assign(meta), plan.assign(rev)); + } + } + + #[test] + fn different_seeds_change_the_split() { + let metas = corpus(); + let a = SplitPlan::new(SplitProtocol::CrossSubject, 1, 0.5).unwrap(); + let b = SplitPlan::new(SplitProtocol::CrossSubject, 2, 0.5).unwrap(); + // With 4 subjects at 50% some seed pair must differ; these two do — + // and if the hash ever changes this test flags the compat break. + let (_, te_a) = a.partition(&metas); + let (_, te_b) = b.partition(&metas); + assert_ne!(te_a, te_b, "seeds 1 and 2 should hold out different subjects"); + } + + #[test] + fn cross_subject_keeps_subjects_whole() { + let metas = corpus(); + let plan = SplitPlan::new(SplitProtocol::CrossSubject, 7, 0.4).unwrap(); + let mut side_by_subject = std::collections::BTreeMap::new(); + for meta in &metas { + let side = plan.assign(meta); + let prev = side_by_subject.insert(meta.subject_id, side); + if let Some(prev) = prev { + assert_eq!(prev, side, "subject {} split across sides", meta.subject_id); + } + } + } + + #[test] + fn cross_environment_keeps_environments_whole() { + let metas = corpus(); + let plan = SplitPlan::new(SplitProtocol::CrossEnvironment, 11, 0.5).unwrap(); + let mut side_by_env = std::collections::BTreeMap::new(); + for meta in &metas { + let side = plan.assign(meta); + if let Some(prev) = side_by_env.insert(meta.environment_id, side) { + assert_eq!(prev, side); + } + } + } + + #[test] + fn cross_orientation_keeps_orientations_whole() { + let metas = corpus(); + let plan = SplitPlan::new(SplitProtocol::CrossOrientation, 13, 0.5).unwrap(); + let mut side_by_orient = std::collections::BTreeMap::new(); + for meta in &metas { + let side = plan.assign(meta); + if let Some(prev) = side_by_orient.insert(meta.orientation_id, side) { + assert_eq!(prev, side); + } + } + } + + #[test] + fn random_baseline_splits_within_recordings() { + // The leaky baseline must (for some recording) place windows of the + // same recording on both sides — that is the leak it demonstrates. + let metas = corpus(); + let plan = SplitPlan::new(SplitProtocol::RandomBaseline, 3, 0.5).unwrap(); + let mut crossing = false; + let mut side_by_recording = std::collections::BTreeMap::new(); + for meta in &metas { + let side = plan.assign(meta); + if let Some(prev) = side_by_recording.insert(meta.recording_id, side) { + if prev != side { + crossing = true; + } + } + } + assert!(crossing, "window-level split should cross recordings"); + } + + #[test] + fn protocol_claims_match_semantics() { + assert!(SplitProtocol::CrossSubject.claims().subject_disjoint); + assert!(SplitProtocol::CrossEnvironment.claims().environment_disjoint); + assert!(SplitProtocol::CrossOrientation.claims().orientation_disjoint); + let random = SplitProtocol::RandomBaseline.claims(); + assert!(!random.subject_disjoint); + assert!(!random.environment_disjoint); + assert!(!random.orientation_disjoint); + } + + #[test] + fn tags_are_stable() { + assert_eq!(SplitProtocol::CrossSubject.tag(), "cross-subject"); + assert_eq!(SplitProtocol::RandomBaseline.tag(), "random-baseline-leaky"); + } +} diff --git a/v2/crates/wifi-densepose-train/src/protocols/leakage.rs b/v2/crates/wifi-densepose-train/src/protocols/leakage.rs new file mode 100644 index 00000000..fc8a8ea1 --- /dev/null +++ b/v2/crates/wifi-densepose-train/src/protocols/leakage.rs @@ -0,0 +1,703 @@ +//! Structural leakage guards, mean-pose baseline, and evidence-graded +//! evaluation reports (ADR-291 §3). +//! +//! Three enforcement points, all `Err`-on-failure (never a warning): +//! +//! 1. [`LeakageAudit`] verifies a proposed train/test split structurally: +//! subject-disjointness and environment/orientation-disjointness **where +//! the protocol claims them**, and — unconditionally — that no two windows +//! cut from the same continuous recording straddle the boundary. +//! 2. [`MeanPoseBaseline`] is fitted from the *training* split only; PCK / +//! MPJPE numbers are meaningless without it (CLAUDE.md: pose PCK requires +//! the mean-pose baseline). +//! 3. [`EvaluationReport`] pairs the model metric with the baseline metric +//! and carries an [`EvidenceGrade`]; `MEASURED` cannot be constructed +//! without an embedded reproducer command string. + +use ndarray::Array2; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeSet; + +use crate::error::ProtocolError; +use crate::protocols::{SampleMeta, SplitProtocol}; + +// --------------------------------------------------------------------------- +// LeakageAudit +// --------------------------------------------------------------------------- + +/// Disjointness properties a protocol claims; the audit verifies each claimed +/// one. Obtained from [`SplitProtocol::claims`], or constructed directly for +/// custom protocols. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct LeakageClaims { + /// Train and test must share no subject. + pub subject_disjoint: bool, + /// Train and test must share no environment/room. + pub environment_disjoint: bool, + /// Train and test must share no orientation. + pub orientation_disjoint: bool, +} + +/// Summary returned by a **passing** audit — counts for reporting, no claim +/// stronger than what was structurally checked. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct LeakageAuditPass { + /// Claims that were verified. + pub claims: LeakageClaims, + /// Number of training windows. + pub train_windows: usize, + /// Number of test windows. + pub test_windows: usize, + /// Distinct subjects in train. + pub train_subjects: usize, + /// Distinct subjects in test. + pub test_subjects: usize, + /// Distinct continuous recordings in train. + pub train_recordings: usize, + /// Distinct continuous recordings in test. + pub test_recordings: usize, +} + +/// Structural train/test-split auditor (ADR-291 §3). +/// +/// A failed audit is an [`Err`], not a warning: leaky splits must be unusable +/// for reporting, not merely frowned upon. +#[derive(Debug, Clone, Copy)] +pub struct LeakageAudit { + claims: LeakageClaims, +} + +impl LeakageAudit { + /// Auditor for an explicit set of claims. + #[must_use] + pub fn new(claims: LeakageClaims) -> Self { + LeakageAudit { claims } + } + + /// Auditor for the claims a standard protocol makes. + #[must_use] + pub fn for_protocol(protocol: SplitProtocol) -> Self { + LeakageAudit { + claims: protocol.claims(), + } + } + + /// Verify a proposed split. + /// + /// Checks, in order: + /// 1. both partitions are non-empty; + /// 2. no continuous recording has windows on both sides (unconditional — + /// overlapping windows of one recording are near-duplicates); + /// 3. subject-disjointness, when claimed; + /// 4. environment-disjointness, when claimed; + /// 5. orientation-disjointness, when claimed. + /// + /// # Errors + /// + /// The [`ProtocolError`] variant describing the **first** violation found. + pub fn audit( + &self, + train: &[SampleMeta], + test: &[SampleMeta], + ) -> Result { + if train.is_empty() { + return Err(ProtocolError::EmptyPartition { side: "train" }); + } + if test.is_empty() { + return Err(ProtocolError::EmptyPartition { side: "test" }); + } + + // (2) Recording windows must never cross the boundary. + let train_recordings: BTreeSet = train.iter().map(|m| m.recording_id).collect(); + let test_recordings: BTreeSet = test.iter().map(|m| m.recording_id).collect(); + if let Some(&recording_id) = train_recordings.intersection(&test_recordings).next() { + return Err(ProtocolError::RecordingCrossesSplit { recording_id }); + } + + // (3–5) Claimed disjointness. + let train_subjects: BTreeSet = train.iter().map(|m| m.subject_id).collect(); + let test_subjects: BTreeSet = test.iter().map(|m| m.subject_id).collect(); + if self.claims.subject_disjoint { + if let Some(&subject_id) = train_subjects.intersection(&test_subjects).next() { + return Err(ProtocolError::SubjectOverlap { subject_id }); + } + } + if self.claims.environment_disjoint { + let train_envs: BTreeSet = train.iter().map(|m| m.environment_id).collect(); + let test_envs: BTreeSet = test.iter().map(|m| m.environment_id).collect(); + if let Some(&environment_id) = train_envs.intersection(&test_envs).next() { + return Err(ProtocolError::EnvironmentOverlap { environment_id }); + } + } + if self.claims.orientation_disjoint { + let train_orients: BTreeSet = train.iter().map(|m| m.orientation_id).collect(); + let test_orients: BTreeSet = test.iter().map(|m| m.orientation_id).collect(); + if let Some(&orientation_id) = train_orients.intersection(&test_orients).next() { + return Err(ProtocolError::OrientationOverlap { orientation_id }); + } + } + + Ok(LeakageAuditPass { + claims: self.claims, + train_windows: train.len(), + test_windows: test.len(), + train_subjects: train_subjects.len(), + test_subjects: test_subjects.len(), + train_recordings: train_recordings.len(), + test_recordings: test_recordings.len(), + }) + } +} + +// --------------------------------------------------------------------------- +// MeanPoseBaseline +// --------------------------------------------------------------------------- + +/// The mean-pose baseline: predicts the per-joint mean of the **training** +/// poses for every test sample (CLAUDE.md: pose PCK requires this baseline — +/// a model must beat "always predict the average pose" before any number +/// means anything). +#[derive(Debug, Clone, PartialEq)] +pub struct MeanPoseBaseline { + mean_pose: Array2, + num_train_poses: usize, +} + +impl MeanPoseBaseline { + /// Fit the baseline from training-split poses only. Each pose is + /// `[num_joints, 2]` (normalised x, y); all poses must share one shape. + /// + /// # Errors + /// + /// - [`ProtocolError::EmptyTrainingPoses`] when `train_poses` is empty. + /// - [`ProtocolError::PoseShapeMismatch`] when poses disagree in shape. + pub fn fit(train_poses: &[Array2]) -> Result { + let first = train_poses.first().ok_or(ProtocolError::EmptyTrainingPoses)?; + let shape = first.dim(); + + let mut mean_pose = Array2::::zeros(shape); + for pose in train_poses { + if pose.dim() != shape { + return Err(ProtocolError::PoseShapeMismatch { + expected: vec![shape.0, shape.1], + actual: pose.shape().to_vec(), + }); + } + mean_pose += pose; + } + mean_pose /= train_poses.len() as f32; + + Ok(MeanPoseBaseline { + mean_pose, + num_train_poses: train_poses.len(), + }) + } + + /// The fitted mean pose, `[num_joints, 2]`. + #[must_use] + pub fn mean_pose(&self) -> &Array2 { + &self.mean_pose + } + + /// Number of training poses the baseline was fitted on. + #[must_use] + pub fn num_train_poses(&self) -> usize { + self.num_train_poses + } + + /// Mean per-joint position error (MPJPE) of the baseline over test-split + /// poses: the mean Euclidean distance between each test joint and the + /// corresponding mean-pose joint. + /// + /// # Errors + /// + /// - [`ProtocolError::EmptyTrainingPoses`] when `test_poses` is empty + /// (nothing to evaluate). + /// - [`ProtocolError::PoseShapeMismatch`] when a test pose does not match + /// the fitted shape. + pub fn mpjpe(&self, test_poses: &[Array2]) -> Result { + if test_poses.is_empty() { + return Err(ProtocolError::EmptyTrainingPoses); + } + let shape = self.mean_pose.dim(); + let mut total = 0.0f64; + let mut joints = 0usize; + for pose in test_poses { + if pose.dim() != shape { + return Err(ProtocolError::PoseShapeMismatch { + expected: vec![shape.0, shape.1], + actual: pose.shape().to_vec(), + }); + } + for j in 0..shape.0 { + let dx = (pose[[j, 0]] - self.mean_pose[[j, 0]]) as f64; + let dy = (pose[[j, 1]] - self.mean_pose[[j, 1]]) as f64; + total += (dx * dx + dy * dy).sqrt(); + joints += 1; + } + } + Ok(total / joints as f64) + } + + /// Baseline PCK@`threshold` over test poses: fraction of joints whose + /// distance to the mean-pose joint is `< threshold` (same units as the + /// pose coordinates). + /// + /// # Errors + /// + /// Same conditions as [`Self::mpjpe`]. + pub fn pck_at( + &self, + test_poses: &[Array2], + threshold: f32, + ) -> Result { + if test_poses.is_empty() { + return Err(ProtocolError::EmptyTrainingPoses); + } + let shape = self.mean_pose.dim(); + let mut correct = 0usize; + let mut joints = 0usize; + for pose in test_poses { + if pose.dim() != shape { + return Err(ProtocolError::PoseShapeMismatch { + expected: vec![shape.0, shape.1], + actual: pose.shape().to_vec(), + }); + } + for j in 0..shape.0 { + let dx = pose[[j, 0]] - self.mean_pose[[j, 0]]; + let dy = pose[[j, 1]] - self.mean_pose[[j, 1]]; + if (dx * dx + dy * dy).sqrt() < threshold { + correct += 1; + } + joints += 1; + } + } + Ok(correct as f64 / joints as f64) + } +} + +// --------------------------------------------------------------------------- +// EvaluationReport +// --------------------------------------------------------------------------- + +/// Evidence grade of a reported number (CLAUDE.md tagging rules). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum EvidenceGrade { + /// Measured on real data with a leak-free split; carries the exact + /// command that reproduces the number. Constructible only through + /// [`EvaluationReport::measured`], which rejects an empty reproducer. + Measured { + /// Command line that reproduces this result. + reproducer: String, + }, + /// Computed on synthetic/generated data. + Synthetic, + /// Quoted from elsewhere; not reproduced in this repository. + Claimed, +} + +impl EvidenceGrade { + /// Stable uppercase tag (`MEASURED` / `SYNTHETIC` / `CLAIMED`). + #[must_use] + pub fn tag(&self) -> &'static str { + match self { + EvidenceGrade::Measured { .. } => "MEASURED", + EvidenceGrade::Synthetic => "SYNTHETIC", + EvidenceGrade::Claimed => "CLAIMED", + } + } +} + +/// An evaluation result that structurally pairs the model metric with the +/// mean-pose (or other) baseline metric and an [`EvidenceGrade`] — a model +/// number can never be reported without its baseline (ADR-291 §3). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct EvaluationReport { + /// Protocol the split followed. + pub protocol: SplitProtocol, + /// Metric name, e.g. `"pck@0.2"` or `"mpjpe"`. + pub metric_name: String, + /// The model's metric on the audited test split. + pub model_metric: f64, + /// The baseline's metric on the same split (e.g. + /// [`MeanPoseBaseline::mpjpe`]). + pub baseline_metric: f64, + /// Evidence grade; `MEASURED` embeds its reproducer. + pub evidence: EvidenceGrade, +} + +impl EvaluationReport { + /// Build a `MEASURED` report. The reproducer command is mandatory and + /// must be non-blank — a measured number without a reproducer is not + /// measured (CLAUDE.md). + /// + /// # Errors + /// + /// - [`ProtocolError::MissingReproducer`] when `reproducer` is blank. + /// - [`ProtocolError::NonFiniteMetric`] when either metric is NaN/±inf. + pub fn measured( + protocol: SplitProtocol, + metric_name: impl Into, + model_metric: f64, + baseline_metric: f64, + reproducer: impl Into, + ) -> Result { + let reproducer = reproducer.into(); + if reproducer.trim().is_empty() { + return Err(ProtocolError::MissingReproducer); + } + Self::build( + protocol, + metric_name.into(), + model_metric, + baseline_metric, + EvidenceGrade::Measured { reproducer }, + ) + } + + /// Build a `SYNTHETIC` report (synthetic/generated data). + /// + /// # Errors + /// + /// [`ProtocolError::NonFiniteMetric`] when either metric is NaN/±inf. + pub fn synthetic( + protocol: SplitProtocol, + metric_name: impl Into, + model_metric: f64, + baseline_metric: f64, + ) -> Result { + Self::build( + protocol, + metric_name.into(), + model_metric, + baseline_metric, + EvidenceGrade::Synthetic, + ) + } + + /// Build a `CLAIMED` report (quoted, not reproduced here). + /// + /// # Errors + /// + /// [`ProtocolError::NonFiniteMetric`] when either metric is NaN/±inf. + pub fn claimed( + protocol: SplitProtocol, + metric_name: impl Into, + model_metric: f64, + baseline_metric: f64, + ) -> Result { + Self::build( + protocol, + metric_name.into(), + model_metric, + baseline_metric, + EvidenceGrade::Claimed, + ) + } + + fn build( + protocol: SplitProtocol, + metric_name: String, + model_metric: f64, + baseline_metric: f64, + evidence: EvidenceGrade, + ) -> Result { + if !model_metric.is_finite() { + return Err(ProtocolError::NonFiniteMetric { + name: format!("{metric_name} (model)"), + value: model_metric, + }); + } + if !baseline_metric.is_finite() { + return Err(ProtocolError::NonFiniteMetric { + name: format!("{metric_name} (baseline)"), + value: baseline_metric, + }); + } + Ok(EvaluationReport { + protocol, + metric_name, + model_metric, + baseline_metric, + evidence, + }) + } + + /// `model − baseline` (positive is better for higher-is-better metrics + /// such as PCK; interpret per metric). + #[must_use] + pub fn margin_over_baseline(&self) -> f64 { + self.model_metric - self.baseline_metric + } + + /// One-line evidence-tagged summary, e.g. + /// `"[MEASURED] cross-subject pck@0.2: model 0.6100 vs mean-pose baseline 0.4100"`. + #[must_use] + pub fn summary(&self) -> String { + format!( + "[{}] {} {}: model {:.4} vs mean-pose baseline {:.4}", + self.evidence.tag(), + self.protocol.tag(), + self.metric_name, + self.model_metric, + self.baseline_metric + ) + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use approx::assert_abs_diff_eq; + use ndarray::array; + + fn meta( + subject: u32, + environment: u32, + orientation: u32, + recording: u64, + window: u64, + ) -> SampleMeta { + SampleMeta { + subject_id: subject, + environment_id: environment, + orientation_id: orientation, + gesture_id: 1, + recording_id: recording, + window_index: window, + } + } + + // ----- LeakageAudit ----------------------------------------------------- + + #[test] + fn audit_passes_clean_cross_subject_split() { + let train = vec![meta(1, 1, 1, 0, 0), meta(1, 1, 1, 0, 1), meta(2, 1, 2, 1, 0)]; + let test = vec![meta(3, 1, 1, 2, 0), meta(3, 1, 1, 2, 1)]; + let pass = LeakageAudit::for_protocol(SplitProtocol::CrossSubject) + .audit(&train, &test) + .expect("clean split must pass"); + assert_eq!(pass.train_windows, 3); + assert_eq!(pass.test_windows, 2); + assert_eq!(pass.train_subjects, 2); + assert_eq!(pass.test_subjects, 1); + assert_eq!(pass.train_recordings, 2); + assert_eq!(pass.test_recordings, 1); + } + + #[test] + fn audit_rejects_subject_overlap() { + let train = vec![meta(1, 1, 1, 0, 0), meta(2, 1, 1, 1, 0)]; + let test = vec![meta(2, 2, 2, 2, 0)]; // subject 2 on both sides + let err = LeakageAudit::for_protocol(SplitProtocol::CrossSubject) + .audit(&train, &test) + .unwrap_err(); + assert!(matches!(err, ProtocolError::SubjectOverlap { subject_id: 2 })); + } + + #[test] + fn audit_rejects_environment_overlap_when_claimed() { + let train = vec![meta(1, 1, 1, 0, 0)]; + let test = vec![meta(2, 1, 2, 1, 0)]; // environment 1 on both sides + let err = LeakageAudit::for_protocol(SplitProtocol::CrossEnvironment) + .audit(&train, &test) + .unwrap_err(); + assert!(matches!( + err, + ProtocolError::EnvironmentOverlap { environment_id: 1 } + )); + // The same split passes a protocol that does not claim env-disjointness. + assert!(LeakageAudit::for_protocol(SplitProtocol::CrossSubject) + .audit(&train, &test) + .is_ok()); + } + + #[test] + fn audit_rejects_orientation_overlap_when_claimed() { + let train = vec![meta(1, 1, 3, 0, 0)]; + let test = vec![meta(2, 2, 3, 1, 0)]; + let err = LeakageAudit::for_protocol(SplitProtocol::CrossOrientation) + .audit(&train, &test) + .unwrap_err(); + assert!(matches!( + err, + ProtocolError::OrientationOverlap { orientation_id: 3 } + )); + } + + #[test] + fn audit_always_rejects_recording_crossing() { + // Even a protocol claiming nothing (RandomBaseline) must fail when a + // continuous recording straddles the boundary. + let train = vec![meta(1, 1, 1, 5, 0)]; + let test = vec![meta(2, 2, 2, 5, 1)]; // same recording 5 + let err = LeakageAudit::for_protocol(SplitProtocol::RandomBaseline) + .audit(&train, &test) + .unwrap_err(); + assert!(matches!( + err, + ProtocolError::RecordingCrossesSplit { recording_id: 5 } + )); + } + + #[test] + fn audit_rejects_empty_partitions() { + let some = vec![meta(1, 1, 1, 0, 0)]; + let audit = LeakageAudit::for_protocol(SplitProtocol::CrossSubject); + assert!(matches!( + audit.audit(&[], &some), + Err(ProtocolError::EmptyPartition { side: "train" }) + )); + assert!(matches!( + audit.audit(&some, &[]), + Err(ProtocolError::EmptyPartition { side: "test" }) + )); + } + + #[test] + fn random_baseline_partition_fails_audit_end_to_end() { + // Wire a real RandomBaseline SplitPlan into the audit: the leaky + // window-level split must be rejected, which is exactly its purpose. + use crate::protocols::SplitPlan; + let mut metas = Vec::new(); + for recording in 0..8u64 { + for window in 0..6u64 { + metas.push(meta(1 + (recording % 3) as u32, 1, 1, recording, window)); + } + } + let plan = SplitPlan::new(SplitProtocol::RandomBaseline, 9, 0.5).unwrap(); + let (train_idx, test_idx) = plan.partition(&metas); + let train: Vec = train_idx.iter().map(|&i| metas[i]).collect(); + let test: Vec = test_idx.iter().map(|&i| metas[i]).collect(); + assert!(matches!( + LeakageAudit::for_protocol(SplitProtocol::RandomBaseline).audit(&train, &test), + Err(ProtocolError::RecordingCrossesSplit { .. }) + )); + } + + // ----- MeanPoseBaseline ------------------------------------------------- + + #[test] + fn mean_pose_is_elementwise_mean_of_training_poses() { + let train = vec![ + array![[0.0f32, 0.0], [1.0, 1.0]], + array![[0.2f32, 0.4], [0.6, 0.0]], + ]; + let baseline = MeanPoseBaseline::fit(&train).unwrap(); + assert_eq!(baseline.num_train_poses(), 2); + let mean = baseline.mean_pose(); + assert_abs_diff_eq!(mean[[0, 0]], 0.1, epsilon = 1e-6); + assert_abs_diff_eq!(mean[[0, 1]], 0.2, epsilon = 1e-6); + assert_abs_diff_eq!(mean[[1, 0]], 0.8, epsilon = 1e-6); + assert_abs_diff_eq!(mean[[1, 1]], 0.5, epsilon = 1e-6); + } + + #[test] + fn mean_pose_mpjpe_math() { + // Baseline fitted on a single pose ⇒ mean equals it exactly. + let train = vec![array![[0.0f32, 0.0], [1.0, 0.0]]]; + let baseline = MeanPoseBaseline::fit(&train).unwrap(); + + // Test pose offset by (0.3, 0.4) on both joints ⇒ distance 0.5 each. + let test = vec![array![[0.3f32, 0.4], [1.3, 0.4]]]; + let mpjpe = baseline.mpjpe(&test).unwrap(); + assert_abs_diff_eq!(mpjpe, 0.5, epsilon = 1e-6); + + // Distances are ~0.5 up to f32 rounding, so probe strictly either + // side: PCK@0.6 counts both joints, PCK@0.49 counts neither. + assert_abs_diff_eq!(baseline.pck_at(&test, 0.6).unwrap(), 1.0, epsilon = 1e-9); + assert_abs_diff_eq!(baseline.pck_at(&test, 0.49).unwrap(), 0.0, epsilon = 1e-9); + } + + #[test] + fn mean_pose_rejects_empty_and_mismatched() { + assert!(matches!( + MeanPoseBaseline::fit(&[]), + Err(ProtocolError::EmptyTrainingPoses) + )); + let train = vec![ + array![[0.0f32, 0.0], [1.0, 1.0]], + array![[0.0f32, 0.0]], // 1 joint vs 2 + ]; + assert!(matches!( + MeanPoseBaseline::fit(&train), + Err(ProtocolError::PoseShapeMismatch { .. }) + )); + + let baseline = MeanPoseBaseline::fit(&[array![[0.0f32, 0.0]]]).unwrap(); + assert!(baseline.mpjpe(&[]).is_err()); + assert!(baseline + .mpjpe(&[array![[0.0f32, 0.0], [1.0, 1.0]]]) + .is_err()); + } + + // ----- EvaluationReport ------------------------------------------------- + + #[test] + fn measured_requires_reproducer() { + let err = EvaluationReport::measured( + SplitProtocol::CrossSubject, + "pck@0.2", + 0.61, + 0.41, + " ", + ) + .unwrap_err(); + assert!(matches!(err, ProtocolError::MissingReproducer)); + + let report = EvaluationReport::measured( + SplitProtocol::CrossSubject, + "pck@0.2", + 0.61, + 0.41, + "cargo run -p wifi-densepose-train --bin train -- eval --protocol cross-subject --seed 42", + ) + .unwrap(); + assert_eq!(report.evidence.tag(), "MEASURED"); + assert_abs_diff_eq!(report.margin_over_baseline(), 0.2, epsilon = 1e-9); + assert!(report.summary().starts_with("[MEASURED] cross-subject pck@0.2")); + } + + #[test] + fn synthetic_and_claimed_tags() { + let s = + EvaluationReport::synthetic(SplitProtocol::CrossOrientation, "mpjpe", 0.1, 0.3) + .unwrap(); + assert_eq!(s.evidence.tag(), "SYNTHETIC"); + let c = EvaluationReport::claimed(SplitProtocol::CrossSubject, "pck@0.5", 0.9, 0.5) + .unwrap(); + assert_eq!(c.evidence.tag(), "CLAIMED"); + } + + #[test] + fn report_rejects_non_finite_metrics() { + assert!(matches!( + EvaluationReport::synthetic(SplitProtocol::CrossSubject, "pck", f64::NAN, 0.5), + Err(ProtocolError::NonFiniteMetric { .. }) + )); + assert!(matches!( + EvaluationReport::synthetic(SplitProtocol::CrossSubject, "pck", 0.5, f64::INFINITY), + Err(ProtocolError::NonFiniteMetric { .. }) + )); + } + + #[test] + fn report_serializes_roundtrip() { + let report = EvaluationReport::measured( + SplitProtocol::CrossEnvironment, + "mpjpe", + 0.07, + 0.19, + "cargo test -p wifi-densepose-train", + ) + .unwrap(); + let json = serde_json::to_string(&report).unwrap(); + let back: EvaluationReport = serde_json::from_str(&json).unwrap(); + assert_eq!(back, report); + } +} diff --git a/v2/crates/wifi-densepose-vitals/Cargo.toml b/v2/crates/wifi-densepose-vitals/Cargo.toml index b235ea37..d6ea9900 100644 --- a/v2/crates/wifi-densepose-vitals/Cargo.toml +++ b/v2/crates/wifi-densepose-vitals/Cargo.toml @@ -23,6 +23,10 @@ criterion = { version = "0.5", features = ["html_reports"] } name = "vitals_bench" harness = false +[[bench]] +name = "groundtruth_bench" +harness = false + [features] default = ["serde"] serde = ["dep:serde"] diff --git a/v2/crates/wifi-densepose-vitals/benches/groundtruth_bench.rs b/v2/crates/wifi-densepose-vitals/benches/groundtruth_bench.rs new file mode 100644 index 00000000..1bb1efae --- /dev/null +++ b/v2/crates/wifi-densepose-vitals/benches/groundtruth_bench.rs @@ -0,0 +1,136 @@ +//! Benchmark for ground-truth time alignment (ADR-293). +//! +//! Aligns an hour-scale synthetic session (3600 s) against a reference +//! series with a known 12 s clock offset, over the default ±30 s lag +//! window. Variants: 1 Hz estimate vs 1 Hz reference (same-rate), 0.5 Hz +//! estimate vs 1 Hz reference (rate-mismatched, the realistic CSI case), +//! and same-rate with the windowed drift fit enabled. The lag search is +//! O(lags × grid points) with no per-lag allocation; this bench tracks that +//! cost at realistic session length. All input is generated in code and +//! fully deterministic; measurement time is kept short deliberately. +//! +//! Reproduce: +//! cargo bench -p wifi-densepose-vitals --bench groundtruth_bench +//! Compile-only: +//! cargo bench -p wifi-densepose-vitals --bench groundtruth_bench --no-run + +use criterion::{black_box, criterion_group, criterion_main, Criterion}; +use std::time::Duration; +use wifi_densepose_vitals::groundtruth::{ + align, AlignmentConfig, EstimateSeries, Measurand, MeasurementPrinciple, ReferenceDevice, + ReferenceSample, ReferenceSeries, +}; + +/// Session length in seconds (one hour). +const SESSION_SECS: usize = 3600; + +/// Known clock offset injected into the estimate series, milliseconds. +const OFFSET_MS: i64 = 12_000; + +/// Deterministic aperiodic heart-rate-like signal (incommensurate periods). +fn synth(t_secs: f64) -> f64 { + 70.0 + 5.0 * (2.0 * std::f64::consts::PI * t_secs / 47.0).sin() + + 3.0 * (2.0 * std::f64::consts::PI * t_secs / 113.0).sin() +} + +/// Reference series: `SESSION_SECS` samples at 1 Hz on the reference clock. +fn reference_1hz() -> ReferenceSeries { + ReferenceSeries::new( + Measurand::HeartRateBpm, + ReferenceDevice { + make: "Synthetic".to_string(), + model: "bench".to_string(), + principle: MeasurementPrinciple::Other, + }, + (0..SESSION_SECS) + .map(|i| ReferenceSample { + timestamp_ms: (i as i64) * 1000, + value: synth(i as f64), + }) + .collect(), + ) + .expect("valid reference") +} + +/// Estimate series at `period_ms` sampling, shifted `OFFSET_MS` earlier. +fn estimate(period_ms: i64) -> EstimateSeries { + let n = (SESSION_SECS as i64 * 1000) / period_ms; + EstimateSeries::new( + Measurand::HeartRateBpm, + (0..n) + .map(|i| { + let t_ms = i * period_ms; + ReferenceSample { + timestamp_ms: t_ms - OFFSET_MS, + value: synth(t_ms as f64 / 1000.0), + } + }) + .collect(), + ) + .expect("valid estimate") +} + +fn bench_align_hour_session(c: &mut Criterion) { + let reference = reference_1hz(); + let est_1hz = estimate(1000); + let est_half_hz = estimate(2000); + let cfg = AlignmentConfig::default(); + + // 1 Hz estimate vs 1 Hz reference: exact offset recovery expected. + c.bench_function("groundtruth_align_1h_est1hz_ref1hz_pm30s", |b| { + b.iter(|| { + let result = align(black_box(&est_1hz), black_box(&reference), black_box(&cfg)) + .expect("alignment succeeds"); + assert_eq!(result.offset_ms, OFFSET_MS); + black_box(result); + }); + }); + + // 0.5 Hz estimate vs 1 Hz reference: the realistic CSI-pipeline case. + // Nearest-sample resampling quantizes, so allow one grid step of slack. + c.bench_function("groundtruth_align_1h_est0p5hz_ref1hz_pm30s", |b| { + b.iter(|| { + let result = align( + black_box(&est_half_hz), + black_box(&reference), + black_box(&cfg), + ) + .expect("alignment succeeds"); + assert!((result.offset_ms - OFFSET_MS).abs() <= cfg.grid_step_ms); + black_box(result); + }); + }); + + // Same-rate alignment with the windowed linear drift fit enabled. + let cfg_drift = AlignmentConfig { + fit_drift: true, + ..AlignmentConfig::default() + }; + c.bench_function("groundtruth_align_1h_est1hz_ref1hz_pm30s_drift", |b| { + b.iter(|| { + let result = align( + black_box(&est_1hz), + black_box(&reference), + black_box(&cfg_drift), + ) + .expect("alignment succeeds"); + black_box(result); + }); + }); +} + +/// Short measurement window: each iteration is an hour-scale alignment, so +/// default criterion settings would make the suite needlessly slow. +fn short_config() -> Criterion { + Criterion::default() + .warm_up_time(Duration::from_millis(500)) + .measurement_time(Duration::from_secs(3)) + .sample_size(10) +} + +criterion_group! { + name = benches; + config = short_config(); + targets = bench_align_hour_session +} +criterion_main!(benches); diff --git a/v2/crates/wifi-densepose-vitals/src/groundtruth.rs b/v2/crates/wifi-densepose-vitals/src/groundtruth.rs new file mode 100644 index 00000000..8111b4e6 --- /dev/null +++ b/v2/crates/wifi-densepose-vitals/src/groundtruth.rs @@ -0,0 +1,2001 @@ +//! Ground-truth reference ingest, time alignment, and agreement metrics +//! (ADR-293). +//! +//! Every credible vitals result ships with reference-sensor ground truth +//! (chest strap, pulse oximeter, ECG). This module makes a `MEASURED` vitals +//! claim reachable for RuView by providing: +//! +//! 1. **Reference ingest** ([`ReferenceSeries`]): timestamped reference +//! samples for one measurand, parsed from an untrusted +//! `timestamp_ms,value` CSV export with row-numbered structured errors. +//! 2. **Time alignment** ([`align`]): constant-offset estimation by +//! maximizing normalized cross-correlation over a bounded lag window on a +//! common nearest-sample grid, plus an optional linear clock-drift fit. +//! Alignment parameters are returned in [`AlignmentResult`], never +//! silently applied. +//! 3. **Agreement metrics** ([`AgreementReport`]): paired-sample count, +//! coverage, MAE, RMSE, bias, Bland-Altman 95% limits of agreement, and +//! percent-within-tolerance. A mandatory [`SessionScope`] states subject +//! count, motion, propagation, and distance band — a report without scope +//! cannot exist. +//! 4. **Evidence tagging** ([`GradedAgreementReport`]): +//! [`EvidenceGrade::Measured`] is constructible only through +//! [`GradedAgreementReport::measured`], which requires non-zero paired +//! samples, minimum coverage, and a non-blank reproducer command — +//! enforcement lives in the constructor, not in documentation. +//! +//! Agreement against consumer reference devices is engineering evidence, +//! not medical validation, and never a camera-grade or clinical claim. + +use crate::store::VitalSignStore; +use crate::types::{VitalReading, VitalStatus}; +use std::fmt; + +#[cfg(feature = "serde")] +use serde::{Deserialize, Serialize}; + +// --------------------------------------------------------------------------- +// Bounds for untrusted input +// --------------------------------------------------------------------------- + +/// Maximum number of data rows accepted from a reference CSV. +pub const MAX_CSV_ROWS: usize = 1_000_000; + +/// Maximum absolute timestamp in milliseconds (`2^52` ms, far beyond any +/// realistic unix-millis session). Keeps all i64 offset/span arithmetic in +/// this module overflow-free and every timestamp exactly representable as +/// `f64`. +pub const MAX_TIMESTAMP_ABS_MS: i64 = 1 << 52; + +/// Maximum plausible physiological value in BPM/BrPM accepted at the input +/// boundary. +pub const MAX_VALUE_BPM: f64 = 300.0; + +/// Maximum number of resampled grid points for alignment or agreement. +pub const MAX_GRID_POINTS: usize = 10_000_000; + +/// Minimum coverage fraction required to grade a report `MEASURED`. +pub const MIN_MEASURED_COVERAGE: f64 = 0.5; + +/// Expected CSV header line. +const CSV_HEADER: &str = "timestamp_ms,value"; + +/// Maximum length of untrusted text echoed back inside an error. +const MAX_ERROR_ECHO: usize = 64; + +// --------------------------------------------------------------------------- +// Errors +// --------------------------------------------------------------------------- + +/// Structured error for ground-truth ingest, alignment, and agreement. +/// +/// For CSV input, `row` is the 1-based line number in the file (the header +/// is line 1). For in-memory constructors ([`ReferenceSeries::new`], +/// [`EstimateSeries::new`], [`EstimateSeries::from_readings`]), `row` is the +/// zero-based index of the offending sample/reading. +#[derive(Debug, Clone, PartialEq)] +pub enum GroundTruthError { + /// Input contained no header line. + MissingHeader, + /// Header line did not match `timestamp_ms,value`. Carries a bounded + /// echo of what was found. + BadHeader { + /// The (truncated) header text encountered. + found: String, + }, + /// A data row did not have exactly two comma-separated fields. + WrongFieldCount { + /// Offending row. + row: usize, + /// Number of fields found. + found: usize, + }, + /// A timestamp field failed to parse as an integer. + BadTimestamp { + /// Offending row. + row: usize, + }, + /// A timestamp is outside `±`[`MAX_TIMESTAMP_ABS_MS`]. + TimestampOutOfRange { + /// Offending row. + row: usize, + }, + /// A value field failed to parse as a finite number. + BadValue { + /// Offending row. + row: usize, + }, + /// A value is outside `[0, `[`MAX_VALUE_BPM`]`]`. + ValueOutOfRange { + /// Offending row. + row: usize, + /// The out-of-range value. + value: f64, + }, + /// Timestamps must be strictly increasing; sorting is never applied + /// silently. + NonMonotonicTimestamp { + /// Offending row. + row: usize, + }, + /// No usable samples were present. + NoSamples, + /// More data rows than [`MAX_CSV_ROWS`] (bounded allocation). + TooManyRows { + /// Row limit that was exceeded. + max: usize, + }, + /// Estimate and reference series measure different quantities. + MeasurandMismatch { + /// Measurand of the estimate series. + estimate: Measurand, + /// Measurand of the reference series. + reference: Measurand, + }, + /// An alignment/agreement configuration parameter is invalid. + InvalidConfig(&'static str), + /// The resampled grid would exceed [`MAX_GRID_POINTS`]. + GridTooLarge { + /// Grid points that would be required. + points: u64, + }, + /// Not enough overlapping valid samples for a statistic. + InsufficientOverlap { + /// Minimum overlapping pairs required. + required: usize, + /// Best overlap actually found. + found: usize, + }, + /// Overlapping samples exist but at least one side has zero variance, + /// so normalized cross-correlation is undefined. + ConstantSignal, + /// A report failed the `MEASURED` evidence gate; the message states the + /// failed requirement. + NotMeasured(&'static str), +} + +impl fmt::Display for GroundTruthError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::MissingHeader => write!(f, "missing CSV header line '{CSV_HEADER}'"), + Self::BadHeader { found } => { + write!(f, "bad CSV header: expected '{CSV_HEADER}', found '{found}'") + } + Self::WrongFieldCount { row, found } => { + write!(f, "row {row}: expected 2 comma-separated fields, found {found}") + } + Self::BadTimestamp { row } => { + write!(f, "row {row}: timestamp is not a valid integer") + } + Self::TimestampOutOfRange { row } => { + write!(f, "row {row}: timestamp outside ±{MAX_TIMESTAMP_ABS_MS} ms") + } + Self::BadValue { row } => write!(f, "row {row}: value is not a finite number"), + Self::ValueOutOfRange { row, value } => { + write!(f, "row {row}: value {value} outside [0, {MAX_VALUE_BPM}]") + } + Self::NonMonotonicTimestamp { row } => { + write!(f, "row {row}: timestamps must be strictly increasing") + } + Self::NoSamples => write!(f, "no usable samples"), + Self::TooManyRows { max } => write!(f, "more than {max} data rows"), + Self::MeasurandMismatch { estimate, reference } => write!( + f, + "measurand mismatch: estimate is {estimate:?}, reference is {reference:?}" + ), + Self::InvalidConfig(msg) => write!(f, "invalid configuration: {msg}"), + Self::GridTooLarge { points } => { + write!(f, "resampled grid of {points} points exceeds {MAX_GRID_POINTS}") + } + Self::InsufficientOverlap { required, found } => write!( + f, + "insufficient overlap: required {required} paired samples, found {found}" + ), + Self::ConstantSignal => { + write!(f, "constant signal: normalized cross-correlation undefined") + } + Self::NotMeasured(msg) => write!(f, "MEASURED evidence gate failed: {msg}"), + } + } +} + +impl std::error::Error for GroundTruthError {} + +// --------------------------------------------------------------------------- +// Reference series +// --------------------------------------------------------------------------- + +/// Quantity a reference or estimate series measures. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub enum Measurand { + /// Heart rate, beats per minute. + HeartRateBpm, + /// Breathing (respiratory) rate, breaths per minute. + BreathingRateBrpm, +} + +impl Measurand { + /// Default agreement tolerance for this measurand (ADR-293: ±2 bpm for + /// heart rate, ±1 brpm for breathing). + #[must_use] + pub fn default_tolerance_bpm(self) -> f64 { + match self { + Self::HeartRateBpm => 2.0, + Self::BreathingRateBrpm => 1.0, + } + } +} + +/// Measurement principle of a reference device. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub enum MeasurementPrinciple { + /// Electrocardiography (e.g. chest strap ECG). + Ecg, + /// Photoplethysmography (e.g. pulse oximeter, optical wrist sensor). + Ppg, + /// Respiratory effort band / chest expansion. + RespiratoryBand, + /// Capnography. + Capnography, + /// Manually counted. + Manual, + /// Anything else; state it in the device model string. + Other, +} + +/// Metadata identifying the reference device a series came from. +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub struct ReferenceDevice { + /// Device make, e.g. `"Polar"`. + pub make: String, + /// Device model, e.g. `"H10"`. + pub model: String, + /// Measurement principle. + pub principle: MeasurementPrinciple, +} + +/// One timestamped sample. +#[derive(Debug, Clone, Copy, PartialEq)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub struct ReferenceSample { + /// Unix timestamp in milliseconds. + pub timestamp_ms: i64, + /// Value in BPM (heart rate) or BrPM (breathing rate). + pub value: f64, +} + +/// Validate one sample at index/row `row` against the previous timestamp. +fn validate_sample( + row: usize, + timestamp_ms: i64, + value: f64, + prev_ts: Option, +) -> Result<(), GroundTruthError> { + if timestamp_ms.abs() > MAX_TIMESTAMP_ABS_MS { + return Err(GroundTruthError::TimestampOutOfRange { row }); + } + if !value.is_finite() { + return Err(GroundTruthError::BadValue { row }); + } + if !(0.0..=MAX_VALUE_BPM).contains(&value) { + return Err(GroundTruthError::ValueOutOfRange { row, value }); + } + if let Some(prev) = prev_ts { + if timestamp_ms <= prev { + return Err(GroundTruthError::NonMonotonicTimestamp { row }); + } + } + Ok(()) +} + +/// Validate an in-memory sample slice (row = zero-based index). +fn validate_samples(samples: &[ReferenceSample]) -> Result<(), GroundTruthError> { + if samples.is_empty() { + return Err(GroundTruthError::NoSamples); + } + let mut prev: Option = None; + for (i, s) in samples.iter().enumerate() { + validate_sample(i, s.timestamp_ms, s.value, prev)?; + prev = Some(s.timestamp_ms); + } + Ok(()) +} + +/// A reference-device time series for one measurand. +/// +/// Samples are guaranteed non-empty, finite, in-range, and strictly +/// increasing in time — the invariant is enforced by every constructor, so +/// downstream alignment/agreement code never re-checks it. +#[derive(Debug, Clone, PartialEq)] +#[cfg_attr(feature = "serde", derive(Serialize))] +pub struct ReferenceSeries { + measurand: Measurand, + device: ReferenceDevice, + samples: Vec, +} + +impl ReferenceSeries { + /// Build a series from in-memory samples, validating the invariant. + /// + /// Errors use the zero-based sample index as `row`. + pub fn new( + measurand: Measurand, + device: ReferenceDevice, + samples: Vec, + ) -> Result { + validate_samples(&samples)?; + Ok(Self { + measurand, + device, + samples, + }) + } + + /// Parse an untrusted `timestamp_ms,value` CSV export. + /// + /// The first non-blank line must be the header `timestamp_ms,value` + /// (a UTF-8 BOM is tolerated). Blank lines are skipped; every other + /// line must be `,`. Malformed rows are + /// rejected with 1-based line numbers; non-monotonic timestamps are an + /// error, never silently sorted. At most [`MAX_CSV_ROWS`] data rows are + /// accepted. + pub fn parse_csv( + measurand: Measurand, + device: ReferenceDevice, + text: &str, + ) -> Result { + Self::parse_csv_bounded(measurand, device, text, MAX_CSV_ROWS) + } + + /// [`Self::parse_csv`] with an explicit row limit (tested directly). + fn parse_csv_bounded( + measurand: Measurand, + device: ReferenceDevice, + text: &str, + max_rows: usize, + ) -> Result { + let mut saw_header = false; + let mut samples: Vec = Vec::new(); + let mut prev_ts: Option = None; + + for (idx, raw) in text.lines().enumerate() { + let row = idx + 1; + let line = raw.trim_start_matches('\u{feff}').trim(); + if line.is_empty() { + continue; + } + if !saw_header { + if line != CSV_HEADER { + let mut found: String = line.chars().take(MAX_ERROR_ECHO).collect(); + if found.len() < line.len() { + found.push('…'); + } + return Err(GroundTruthError::BadHeader { found }); + } + saw_header = true; + continue; + } + if samples.len() >= max_rows { + return Err(GroundTruthError::TooManyRows { max: max_rows }); + } + let fields: Vec<&str> = line.split(',').collect(); + if fields.len() != 2 { + return Err(GroundTruthError::WrongFieldCount { + row, + found: fields.len(), + }); + } + let timestamp_ms: i64 = fields[0] + .trim() + .parse() + .map_err(|_| GroundTruthError::BadTimestamp { row })?; + let value: f64 = fields[1] + .trim() + .parse() + .map_err(|_| GroundTruthError::BadValue { row })?; + validate_sample(row, timestamp_ms, value, prev_ts)?; + prev_ts = Some(timestamp_ms); + samples.push(ReferenceSample { + timestamp_ms, + value, + }); + } + + if !saw_header { + return Err(GroundTruthError::MissingHeader); + } + if samples.is_empty() { + return Err(GroundTruthError::NoSamples); + } + Ok(Self { + measurand, + device, + samples, + }) + } + + /// The measurand this series records. + #[must_use] + pub fn measurand(&self) -> Measurand { + self.measurand + } + + /// The reference device metadata. + #[must_use] + pub fn device(&self) -> &ReferenceDevice { + &self.device + } + + /// The validated samples (strictly increasing timestamps). + #[must_use] + pub fn samples(&self) -> &[ReferenceSample] { + &self.samples + } +} + +// --------------------------------------------------------------------------- +// Estimate series (CSI-derived) +// --------------------------------------------------------------------------- + +/// A CSI-derived estimate series, extracted from [`VitalReading`]s, carrying +/// the same validated-invariant as [`ReferenceSeries`]. +#[derive(Debug, Clone, PartialEq)] +#[cfg_attr(feature = "serde", derive(Serialize))] +pub struct EstimateSeries { + measurand: Measurand, + samples: Vec, +} + +impl EstimateSeries { + /// Build from in-memory samples, validating the invariant. + /// + /// Errors use the zero-based sample index as `row`. + pub fn new( + measurand: Measurand, + samples: Vec, + ) -> Result { + validate_samples(&samples)?; + Ok(Self { measurand, samples }) + } + + /// Extract one measurand from a slice of pipeline readings. + /// + /// Readings whose selected estimate has [`VitalStatus::Unavailable`] are + /// skipped (Degraded/Unreliable estimates are kept — honest agreement + /// statistics must include them). `timestamp_secs` is converted to unix + /// milliseconds; non-finite or out-of-range timestamps/values are + /// structured errors carrying the zero-based reading index as `row`. + pub fn from_readings( + measurand: Measurand, + readings: &[VitalReading], + ) -> Result { + let mut samples: Vec = Vec::new(); + let mut prev_ts: Option = None; + for (row, reading) in readings.iter().enumerate() { + let est = match measurand { + Measurand::HeartRateBpm => &reading.heart_rate, + Measurand::BreathingRateBrpm => &reading.respiratory_rate, + }; + if est.status == VitalStatus::Unavailable { + continue; + } + let ts_ms_f = reading.timestamp_secs * 1000.0; + if !ts_ms_f.is_finite() || ts_ms_f.abs() > MAX_TIMESTAMP_ABS_MS as f64 { + return Err(GroundTruthError::TimestampOutOfRange { row }); + } + #[allow(clippy::cast_possible_truncation)] + let timestamp_ms = ts_ms_f.round() as i64; + validate_sample(row, timestamp_ms, est.value_bpm, prev_ts)?; + prev_ts = Some(timestamp_ms); + samples.push(ReferenceSample { + timestamp_ms, + value: est.value_bpm, + }); + } + if samples.is_empty() { + return Err(GroundTruthError::NoSamples); + } + Ok(Self { measurand, samples }) + } + + /// Extract one measurand from everything currently held in a + /// [`VitalSignStore`] session. + /// + /// Takes `&mut` because [`VitalSignStore::history`] rotates its ring + /// buffer in place; contents are unchanged. + pub fn from_store( + measurand: Measurand, + store: &mut VitalSignStore, + ) -> Result { + let n = store.len(); + Self::from_readings(measurand, store.history(n)) + } + + /// The measurand this series records. + #[must_use] + pub fn measurand(&self) -> Measurand { + self.measurand + } + + /// The validated samples (strictly increasing timestamps). + #[must_use] + pub fn samples(&self) -> &[ReferenceSample] { + &self.samples + } +} + +// --------------------------------------------------------------------------- +// Resampling +// --------------------------------------------------------------------------- + +/// Number of grid points spanning `[start, end]` at `step` ms, bounded by +/// [`MAX_GRID_POINTS`]. +fn grid_len(start_ms: i64, end_ms: i64, step_ms: i64) -> Result { + debug_assert!(end_ms >= start_ms && step_ms > 0); + let points = (end_ms - start_ms) / step_ms + 1; + let points_u = points as u64; + if points_u > MAX_GRID_POINTS as u64 { + return Err(GroundTruthError::GridTooLarge { points: points_u }); + } + Ok(points as usize) +} + +/// Nearest-sample resampling onto a uniform grid. +/// +/// A grid point at time `t` takes the value of the nearest sample if that +/// sample is within `max_dist_ms`; otherwise the grid point is `None`. No +/// interpolation is performed, so physiological values are never bridged +/// across gaps: with `max_dist_ms = max_gap_ms / 2`, two samples further +/// apart than `max_gap_ms` leave uncovered grid points between them. +fn resample_nearest( + samples: &[ReferenceSample], + grid_start_ms: i64, + step_ms: i64, + n_points: usize, + max_dist_ms: i64, +) -> Vec> { + debug_assert!(!samples.is_empty()); + let mut out = Vec::with_capacity(n_points); + let mut j = 0usize; + for i in 0..n_points { + let t = grid_start_ms + (i as i64) * step_ms; + while j + 1 < samples.len() + && (samples[j + 1].timestamp_ms - t).abs() < (samples[j].timestamp_ms - t).abs() + { + j += 1; + } + let dist = (samples[j].timestamp_ms - t).abs(); + out.push(if dist <= max_dist_ms { + Some(samples[j].value) + } else { + None + }); + } + out +} + +// --------------------------------------------------------------------------- +// Time alignment +// --------------------------------------------------------------------------- + +/// Configuration for [`align`]. +#[derive(Debug, Clone)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub struct AlignmentConfig { + /// Bounded lag search window in milliseconds (default ±30 s). + pub max_lag_ms: i64, + /// Common resampling grid step in milliseconds (also the offset + /// resolution; default 1000). + pub grid_step_ms: i64, + /// Maximum gap in milliseconds across which values may be carried to a + /// grid point (nearest-sample within `max_gap_ms / 2`; default 5000). + pub max_gap_ms: i64, + /// Minimum overlapping valid pairs required at a candidate lag + /// (default 10). + pub min_overlap: usize, + /// Whether to additionally fit a linear clock drift (default false). + pub fit_drift: bool, + /// Number of windows for the drift fit (default 4, minimum 2). + pub drift_windows: usize, +} + +impl Default for AlignmentConfig { + fn default() -> Self { + Self { + max_lag_ms: 30_000, + grid_step_ms: 1000, + max_gap_ms: 5000, + min_overlap: 10, + fit_drift: false, + drift_windows: 4, + } + } +} + +impl AlignmentConfig { + fn validate(&self) -> Result<(), GroundTruthError> { + if self.grid_step_ms <= 0 { + return Err(GroundTruthError::InvalidConfig("grid_step_ms must be > 0")); + } + if self.max_gap_ms <= 0 { + return Err(GroundTruthError::InvalidConfig("max_gap_ms must be > 0")); + } + if self.max_lag_ms < 0 || self.max_lag_ms > MAX_TIMESTAMP_ABS_MS { + return Err(GroundTruthError::InvalidConfig( + "max_lag_ms must be in [0, 2^52]", + )); + } + if self.min_overlap < 2 { + return Err(GroundTruthError::InvalidConfig("min_overlap must be >= 2")); + } + if self.fit_drift && self.drift_windows < 2 { + return Err(GroundTruthError::InvalidConfig( + "drift_windows must be >= 2 when fit_drift is set", + )); + } + Ok(()) + } +} + +/// Optional linear clock-drift fit: the estimated offset as a linear +/// function of time, `offset(t) ≈ offset_at_start_ms + rate_ppm * 1e-6 * t`, +/// with `t` measured from the start of the common grid. +#[derive(Debug, Clone, PartialEq)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub struct DriftFit { + /// Fitted offset at the start of the common grid, milliseconds. + pub offset_at_start_ms: f64, + /// Fitted clock rate difference, parts per million (positive: the + /// estimate clock runs slow relative to the reference clock). + pub rate_ppm: f64, + /// Number of windows that produced a usable local offset. + pub windows_used: usize, +} + +/// Result of [`align`]. Parameters are reported here and must be passed +/// explicitly to [`AgreementReport::compute`] — they are never silently +/// applied to any series. +#[derive(Debug, Clone, PartialEq)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub struct AlignmentResult { + /// Estimated constant clock offset in milliseconds: add this to + /// estimate timestamps to map them onto the reference clock. + pub offset_ms: i64, + /// Peak normalized cross-correlation at the chosen offset, in `[-1, 1]`. + pub peak_ncc: f64, + /// Number of overlapping valid grid pairs at the chosen offset. + pub n_overlap: usize, + /// Grid step used, milliseconds (the offset resolution). + pub grid_step_ms: i64, + /// Optional linear clock-drift fit (requested via + /// [`AlignmentConfig::fit_drift`]; `None` if too few windows aligned). + pub drift: Option, +} + +/// Best lag found by a bounded normalized cross-correlation search. +struct LagSearch { + lag_steps: i64, + ncc: f64, + n_overlap: usize, +} + +/// Search lags `-max_lag_steps..=max_lag_steps` for the maximum normalized +/// cross-correlation between `est[i]` and `refg[i + lag]` over pairs where +/// both grids hold a value. Ties prefer the smaller `|lag|` (deterministic: +/// lags are scanned in increasing order). +fn best_lag( + est: &[Option], + refg: &[Option], + max_lag_steps: i64, + min_overlap: usize, +) -> Result { + let n = est.len() as i64; + let mut best: Option = None; + let mut max_overlap_seen = 0usize; + + for lag in -max_lag_steps..=max_lag_steps { + let i_lo = 0.max(-lag); + let i_hi = n.min(n - lag); + if i_hi <= i_lo { + continue; + } + // Zip the two aligned windows once per lag: no per-lag allocation, + // and no per-element bounds check inside the O(lags × n) hot loop. + let est_win = &est[i_lo as usize..i_hi as usize]; + let ref_win = &refg[(i_lo + lag) as usize..(i_hi + lag) as usize]; + let mut count = 0usize; + let (mut se, mut sr, mut see, mut srr, mut ser) = (0.0f64, 0.0, 0.0, 0.0, 0.0); + for (&ev, &rv) in est_win.iter().zip(ref_win) { + let (Some(e), Some(r)) = (ev, rv) else { + continue; + }; + count += 1; + se += e; + sr += r; + see += e * e; + srr += r * r; + ser += e * r; + } + max_overlap_seen = max_overlap_seen.max(count); + if count < min_overlap { + continue; + } + let nf = count as f64; + let var_e = see - se * se / nf; + let var_r = srr - sr * sr / nf; + if var_e <= 0.0 || var_r <= 0.0 { + continue; + } + let ncc = (ser - se * sr / nf) / (var_e * var_r).sqrt(); + let take = match &best { + None => true, + Some(b) => ncc > b.ncc || (ncc == b.ncc && lag.abs() < b.lag_steps.abs()), + }; + if take { + best = Some(LagSearch { + lag_steps: lag, + ncc, + n_overlap: count, + }); + } + } + + best.ok_or({ + if max_overlap_seen < min_overlap { + GroundTruthError::InsufficientOverlap { + required: min_overlap, + found: max_overlap_seen, + } + } else { + GroundTruthError::ConstantSignal + } + }) +} + +/// Fit a linear clock drift from per-window constant offsets. +/// +/// The common grid is split into `cfg.drift_windows` equal windows; each +/// window runs its own bounded lag search, and the resulting +/// (window-center-time, local-offset) points are fit by least squares. +/// Returns `None` when fewer than two windows align. +fn fit_drift( + est: &[Option], + refg: &[Option], + max_lag_steps: i64, + cfg: &AlignmentConfig, +) -> Option { + let n = est.len(); + let windows = cfg.drift_windows; + let mut xs: Vec = Vec::with_capacity(windows); + let mut ys: Vec = Vec::with_capacity(windows); + for w in 0..windows { + let lo = w * n / windows; + let hi = ((w + 1) * n / windows).min(n); + if hi <= lo { + continue; + } + if let Ok(local) = best_lag(&est[lo..hi], &refg[lo..hi], max_lag_steps, cfg.min_overlap) { + let center_ms = ((lo + hi) as f64 / 2.0) * cfg.grid_step_ms as f64; + xs.push(center_ms); + ys.push((local.lag_steps * cfg.grid_step_ms) as f64); + } + } + if xs.len() < 2 { + return None; + } + let nf = xs.len() as f64; + let x_mean = xs.iter().sum::() / nf; + let y_mean = ys.iter().sum::() / nf; + let sxx: f64 = xs.iter().map(|x| (x - x_mean) * (x - x_mean)).sum(); + if sxx <= 0.0 { + return None; + } + let sxy: f64 = xs + .iter() + .zip(&ys) + .map(|(x, y)| (x - x_mean) * (y - y_mean)) + .sum(); + let slope = sxy / sxx; + let intercept = y_mean - slope * x_mean; + Some(DriftFit { + offset_at_start_ms: intercept, + rate_ppm: slope * 1.0e6, + windows_used: xs.len(), + }) +} + +/// Estimate the constant clock offset between a CSI-derived estimate series +/// and a reference series by maximizing normalized cross-correlation over a +/// bounded lag window on a common nearest-sample grid. +/// +/// Grid points further than `max_gap_ms / 2` from any sample are treated as +/// gaps and never bridged. The returned offset has `grid_step_ms` +/// resolution and is **reported, not applied** — pass it explicitly to +/// [`AgreementReport::compute`]. +pub fn align( + estimate: &EstimateSeries, + reference: &ReferenceSeries, + cfg: &AlignmentConfig, +) -> Result { + cfg.validate()?; + if estimate.measurand != reference.measurand { + return Err(GroundTruthError::MeasurandMismatch { + estimate: estimate.measurand, + reference: reference.measurand, + }); + } + let e = estimate.samples(); + let r = reference.samples(); + let start = e[0].timestamp_ms.min(r[0].timestamp_ms); + let end = e[e.len() - 1] + .timestamp_ms + .max(r[r.len() - 1].timestamp_ms); + let n = grid_len(start, end, cfg.grid_step_ms)?; + let max_dist = cfg.max_gap_ms / 2; + let eg = resample_nearest(e, start, cfg.grid_step_ms, n, max_dist); + let rg = resample_nearest(r, start, cfg.grid_step_ms, n, max_dist); + let max_lag_steps = cfg.max_lag_ms / cfg.grid_step_ms; + + let global = best_lag(&eg, &rg, max_lag_steps, cfg.min_overlap)?; + let drift = if cfg.fit_drift { + fit_drift(&eg, &rg, max_lag_steps, cfg) + } else { + None + }; + + Ok(AlignmentResult { + offset_ms: global.lag_steps * cfg.grid_step_ms, + peak_ncc: global.ncc, + n_overlap: global.n_overlap, + grid_step_ms: cfg.grid_step_ms, + drift, + }) +} + +// --------------------------------------------------------------------------- +// Session scope +// --------------------------------------------------------------------------- + +/// Subject motion state during a session. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub enum MotionState { + /// Subject seated/lying, minimal movement. + Static, + /// Subject moving during the session. + Moving, +} + +/// RF propagation condition between sensor and subject. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub enum Propagation { + /// Clear line of sight. + LineOfSight, + /// Obstructed within the same room (furniture, people). + NonLineOfSight, + /// Signal traverses at least one wall. + ThroughWall, +} + +/// Coarse sensor-to-subject distance band. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub enum DistanceBand { + /// Up to 2 m. + Near, + /// 2 m to 5 m. + Mid, + /// Beyond 5 m. + Far, +} + +/// Mandatory scope statement for an agreement report (ADR-293): a vitals +/// number without its scope is systematically misleading, so a report +/// cannot be constructed without one. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub struct SessionScope { + /// Number of people in the sensing area during the session. + pub subject_count: u32, + /// Subject motion state. + pub motion: MotionState, + /// RF propagation condition. + pub propagation: Propagation, + /// Sensor-to-subject distance band. + pub distance_band: DistanceBand, +} + +// --------------------------------------------------------------------------- +// Agreement metrics +// --------------------------------------------------------------------------- + +/// Configuration for [`AgreementReport::compute`]. +#[derive(Debug, Clone)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +pub struct AgreementConfig { + /// Pairing grid step in milliseconds (default 1000). + pub grid_step_ms: i64, + /// Maximum gap in milliseconds across which values may be carried to a + /// grid point (nearest-sample within `max_gap_ms / 2`; default 5000). + pub max_gap_ms: i64, + /// Agreement tolerance in BPM; `None` uses + /// [`Measurand::default_tolerance_bpm`] (±2 bpm HR, ±1 brpm breathing). + pub tolerance_bpm: Option, +} + +impl Default for AgreementConfig { + fn default() -> Self { + Self { + grid_step_ms: 1000, + max_gap_ms: 5000, + tolerance_bpm: None, + } + } +} + +impl AgreementConfig { + fn validate(&self) -> Result<(), GroundTruthError> { + if self.grid_step_ms <= 0 { + return Err(GroundTruthError::InvalidConfig("grid_step_ms must be > 0")); + } + if self.max_gap_ms <= 0 { + return Err(GroundTruthError::InvalidConfig("max_gap_ms must be > 0")); + } + if let Some(t) = self.tolerance_bpm { + if !t.is_finite() || t <= 0.0 { + return Err(GroundTruthError::InvalidConfig( + "tolerance_bpm must be finite and > 0", + )); + } + } + Ok(()) + } +} + +/// Agreement statistics between an aligned estimate series and a reference +/// series. Differences are `estimate - reference` in BPM. +/// +/// The [`SessionScope`] field is mandatory by type: no report exists +/// without its scope. `applied_offset_ms` records the alignment that was +/// explicitly applied for pairing. +#[derive(Debug, Clone, PartialEq)] +#[cfg_attr(feature = "serde", derive(Serialize))] +pub struct AgreementReport { + /// Measurand compared. + pub measurand: Measurand, + /// Reference device the estimates were compared against. + pub device: ReferenceDevice, + /// Mandatory session scope. + pub scope: SessionScope, + /// Constant clock offset (ms) that was explicitly applied to estimate + /// timestamps for pairing. + pub applied_offset_ms: i64, + /// Number of paired samples. + pub n_pairs: usize, + /// Fraction of the overlapping-span grid where both series had a valid + /// sample, in `[0, 1]`. + pub coverage: f64, + /// Mean absolute error, BPM. + pub mae_bpm: f64, + /// Root-mean-square error, BPM. + pub rmse_bpm: f64, + /// Mean error (bias), BPM. + pub bias_bpm: f64, + /// Bland-Altman lower 95% limit of agreement (`bias - 1.96·SD`), BPM. + pub loa_lower_bpm: f64, + /// Bland-Altman upper 95% limit of agreement (`bias + 1.96·SD`), BPM. + pub loa_upper_bpm: f64, + /// Tolerance used for `within_tolerance_fraction`, BPM. + pub tolerance_bpm: f64, + /// Fraction of pairs with `|estimate - reference| <= tolerance_bpm`. + pub within_tolerance_fraction: f64, +} + +impl AgreementReport { + /// Compute agreement statistics between an estimate and a reference + /// series, applying the given constant clock offset (typically + /// [`AlignmentResult::offset_ms`]) to the estimate timestamps. + /// + /// The offset is a required, explicit argument — alignment is never + /// applied silently — and is echoed back in `applied_offset_ms`. + /// Pairing uses nearest-sample resampling on a grid over the + /// overlapping span; gaps wider than `max_gap_ms` are never bridged. + /// At least two pairs are required (Bland-Altman limits need a sample + /// standard deviation). + pub fn compute( + estimate: &EstimateSeries, + reference: &ReferenceSeries, + applied_offset_ms: i64, + cfg: &AgreementConfig, + scope: SessionScope, + ) -> Result { + cfg.validate()?; + if applied_offset_ms.abs() > MAX_TIMESTAMP_ABS_MS { + return Err(GroundTruthError::InvalidConfig( + "applied_offset_ms out of range", + )); + } + if estimate.measurand != reference.measurand { + return Err(GroundTruthError::MeasurandMismatch { + estimate: estimate.measurand, + reference: reference.measurand, + }); + } + let e = estimate.samples(); + let r = reference.samples(); + // Overlapping span on the reference clock; |ts| <= 2^52 and + // |offset| <= 2^52 keep the sums well inside i64. + let e_start = e[0].timestamp_ms + applied_offset_ms; + let e_end = e[e.len() - 1].timestamp_ms + applied_offset_ms; + let start = e_start.max(r[0].timestamp_ms); + let end = e_end.min(r[r.len() - 1].timestamp_ms); + if end < start { + return Err(GroundTruthError::InsufficientOverlap { + required: 2, + found: 0, + }); + } + let n_grid = grid_len(start, end, cfg.grid_step_ms)?; + let max_dist = cfg.max_gap_ms / 2; + let eg = resample_nearest( + e, + start - applied_offset_ms, + cfg.grid_step_ms, + n_grid, + max_dist, + ); + let rg = resample_nearest(r, start, cfg.grid_step_ms, n_grid, max_dist); + + let diffs: Vec = eg + .iter() + .zip(&rg) + .filter_map(|(ev, rv)| match (ev, rv) { + (Some(ev), Some(rv)) => Some(ev - rv), + _ => None, + }) + .collect(); + let n_pairs = diffs.len(); + if n_pairs < 2 { + return Err(GroundTruthError::InsufficientOverlap { + required: 2, + found: n_pairs, + }); + } + + let nf = n_pairs as f64; + let bias = diffs.iter().sum::() / nf; + let mae = diffs.iter().map(|d| d.abs()).sum::() / nf; + let rmse = (diffs.iter().map(|d| d * d).sum::() / nf).sqrt(); + let var = diffs.iter().map(|d| (d - bias) * (d - bias)).sum::() / (nf - 1.0); + let sd = var.sqrt(); + let tolerance = cfg + .tolerance_bpm + .unwrap_or_else(|| estimate.measurand.default_tolerance_bpm()); + let within = diffs.iter().filter(|d| d.abs() <= tolerance).count() as f64 / nf; + + Ok(Self { + measurand: estimate.measurand, + device: reference.device.clone(), + scope, + applied_offset_ms, + n_pairs, + coverage: nf / n_grid as f64, + mae_bpm: mae, + rmse_bpm: rmse, + bias_bpm: bias, + loa_lower_bpm: bias - 1.96 * sd, + loa_upper_bpm: bias + 1.96 * sd, + tolerance_bpm: tolerance, + within_tolerance_fraction: within, + }) + } +} + +// --------------------------------------------------------------------------- +// Evidence grading +// --------------------------------------------------------------------------- + +/// Proof-of-measurement payload for [`EvidenceGrade::Measured`]. +/// +/// Has no public constructor: the only way to obtain one is +/// [`GradedAgreementReport::measured`], which enforces the gate. This makes +/// `MEASURED` unconstructible without passing the gate (ADR-291-style +/// enforcement in types). +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(Serialize))] +pub struct MeasuredEvidence { + reproducer: String, +} + +impl MeasuredEvidence { + /// The exact command line that reproduces the reported numbers. + #[must_use] + pub fn reproducer(&self) -> &str { + &self.reproducer + } +} + +/// Evidence grade per CLAUDE.md tagging rules. +#[derive(Debug, Clone, PartialEq)] +#[cfg_attr(feature = "serde", derive(Serialize))] +pub enum EvidenceGrade { + /// Measured against a real reference device with a reproducer command. + /// Only constructible through [`GradedAgreementReport::measured`]. + Measured(MeasuredEvidence), + /// Real data, but the comparison does not meet the `MEASURED` gate + /// (or is quoted rather than reproduced here). + Claimed, + /// Computed on synthetic/generated input. + Synthetic, +} + +impl EvidenceGrade { + /// Stable uppercase tag (`MEASURED` / `CLAIMED` / `SYNTHETIC`). + #[must_use] + pub fn tag(&self) -> &'static str { + match self { + Self::Measured(_) => "MEASURED", + Self::Claimed => "CLAIMED", + Self::Synthetic => "SYNTHETIC", + } + } +} + +/// An [`AgreementReport`] paired with its [`EvidenceGrade`]. +/// +/// Fields are private; the constructors are the policy: +/// +/// - [`Self::measured`] requires a reference device (structurally present in +/// every computed report), `n_pairs > 0`, coverage of at least +/// [`MIN_MEASURED_COVERAGE`], and a non-blank reproducer command. +/// - [`Self::claimed`] and [`Self::synthetic`] are always available. +#[derive(Debug, Clone, PartialEq)] +#[cfg_attr(feature = "serde", derive(Serialize))] +pub struct GradedAgreementReport { + report: AgreementReport, + evidence: EvidenceGrade, +} + +impl GradedAgreementReport { + /// Grade a report `MEASURED`. The gate is enforced here, not in docs: + /// zero pairs, coverage below [`MIN_MEASURED_COVERAGE`], or a blank + /// reproducer are structured errors. + pub fn measured( + report: AgreementReport, + reproducer: &str, + ) -> Result { + if report.n_pairs == 0 { + return Err(GroundTruthError::NotMeasured( + "zero paired samples against the reference device", + )); + } + if !report.coverage.is_finite() || report.coverage < MIN_MEASURED_COVERAGE { + return Err(GroundTruthError::NotMeasured( + "coverage below the minimum for a measured claim", + )); + } + if reproducer.trim().is_empty() { + return Err(GroundTruthError::NotMeasured( + "a measured number without a reproducer command is not measured", + )); + } + Ok(Self { + report, + evidence: EvidenceGrade::Measured(MeasuredEvidence { + reproducer: reproducer.trim().to_string(), + }), + }) + } + + /// Grade a report `CLAIMED` (real data, gate not met or not reproduced + /// here). + #[must_use] + pub fn claimed(report: AgreementReport) -> Self { + Self { + report, + evidence: EvidenceGrade::Claimed, + } + } + + /// Grade a report `SYNTHETIC` (generated input). + #[must_use] + pub fn synthetic(report: AgreementReport) -> Self { + Self { + report, + evidence: EvidenceGrade::Synthetic, + } + } + + /// The underlying agreement report. + #[must_use] + pub fn report(&self) -> &AgreementReport { + &self.report + } + + /// The evidence grade. + #[must_use] + pub fn evidence(&self) -> &EvidenceGrade { + &self.evidence + } +} + +// --------------------------------------------------------------------------- +// Session evaluation (VitalSignStore integration) +// --------------------------------------------------------------------------- + +/// Alignment plus agreement for one store session, with the alignment +/// parameters visible in both places. +#[derive(Debug, Clone, PartialEq)] +#[cfg_attr(feature = "serde", derive(Serialize))] +pub struct SessionEvaluation { + /// The estimated alignment (offset and optional drift fit). + pub alignment: AlignmentResult, + /// Agreement computed with `alignment.offset_ms` explicitly applied + /// (echoed in `report.applied_offset_ms`). Drift is reported only, + /// never applied. + pub report: AgreementReport, +} + +/// Evaluate everything currently held in a [`VitalSignStore`] session +/// against a reference series: extract the matching measurand, estimate the +/// constant clock offset, and compute agreement with that offset explicitly +/// applied (and reported in the result). +/// +/// Takes `&mut` store because reading history rotates its ring buffer in +/// place; contents are unchanged. +pub fn evaluate_session( + store: &mut VitalSignStore, + reference: &ReferenceSeries, + align_cfg: &AlignmentConfig, + agree_cfg: &AgreementConfig, + scope: SessionScope, +) -> Result { + let estimate = EstimateSeries::from_store(reference.measurand(), store)?; + let alignment = align(&estimate, reference, align_cfg)?; + let report = AgreementReport::compute( + &estimate, + reference, + alignment.offset_ms, + agree_cfg, + scope, + )?; + Ok(SessionEvaluation { alignment, report }) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::{VitalEstimate, VitalReading, VitalStatus}; + + fn device() -> ReferenceDevice { + ReferenceDevice { + make: "Polar".to_string(), + model: "H10".to_string(), + principle: MeasurementPrinciple::Ecg, + } + } + + fn scope() -> SessionScope { + SessionScope { + subject_count: 1, + motion: MotionState::Static, + propagation: Propagation::LineOfSight, + distance_band: DistanceBand::Near, + } + } + + /// Deterministic aperiodic test signal (BPM range), aperiodic within + /// the ±30 s lag window thanks to incommensurate periods. + fn synth(t_secs: f64) -> f64 { + 70.0 + 5.0 * (2.0 * std::f64::consts::PI * t_secs / 47.0).sin() + + 3.0 * (2.0 * std::f64::consts::PI * t_secs / 113.0).sin() + } + + fn series_1hz(start_ms: i64, n: usize, f: impl Fn(f64) -> f64) -> Vec { + (0..n) + .map(|i| { + let ts = start_ms + (i as i64) * 1000; + ReferenceSample { + timestamp_ms: ts, + value: f(ts as f64 / 1000.0), + } + }) + .collect() + } + + // -- CSV parsing -------------------------------------------------------- + + #[test] + fn csv_parses_valid_input() { + let csv = "timestamp_ms,value\n1000,72.5\n2000,73.0\n3000,71.5\n"; + let s = ReferenceSeries::parse_csv(Measurand::HeartRateBpm, device(), csv).unwrap(); + assert_eq!(s.samples().len(), 3); + assert_eq!(s.samples()[0].timestamp_ms, 1000); + assert!((s.samples()[2].value - 71.5).abs() < f64::EPSILON); + assert_eq!(s.measurand(), Measurand::HeartRateBpm); + assert_eq!(s.device().make, "Polar"); + } + + #[test] + fn csv_tolerates_crlf_blank_lines_and_bom() { + let csv = "\u{feff}timestamp_ms,value\r\n\r\n1000,72\r\n2000,73\r\n\r\n"; + let s = ReferenceSeries::parse_csv(Measurand::HeartRateBpm, device(), csv).unwrap(); + assert_eq!(s.samples().len(), 2); + } + + #[test] + fn csv_rejects_empty_input() { + let err = ReferenceSeries::parse_csv(Measurand::HeartRateBpm, device(), "").unwrap_err(); + assert_eq!(err, GroundTruthError::MissingHeader); + } + + #[test] + fn csv_rejects_bad_header() { + let err = ReferenceSeries::parse_csv(Measurand::HeartRateBpm, device(), "time,bpm\n1,2\n") + .unwrap_err(); + assert!(matches!(err, GroundTruthError::BadHeader { .. })); + } + + #[test] + fn csv_bad_header_echo_is_bounded() { + let long = "x".repeat(10_000); + let err = + ReferenceSeries::parse_csv(Measurand::HeartRateBpm, device(), &long).unwrap_err(); + let GroundTruthError::BadHeader { found } = err else { + panic!("expected BadHeader"); + }; + assert!(found.chars().count() <= MAX_ERROR_ECHO + 1); + } + + #[test] + fn csv_rejects_wrong_field_count_with_row_number() { + let csv = "timestamp_ms,value\n1000,72\n2000,73,extra\n"; + let err = ReferenceSeries::parse_csv(Measurand::HeartRateBpm, device(), csv).unwrap_err(); + assert_eq!(err, GroundTruthError::WrongFieldCount { row: 3, found: 3 }); + + let csv = "timestamp_ms,value\njustonefield\n"; + let err = ReferenceSeries::parse_csv(Measurand::HeartRateBpm, device(), csv).unwrap_err(); + assert_eq!(err, GroundTruthError::WrongFieldCount { row: 2, found: 1 }); + } + + #[test] + fn csv_rejects_bad_timestamp_with_row_number() { + let csv = "timestamp_ms,value\n1000,72\nnot_a_ts,73\n"; + let err = ReferenceSeries::parse_csv(Measurand::HeartRateBpm, device(), csv).unwrap_err(); + assert_eq!(err, GroundTruthError::BadTimestamp { row: 3 }); + } + + #[test] + fn csv_rejects_bad_and_nonfinite_values() { + let csv = "timestamp_ms,value\n1000,abc\n"; + let err = ReferenceSeries::parse_csv(Measurand::HeartRateBpm, device(), csv).unwrap_err(); + assert_eq!(err, GroundTruthError::BadValue { row: 2 }); + + let csv = "timestamp_ms,value\n1000,NaN\n"; + let err = ReferenceSeries::parse_csv(Measurand::HeartRateBpm, device(), csv).unwrap_err(); + assert_eq!(err, GroundTruthError::BadValue { row: 2 }); + + let csv = "timestamp_ms,value\n1000,inf\n"; + let err = ReferenceSeries::parse_csv(Measurand::HeartRateBpm, device(), csv).unwrap_err(); + assert_eq!(err, GroundTruthError::BadValue { row: 2 }); + } + + #[test] + fn csv_rejects_out_of_range_value() { + let csv = "timestamp_ms,value\n1000,400\n"; + let err = ReferenceSeries::parse_csv(Measurand::HeartRateBpm, device(), csv).unwrap_err(); + assert!(matches!(err, GroundTruthError::ValueOutOfRange { row: 2, .. })); + + let csv = "timestamp_ms,value\n1000,-1\n"; + let err = ReferenceSeries::parse_csv(Measurand::HeartRateBpm, device(), csv).unwrap_err(); + assert!(matches!(err, GroundTruthError::ValueOutOfRange { row: 2, .. })); + } + + #[test] + fn csv_rejects_non_monotonic_timestamps() { + // Decreasing. + let csv = "timestamp_ms,value\n2000,72\n1000,73\n"; + let err = ReferenceSeries::parse_csv(Measurand::HeartRateBpm, device(), csv).unwrap_err(); + assert_eq!(err, GroundTruthError::NonMonotonicTimestamp { row: 3 }); + + // Duplicate. + let csv = "timestamp_ms,value\n2000,72\n2000,73\n"; + let err = ReferenceSeries::parse_csv(Measurand::HeartRateBpm, device(), csv).unwrap_err(); + assert_eq!(err, GroundTruthError::NonMonotonicTimestamp { row: 3 }); + } + + #[test] + fn csv_rejects_timestamp_out_of_range() { + let csv = format!("timestamp_ms,value\n{},72\n", i64::MAX); + let err = ReferenceSeries::parse_csv(Measurand::HeartRateBpm, device(), &csv).unwrap_err(); + // i64::MAX parses fine but exceeds the 2^52 bound. + assert_eq!(err, GroundTruthError::TimestampOutOfRange { row: 2 }); + } + + #[test] + fn csv_rejects_header_only() { + let err = ReferenceSeries::parse_csv(Measurand::HeartRateBpm, device(), "timestamp_ms,value\n") + .unwrap_err(); + assert_eq!(err, GroundTruthError::NoSamples); + } + + #[test] + fn csv_row_limit_is_enforced() { + let csv = "timestamp_ms,value\n1000,70\n2000,71\n3000,72\n"; + let err = + ReferenceSeries::parse_csv_bounded(Measurand::HeartRateBpm, device(), csv, 2) + .unwrap_err(); + assert_eq!(err, GroundTruthError::TooManyRows { max: 2 }); + } + + #[test] + fn series_new_validates_and_reports_index() { + let err = ReferenceSeries::new(Measurand::HeartRateBpm, device(), vec![]).unwrap_err(); + assert_eq!(err, GroundTruthError::NoSamples); + + let samples = vec![ + ReferenceSample { + timestamp_ms: 2000, + value: 70.0, + }, + ReferenceSample { + timestamp_ms: 1000, + value: 71.0, + }, + ]; + let err = ReferenceSeries::new(Measurand::HeartRateBpm, device(), samples).unwrap_err(); + assert_eq!(err, GroundTruthError::NonMonotonicTimestamp { row: 1 }); + } + + // -- Alignment ---------------------------------------------------------- + + fn cfg_default() -> AlignmentConfig { + AlignmentConfig::default() + } + + #[test] + fn alignment_recovers_zero_offset() { + let reference = ReferenceSeries::new( + Measurand::HeartRateBpm, + device(), + series_1hz(0, 300, synth), + ) + .unwrap(); + let estimate = EstimateSeries::new( + Measurand::HeartRateBpm, + series_1hz(0, 300, synth), + ) + .unwrap(); + let result = align(&estimate, &reference, &cfg_default()).unwrap(); + assert_eq!(result.offset_ms, 0); + assert!(result.peak_ncc > 0.999); + assert!(result.drift.is_none()); + } + + #[test] + fn alignment_recovers_known_positive_offset() { + // Estimate device stamps events 7 s early: an estimate sample at + // its own clock time t carries the reference value at t + 7 s, so + // reference_time = estimate_time + 7000. + let reference = ReferenceSeries::new( + Measurand::HeartRateBpm, + device(), + series_1hz(0, 600, synth), + ) + .unwrap(); + let est_samples: Vec = (0..600) + .map(|i| ReferenceSample { + timestamp_ms: (i as i64) * 1000 - 7000, + value: synth(i as f64), + }) + .collect(); + let estimate = EstimateSeries::new(Measurand::HeartRateBpm, est_samples).unwrap(); + let result = align(&estimate, &reference, &cfg_default()).unwrap(); + assert_eq!(result.offset_ms, 7000); + assert!(result.peak_ncc > 0.999); + } + + #[test] + fn alignment_recovers_known_negative_offset() { + let reference = ReferenceSeries::new( + Measurand::HeartRateBpm, + device(), + series_1hz(0, 600, synth), + ) + .unwrap(); + let est_samples: Vec = (0..600) + .map(|i| ReferenceSample { + timestamp_ms: (i as i64) * 1000 + 11_000, + value: synth(i as f64), + }) + .collect(); + let estimate = EstimateSeries::new(Measurand::HeartRateBpm, est_samples).unwrap(); + let result = align(&estimate, &reference, &cfg_default()).unwrap(); + assert_eq!(result.offset_ms, -11_000); + assert!(result.peak_ncc > 0.999); + } + + #[test] + fn alignment_rejects_measurand_mismatch() { + let reference = ReferenceSeries::new( + Measurand::BreathingRateBrpm, + device(), + series_1hz(0, 60, |t| { + 15.0 + (t / 20.0).sin() + }), + ) + .unwrap(); + let estimate = EstimateSeries::new( + Measurand::HeartRateBpm, + series_1hz(0, 60, synth), + ) + .unwrap(); + let err = align(&estimate, &reference, &cfg_default()).unwrap_err(); + assert!(matches!(err, GroundTruthError::MeasurandMismatch { .. })); + } + + #[test] + fn alignment_rejects_insufficient_overlap() { + // Series 10 minutes apart with a ±30 s window: no lag overlaps. + let reference = ReferenceSeries::new( + Measurand::HeartRateBpm, + device(), + series_1hz(0, 60, synth), + ) + .unwrap(); + let estimate = EstimateSeries::new( + Measurand::HeartRateBpm, + series_1hz(600_000, 60, synth), + ) + .unwrap(); + let err = align(&estimate, &reference, &cfg_default()).unwrap_err(); + assert!(matches!(err, GroundTruthError::InsufficientOverlap { .. })); + } + + #[test] + fn alignment_rejects_constant_signal() { + let reference = ReferenceSeries::new( + Measurand::HeartRateBpm, + device(), + series_1hz(0, 60, |_| 70.0), + ) + .unwrap(); + let estimate = EstimateSeries::new( + Measurand::HeartRateBpm, + series_1hz(0, 60, |_| 70.0), + ) + .unwrap(); + let err = align(&estimate, &reference, &cfg_default()).unwrap_err(); + assert_eq!(err, GroundTruthError::ConstantSignal); + } + + #[test] + fn alignment_rejects_invalid_config() { + let reference = ReferenceSeries::new( + Measurand::HeartRateBpm, + device(), + series_1hz(0, 60, synth), + ) + .unwrap(); + let estimate = EstimateSeries::new( + Measurand::HeartRateBpm, + series_1hz(0, 60, synth), + ) + .unwrap(); + let cfg = AlignmentConfig { + grid_step_ms: 0, + ..AlignmentConfig::default() + }; + assert!(matches!( + align(&estimate, &reference, &cfg).unwrap_err(), + GroundTruthError::InvalidConfig(_) + )); + let cfg = AlignmentConfig { + fit_drift: true, + drift_windows: 1, + ..AlignmentConfig::default() + }; + assert!(matches!( + align(&estimate, &reference, &cfg).unwrap_err(), + GroundTruthError::InvalidConfig(_) + )); + } + + #[test] + fn alignment_drift_fit_recovers_synthetic_drift() { + // The mapping reference_time = estimate_time + offset(t) with + // offset(t) = 2000 ms + 0.01 * t (1% clock-rate error). + let a_ms = 2000.0; + let b = 0.01; + let n_est = 2000usize; + let est_samples: Vec = (0..n_est) + .map(|i| { + let est_ts = (i as i64) * 1000; + let ref_time_s = (est_ts as f64 + a_ms + b * est_ts as f64) / 1000.0; + ReferenceSample { + timestamp_ms: est_ts, + value: synth(ref_time_s), + } + }) + .collect(); + let ref_samples = series_1hz(0, 2101, synth); + let reference = + ReferenceSeries::new(Measurand::HeartRateBpm, device(), ref_samples).unwrap(); + let estimate = EstimateSeries::new(Measurand::HeartRateBpm, est_samples).unwrap(); + let cfg = AlignmentConfig { + fit_drift: true, + ..AlignmentConfig::default() + }; + let result = align(&estimate, &reference, &cfg).unwrap(); + let drift = result.drift.expect("drift fit should succeed"); + assert_eq!(drift.windows_used, 4); + // True rate is 10_000 ppm; local offsets quantize to the 1 s grid, + // so allow a generous but decisive tolerance. + assert!( + (drift.rate_ppm - 10_000.0).abs() < 2000.0, + "rate_ppm = {}", + drift.rate_ppm + ); + assert!( + (drift.offset_at_start_ms - a_ms).abs() < 1500.0, + "offset_at_start_ms = {}", + drift.offset_at_start_ms + ); + } + + #[test] + fn resampling_does_not_bridge_wide_gaps() { + let samples = vec![ + ReferenceSample { + timestamp_ms: 0, + value: 70.0, + }, + ReferenceSample { + timestamp_ms: 10_000, + value: 71.0, + }, + ]; + // max_dist 500 ms: grid points between the two samples stay None. + let grid = resample_nearest(&samples, 0, 1000, 11, 500); + assert_eq!(grid[0], Some(70.0)); + assert_eq!(grid[10], Some(71.0)); + for g in &grid[1..10] { + assert_eq!(*g, None); + } + } + + // -- Agreement ---------------------------------------------------------- + + fn agree_cfg(tolerance: Option) -> AgreementConfig { + AgreementConfig { + grid_step_ms: 1000, + max_gap_ms: 2000, + tolerance_bpm: tolerance, + } + } + + /// Fixture: diffs (est - ref) = [1, -1, 2, 0] over four 1 Hz pairs. + /// + /// Hand-computed: bias = 0.5, MAE = 1.0, RMSE = sqrt(1.5), + /// SD (n-1) = sqrt(5/3), LoA = 0.5 ± 1.96*sqrt(5/3). + fn fixture_pair() -> (EstimateSeries, ReferenceSeries) { + let ref_samples: Vec = (0..4) + .map(|i| ReferenceSample { + timestamp_ms: i * 1000, + value: 70.0, + }) + .collect(); + let est_values = [71.0, 69.0, 72.0, 70.0]; + let est_samples: Vec = est_values + .iter() + .enumerate() + .map(|(i, v)| ReferenceSample { + timestamp_ms: (i as i64) * 1000, + value: *v, + }) + .collect(); + ( + EstimateSeries::new(Measurand::HeartRateBpm, est_samples).unwrap(), + ReferenceSeries::new(Measurand::HeartRateBpm, device(), ref_samples).unwrap(), + ) + } + + #[test] + fn agreement_matches_hand_computed_fixture() { + let (estimate, reference) = fixture_pair(); + let report = + AgreementReport::compute(&estimate, &reference, 0, &agree_cfg(None), scope()).unwrap(); + + assert_eq!(report.n_pairs, 4); + assert!((report.coverage - 1.0).abs() < 1e-12); + assert!((report.bias_bpm - 0.5).abs() < 1e-12); + assert!((report.mae_bpm - 1.0).abs() < 1e-12); + assert!((report.rmse_bpm - 1.5f64.sqrt()).abs() < 1e-12); + let sd = (5.0f64 / 3.0).sqrt(); + assert!((report.loa_lower_bpm - (0.5 - 1.96 * sd)).abs() < 1e-12); + assert!((report.loa_upper_bpm - (0.5 + 1.96 * sd)).abs() < 1e-12); + // Default HR tolerance ±2 bpm: all four diffs are within. + assert!((report.tolerance_bpm - 2.0).abs() < f64::EPSILON); + assert!((report.within_tolerance_fraction - 1.0).abs() < 1e-12); + assert_eq!(report.applied_offset_ms, 0); + assert_eq!(report.scope, scope()); + } + + #[test] + fn agreement_within_tolerance_with_explicit_tolerance() { + let (estimate, reference) = fixture_pair(); + let report = + AgreementReport::compute(&estimate, &reference, 0, &agree_cfg(Some(1.0)), scope()) + .unwrap(); + // Diffs [1, -1, 2, 0]: three of four within ±1. + assert!((report.within_tolerance_fraction - 0.75).abs() < 1e-12); + assert!((report.tolerance_bpm - 1.0).abs() < f64::EPSILON); + } + + #[test] + fn agreement_breathing_default_tolerance_is_1_brpm() { + let ref_samples = series_1hz(0, 10, |_| 15.0); + let est_samples = series_1hz(0, 10, |_| 16.5); + let reference = + ReferenceSeries::new(Measurand::BreathingRateBrpm, device(), ref_samples).unwrap(); + let estimate = EstimateSeries::new(Measurand::BreathingRateBrpm, est_samples).unwrap(); + let report = + AgreementReport::compute(&estimate, &reference, 0, &agree_cfg(None), scope()).unwrap(); + assert!((report.tolerance_bpm - 1.0).abs() < f64::EPSILON); + // All diffs are +1.5 brpm: none within ±1. + assert!((report.within_tolerance_fraction - 0.0).abs() < 1e-12); + assert!((report.bias_bpm - 1.5).abs() < 1e-9); + } + + #[test] + fn agreement_coverage_reflects_unbridged_gaps() { + // Reference covers 0..=10 s; estimate is missing 4..=7 s. With + // max_gap 1000 (max_dist 500), the four gap grid points stay + // unpaired: 7 pairs over an 11-point grid. + let ref_samples = series_1hz(0, 11, synth); + let est_samples: Vec = (0..11) + .filter(|i| !(4..=7).contains(i)) + .map(|i| ReferenceSample { + timestamp_ms: (i as i64) * 1000, + value: synth(i as f64), + }) + .collect(); + let reference = + ReferenceSeries::new(Measurand::HeartRateBpm, device(), ref_samples).unwrap(); + let estimate = EstimateSeries::new(Measurand::HeartRateBpm, est_samples).unwrap(); + let cfg = AgreementConfig { + grid_step_ms: 1000, + max_gap_ms: 1000, + tolerance_bpm: None, + }; + let report = AgreementReport::compute(&estimate, &reference, 0, &cfg, scope()).unwrap(); + assert_eq!(report.n_pairs, 7); + assert!((report.coverage - 7.0 / 11.0).abs() < 1e-12); + } + + #[test] + fn agreement_applies_offset_explicitly() { + // Estimate timestamps 5 s behind the reference clock; passing the + // alignment offset pairs them exactly. + let ref_samples = series_1hz(0, 120, synth); + let est_samples: Vec = (0..120) + .map(|i| ReferenceSample { + timestamp_ms: (i as i64) * 1000 - 5000, + value: synth(i as f64), + }) + .collect(); + let reference = + ReferenceSeries::new(Measurand::HeartRateBpm, device(), ref_samples).unwrap(); + let estimate = EstimateSeries::new(Measurand::HeartRateBpm, est_samples).unwrap(); + let report = + AgreementReport::compute(&estimate, &reference, 5000, &agree_cfg(None), scope()) + .unwrap(); + assert_eq!(report.applied_offset_ms, 5000); + assert!(report.mae_bpm < 1e-9); + // Without the offset the same series disagree. + let misaligned = + AgreementReport::compute(&estimate, &reference, 0, &agree_cfg(None), scope()).unwrap(); + assert!(misaligned.mae_bpm > report.mae_bpm); + } + + #[test] + fn agreement_rejects_disjoint_and_mismatched_series() { + let (estimate, reference) = fixture_pair(); + // No overlap after a huge offset. + let err = AgreementReport::compute( + &estimate, + &reference, + 1_000_000, + &agree_cfg(None), + scope(), + ) + .unwrap_err(); + assert!(matches!(err, GroundTruthError::InsufficientOverlap { .. })); + + // Measurand mismatch. + let breathing = EstimateSeries::new( + Measurand::BreathingRateBrpm, + series_1hz(0, 4, |_| 15.0), + ) + .unwrap(); + let err = AgreementReport::compute(&breathing, &reference, 0, &agree_cfg(None), scope()) + .unwrap_err(); + assert!(matches!(err, GroundTruthError::MeasurandMismatch { .. })); + } + + // -- Evidence grading --------------------------------------------------- + + fn good_report() -> AgreementReport { + let (estimate, reference) = fixture_pair(); + AgreementReport::compute(&estimate, &reference, 0, &agree_cfg(None), scope()).unwrap() + } + + #[test] + fn measured_grade_requires_reproducer() { + let graded = GradedAgreementReport::measured( + good_report(), + "cargo test -p wifi-densepose-vitals groundtruth", + ) + .unwrap(); + assert_eq!(graded.evidence().tag(), "MEASURED"); + let EvidenceGrade::Measured(evidence) = graded.evidence() else { + panic!("expected Measured"); + }; + assert_eq!( + evidence.reproducer(), + "cargo test -p wifi-densepose-vitals groundtruth" + ); + + let err = GradedAgreementReport::measured(good_report(), " ").unwrap_err(); + assert!(matches!(err, GroundTruthError::NotMeasured(_))); + } + + #[test] + fn measured_grade_rejects_zero_pairs() { + let mut report = good_report(); + report.n_pairs = 0; + let err = GradedAgreementReport::measured(report, "cargo test").unwrap_err(); + assert!(matches!(err, GroundTruthError::NotMeasured(_))); + } + + #[test] + fn measured_grade_rejects_low_coverage() { + let mut report = good_report(); + report.coverage = MIN_MEASURED_COVERAGE - 0.01; + let err = GradedAgreementReport::measured(report, "cargo test").unwrap_err(); + assert!(matches!(err, GroundTruthError::NotMeasured(_))); + } + + #[test] + fn claimed_and_synthetic_grades_are_always_constructible() { + let claimed = GradedAgreementReport::claimed(good_report()); + assert_eq!(claimed.evidence().tag(), "CLAIMED"); + let synthetic = GradedAgreementReport::synthetic(good_report()); + assert_eq!(synthetic.evidence().tag(), "SYNTHETIC"); + assert_eq!(synthetic.report().n_pairs, 4); + } + + // -- VitalSignStore integration ----------------------------------------- + + fn reading(ts_secs: f64, hr: f64, rr: f64, hr_status: VitalStatus) -> VitalReading { + VitalReading { + respiratory_rate: VitalEstimate { + value_bpm: rr, + confidence: 0.9, + status: VitalStatus::Valid, + }, + heart_rate: VitalEstimate { + value_bpm: hr, + confidence: 0.85, + status: hr_status, + }, + subcarrier_count: 56, + signal_quality: 0.9, + timestamp_secs: ts_secs, + } + } + + #[test] + fn estimate_series_from_readings_skips_unavailable() { + let readings = vec![ + reading(0.0, 70.0, 15.0, VitalStatus::Valid), + reading(1.0, 0.0, 15.0, VitalStatus::Unavailable), + reading(2.0, 72.0, 15.0, VitalStatus::Degraded), + ]; + let series = EstimateSeries::from_readings(Measurand::HeartRateBpm, &readings).unwrap(); + assert_eq!(series.samples().len(), 2); + assert_eq!(series.samples()[0].timestamp_ms, 0); + assert_eq!(series.samples()[1].timestamp_ms, 2000); + assert!((series.samples()[1].value - 72.0).abs() < f64::EPSILON); + } + + #[test] + fn estimate_series_from_readings_rejects_bad_input() { + let readings = vec![ + reading(1.0, 70.0, 15.0, VitalStatus::Valid), + reading(1.0, 71.0, 15.0, VitalStatus::Valid), + ]; + let err = EstimateSeries::from_readings(Measurand::HeartRateBpm, &readings).unwrap_err(); + assert_eq!(err, GroundTruthError::NonMonotonicTimestamp { row: 1 }); + + let readings = vec![reading(f64::NAN, 70.0, 15.0, VitalStatus::Valid)]; + let err = EstimateSeries::from_readings(Measurand::HeartRateBpm, &readings).unwrap_err(); + assert_eq!(err, GroundTruthError::TimestampOutOfRange { row: 0 }); + + let readings = vec![reading(0.0, 0.0, 15.0, VitalStatus::Unavailable)]; + let err = EstimateSeries::from_readings(Measurand::HeartRateBpm, &readings).unwrap_err(); + assert_eq!(err, GroundTruthError::NoSamples); + } + + #[test] + fn evaluate_session_end_to_end_recovers_offset_and_agrees() { + // Store readings at 1 Hz on the estimate clock; the reference + // device stamps the same physiological signal 5 s later + // (reference_time = estimate_time + 5000). + let mut store = VitalSignStore::new(1000); + for i in 0..300 { + store.push(reading(i as f64, synth(i as f64 + 5.0), 15.0, VitalStatus::Valid)); + } + let ref_samples: Vec = (0..300) + .map(|i| ReferenceSample { + timestamp_ms: (i as i64) * 1000 + 5000, + value: synth(i as f64 + 5.0), + }) + .collect(); + let reference = + ReferenceSeries::new(Measurand::HeartRateBpm, device(), ref_samples).unwrap(); + + let eval = evaluate_session( + &mut store, + &reference, + &AlignmentConfig::default(), + &AgreementConfig::default(), + scope(), + ) + .unwrap(); + + assert_eq!(eval.alignment.offset_ms, 5000); + assert_eq!(eval.report.applied_offset_ms, 5000); + assert!(eval.report.mae_bpm < 1e-9); + assert!(eval.report.coverage > 0.99); + assert!(eval.report.n_pairs >= 290); + + // And the result meets the MEASURED gate. + let graded = GradedAgreementReport::measured( + eval.report, + "cargo test -p wifi-densepose-vitals evaluate_session_end_to_end", + ) + .unwrap(); + assert_eq!(graded.evidence().tag(), "MEASURED"); + } + + #[test] + fn error_display_is_stable() { + let err = GroundTruthError::NonMonotonicTimestamp { row: 7 }; + assert_eq!( + err.to_string(), + "row 7: timestamps must be strictly increasing" + ); + assert_eq!( + GroundTruthError::MissingHeader.to_string(), + "missing CSV header line 'timestamp_ms,value'" + ); + } + + #[cfg(feature = "serde")] + #[test] + fn graded_report_serializes() { + let graded = GradedAgreementReport::measured(good_report(), "cargo test").unwrap(); + let json = serde_json::to_string(&graded).unwrap(); + assert!(json.contains("Measured")); + assert!(json.contains("cargo test")); + } +} diff --git a/v2/crates/wifi-densepose-vitals/src/lib.rs b/v2/crates/wifi-densepose-vitals/src/lib.rs index ca84aea9..37818088 100644 --- a/v2/crates/wifi-densepose-vitals/src/lib.rs +++ b/v2/crates/wifi-densepose-vitals/src/lib.rs @@ -23,6 +23,12 @@ //! Results are stored in a [`VitalSignStore`] with configurable //! retention for historical analysis. //! +//! Ground-truth evaluation ([`groundtruth`], ADR-293) ingests a +//! reference-device series (CSV export), time-aligns it against a store +//! session, and produces evidence-graded agreement statistics +//! (MAE/RMSE/bias, Bland-Altman limits, percent-within-tolerance) with a +//! mandatory session scope. +//! //! # Example //! //! ``` @@ -67,6 +73,7 @@ pub mod anomaly; pub mod breathing; +pub mod groundtruth; pub mod heartrate; pub mod preprocessor; pub mod store; @@ -74,6 +81,12 @@ pub mod types; pub use anomaly::{AnomalyAlert, VitalAnomalyDetector}; pub use breathing::BreathingExtractor; +pub use groundtruth::{ + align, evaluate_session, AgreementConfig, AgreementReport, AlignmentConfig, AlignmentResult, + DistanceBand, DriftFit, EstimateSeries, EvidenceGrade, GradedAgreementReport, + GroundTruthError, Measurand, MeasurementPrinciple, MotionState, Propagation, ReferenceDevice, + ReferenceSample, ReferenceSeries, SessionEvaluation, SessionScope, +}; pub use heartrate::HeartRateExtractor; pub use preprocessor::CsiVitalPreprocessor; pub use store::{VitalSignStore, VitalStats};