From 49c594822ffca74caa15271dc505cfd2362a65be Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Aug 2026 03:16:33 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20implement=20ADR-297=20phase-2=20world-m?= =?UTF-8?q?odel=20core=20=E2=80=94=20HAL,=20ground-truth,=20tracking,=20fu?= =?UTF-8?q?sion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The layer that turns the certificate spine into a modality-agnostic perception substrate. Four crates, all deterministic and green independently (43 tests). ruview-hal (ADR-317): one abstraction mapping any modality (CSI/802.11bf/BLE/ UWB/mmWave/acoustic/camera/lidar/IMU/custom) to a canonical ontology Observation. SensorHal trait + two SYNTHETIC/L0 reference adapters; malformed input yields a degraded UNKNOWN observation, never a panic; synthetic can never alias measured. 8 tests. ruview-groundtruth (ADR-300): reference sensors as a formal VALIDATION plane (never an estimator input, enforced by the type boundary); modality-agnostic ReferenceSeries, deterministic cross-correlation alignment, AgreementReport with mandatory SessionScope, emitting per-context ruview-evidence records; Measured requires reference + coverage + reproducer. 15 tests. ruview-track (ADR-304): privacy-preserving persistent tracks (opaque person ids, coarse non-reversible features, no civil-identity binding); ambiguous detections stay tentative rather than misassigned; cross-zone hand-off. 8 tests. ruview-fusion (ADR-308): multiple HalObservations -> one probabilistic WorldState, uncertainty-aware (confidence-weighted, not naive averaging); irreconcilable conflict or insufficient coverage yields UNKNOWN, not a confident average. 9+ tests incl. irreconcilable_conflict_yields_unknown. Flips ADR-300/304/308/317 to implemented; registers the four crates as workspace members. SYNTHETIC/L0 throughout; no hardware/MEASURED claims. Co-Authored-By: claude-flow Claude-Session: https://claude.ai/code/session_015TcKegTS7QqhWPC2L2SzaS --- .../ADR-300-ground-truth-synchronization.md | 2 +- .../ADR-304-persistent-identity-tracking.md | 2 +- docs/adr/ADR-308-real-sensor-fusion.md | 2 +- docs/adr/ADR-317-sensor-hal.md | 2 +- v2/Cargo.lock | 42 ++ v2/Cargo.toml | 5 + v2/crates/ruview-fusion/Cargo.toml | 16 + v2/crates/ruview-fusion/src/engine.rs | 200 +++++++ v2/crates/ruview-fusion/src/estimate.rs | 99 ++++ v2/crates/ruview-fusion/src/lib.rs | 489 ++++++++++++++++ v2/crates/ruview-fusion/src/observation.rs | 116 ++++ v2/crates/ruview-fusion/src/world.rs | 147 +++++ v2/crates/ruview-groundtruth/Cargo.toml | 16 + v2/crates/ruview-groundtruth/src/agreement.rs | 332 +++++++++++ v2/crates/ruview-groundtruth/src/align.rs | 323 +++++++++++ v2/crates/ruview-groundtruth/src/error.rs | 148 +++++ v2/crates/ruview-groundtruth/src/lib.rs | 532 +++++++++++++++++ v2/crates/ruview-groundtruth/src/model.rs | 129 +++++ v2/crates/ruview-groundtruth/src/scope.rs | 93 +++ v2/crates/ruview-groundtruth/src/series.rs | 203 +++++++ v2/crates/ruview-groundtruth/src/source.rs | 105 ++++ v2/crates/ruview-hal/Cargo.toml | 15 + v2/crates/ruview-hal/src/adapter.rs | 234 ++++++++ v2/crates/ruview-hal/src/descriptor.rs | 66 +++ v2/crates/ruview-hal/src/label.rs | 82 +++ v2/crates/ruview-hal/src/lib.rs | 343 +++++++++++ v2/crates/ruview-hal/src/modality.rs | 91 +++ v2/crates/ruview-hal/src/observation.rs | 156 +++++ v2/crates/ruview-track/Cargo.toml | 15 + v2/crates/ruview-track/src/config.rs | 76 +++ v2/crates/ruview-track/src/error.rs | 36 ++ v2/crates/ruview-track/src/feature.rs | 152 +++++ v2/crates/ruview-track/src/lib.rs | 78 +++ v2/crates/ruview-track/src/manager.rs | 539 ++++++++++++++++++ v2/crates/ruview-track/src/topology.rs | 93 +++ v2/crates/ruview-track/tests/tracking.rs | 277 +++++++++ 36 files changed, 5252 insertions(+), 4 deletions(-) create mode 100644 v2/crates/ruview-fusion/Cargo.toml create mode 100644 v2/crates/ruview-fusion/src/engine.rs create mode 100644 v2/crates/ruview-fusion/src/estimate.rs create mode 100644 v2/crates/ruview-fusion/src/lib.rs create mode 100644 v2/crates/ruview-fusion/src/observation.rs create mode 100644 v2/crates/ruview-fusion/src/world.rs create mode 100644 v2/crates/ruview-groundtruth/Cargo.toml create mode 100644 v2/crates/ruview-groundtruth/src/agreement.rs create mode 100644 v2/crates/ruview-groundtruth/src/align.rs create mode 100644 v2/crates/ruview-groundtruth/src/error.rs create mode 100644 v2/crates/ruview-groundtruth/src/lib.rs create mode 100644 v2/crates/ruview-groundtruth/src/model.rs create mode 100644 v2/crates/ruview-groundtruth/src/scope.rs create mode 100644 v2/crates/ruview-groundtruth/src/series.rs create mode 100644 v2/crates/ruview-groundtruth/src/source.rs create mode 100644 v2/crates/ruview-hal/Cargo.toml create mode 100644 v2/crates/ruview-hal/src/adapter.rs create mode 100644 v2/crates/ruview-hal/src/descriptor.rs create mode 100644 v2/crates/ruview-hal/src/label.rs create mode 100644 v2/crates/ruview-hal/src/lib.rs create mode 100644 v2/crates/ruview-hal/src/modality.rs create mode 100644 v2/crates/ruview-hal/src/observation.rs create mode 100644 v2/crates/ruview-track/Cargo.toml create mode 100644 v2/crates/ruview-track/src/config.rs create mode 100644 v2/crates/ruview-track/src/error.rs create mode 100644 v2/crates/ruview-track/src/feature.rs create mode 100644 v2/crates/ruview-track/src/lib.rs create mode 100644 v2/crates/ruview-track/src/manager.rs create mode 100644 v2/crates/ruview-track/src/topology.rs create mode 100644 v2/crates/ruview-track/tests/tracking.rs diff --git a/docs/adr/ADR-300-ground-truth-synchronization.md b/docs/adr/ADR-300-ground-truth-synchronization.md index fe365b20..fd6f4cb7 100644 --- a/docs/adr/ADR-300-ground-truth-synchronization.md +++ b/docs/adr/ADR-300-ground-truth-synchronization.md @@ -1,6 +1,6 @@ # ADR-300: Ground-truth synchronization — reference sensors as a formal validation plane -- **Status**: Proposed (ADR-297 phase 2) +- **Status**: Accepted — initial implementation (ADR-297 phase 2) - **Date**: 2026-08-11 - **Deciders**: ruv - **Tags**: ground-truth, validation, fusion, evidence, benchmark, honesty, substrate diff --git a/docs/adr/ADR-304-persistent-identity-tracking.md b/docs/adr/ADR-304-persistent-identity-tracking.md index c3bde2b6..0f97d731 100644 --- a/docs/adr/ADR-304-persistent-identity-tracking.md +++ b/docs/adr/ADR-304-persistent-identity-tracking.md @@ -1,6 +1,6 @@ # ADR-304: Persistent identity & tracking — privacy-preserving probabilistic tracks -- **Status**: Proposed (ADR-297 phase 2) +- **Status**: Accepted — initial implementation (ADR-297 phase 2) - **Date**: 2026-08-11 - **Deciders**: ruv - **Tags**: tracking, identity, privacy, fusion, worldgraph, phase-2 diff --git a/docs/adr/ADR-308-real-sensor-fusion.md b/docs/adr/ADR-308-real-sensor-fusion.md index 1abe7bc8..052eba1e 100644 --- a/docs/adr/ADR-308-real-sensor-fusion.md +++ b/docs/adr/ADR-308-real-sensor-fusion.md @@ -1,6 +1,6 @@ # ADR-308: Real sensor fusion — uncertainty-aware, multiple observations → one world state -- **Status**: Proposed (ADR-297 phase 2) +- **Status**: Accepted — initial implementation (ADR-297 phase 2) - **Date**: 2026-08-11 - **Deciders**: ruv - **Tags**: fusion, uncertainty, multimodal, world-state, ontology, phase-2 diff --git a/docs/adr/ADR-317-sensor-hal.md b/docs/adr/ADR-317-sensor-hal.md index a35a6b6f..e3c7ca36 100644 --- a/docs/adr/ADR-317-sensor-hal.md +++ b/docs/adr/ADR-317-sensor-hal.md @@ -1,6 +1,6 @@ # ADR-317: RuView sensor HAL — abstract all sensing hardware to one Observation type -- **Status**: Proposed (ADR-297 phase 2) +- **Status**: Accepted — initial implementation (ADR-297 phase 2) - **Date**: 2026-08-11 - **Deciders**: ruv - **Tags**: hal, sensor-abstraction, ontology, fusion, adapters, category, phase-2 diff --git a/v2/Cargo.lock b/v2/Cargo.lock index 5e511bd8..5284d676 100644 --- a/v2/Cargo.lock +++ b/v2/Cargo.lock @@ -7922,6 +7922,38 @@ dependencies = [ "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-ontology" version = "0.3.1" @@ -7985,6 +8017,16 @@ dependencies = [ "tracing", ] +[[package]] +name = "ruview-track" +version = "0.3.1" +dependencies = [ + "ruview-ontology", + "serde", + "serde_json", + "thiserror 2.0.18", +] + [[package]] name = "ruview-unified" version = "0.3.1" diff --git a/v2/Cargo.toml b/v2/Cargo.toml index 931456a2..88dbb8a2 100644 --- a/v2/Cargo.toml +++ b/v2/Cargo.toml @@ -104,6 +104,11 @@ members = [ "crates/ruview-certify", # ADR-315 capability certificate "crates/ruview-scorecard", # ADR-314 multi-domain benchmark scorecard "crates/ruview-policy", # ADR-318 decision policy / action authorization + # ADR-297 phase 2 — unified world-model core: + "crates/ruview-hal", # ADR-317 sensor HAL (any modality -> Observation) + "crates/ruview-groundtruth",# ADR-300 ground-truth synchronization / validation plane + "crates/ruview-track", # ADR-304 persistent privacy-preserving tracking + "crates/ruview-fusion", # ADR-308 uncertainty-aware fusion -> one world state ] # ADR-040: WASM edge crate targets wasm32-unknown-unknown (no_std), # excluded from workspace to avoid breaking `cargo test --workspace`. 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..2fc828f9 --- /dev/null +++ b/v2/crates/ruview-fusion/src/engine.rs @@ -0,0 +1,200 @@ +//! The fusion engine (ADR-308 §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..e141af68 --- /dev/null +++ b/v2/crates/ruview-fusion/src/estimate.rs @@ -0,0 +1,99 @@ +//! The presence estimate and its uncertainty-aware combination (ADR-308 §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-308: "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..9b987951 --- /dev/null +++ b/v2/crates/ruview-fusion/src/lib.rs @@ -0,0 +1,489 @@ +//! # `ruview-fusion` — uncertainty-aware sensor fusion (ADR-308, ADR-297 §11) +//! +//! **Many observations resolve to one probabilistic world state, not many feeds +//! into a visualization.** This is the defining invariant of ADR-308: 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-309 spatial memory, ADR-310 counterfactual, ADR-312 RF twin) +//! reads, with each contributing observation's provenance and confidence still +//! recoverable. +//! +//! [`FusionEngine`] ingests a set of [`PresenceObservation`]s — canonical +//! ADR-303 [`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-297 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-308). 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..512044cf --- /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-308 §1). +//! +//! Fusion consumes authenticated, ontology-typed observations. A +//! [`HalObservation`] carries the modality, evidence level, sensor identity, +//! container, and provenance (the canonical ADR-303 vocabulary, reused rather +//! than reinvented — ADR-297 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-297 +//! 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..9f9796f8 --- /dev/null +++ b/v2/crates/ruview-fusion/src/world.rs @@ -0,0 +1,147 @@ +//! The fused output: one probabilistic world state (ADR-308 §3). +//! +//! The invariant of ADR-308 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-308: "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-297 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-309/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..19da8862 --- /dev/null +++ b/v2/crates/ruview-groundtruth/src/agreement.rs @@ -0,0 +1,332 @@ +//! Agreement as validation, not fusion (ADR-300 §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-290/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-303 vocabulary +/// while still writing an ADR-301 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-290/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-290 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-303 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-300 §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-314); 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..b58a131c --- /dev/null +++ b/v2/crates/ruview-groundtruth/src/align.rs @@ -0,0 +1,323 @@ +//! Deterministic time alignment (ADR-300 §2, generalizing ADR-290). +//! +//! 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-290 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..2c45e8ed --- /dev/null +++ b/v2/crates/ruview-groundtruth/src/error.rs @@ -0,0 +1,148 @@ +//! Boundary errors for the ground-truth validation plane (ADR-300). +//! +//! 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-290 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..56684583 --- /dev/null +++ b/v2/crates/ruview-groundtruth/src/lib.rs @@ -0,0 +1,532 @@ +//! # `ruview-groundtruth` — reference sensors as a formal validation plane (ADR-300) +//! +//! This crate generalizes the ADR-290 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-300) 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-297 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-297 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-290/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-297 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..7537a8b1 --- /dev/null +++ b/v2/crates/ruview-groundtruth/src/model.rs @@ -0,0 +1,129 @@ +//! Modality-agnostic measurands and readings (ADR-300 §1). +//! +//! ADR-290 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-290'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-288 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-301'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..d8237b53 --- /dev/null +++ b/v2/crates/ruview-groundtruth/src/scope.rs @@ -0,0 +1,93 @@ +//! Mandatory session scope (ADR-300 §3, mirroring ADR-290). +//! +//! 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-290 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..b489bf46 --- /dev/null +++ b/v2/crates/ruview-groundtruth/src/series.rs @@ -0,0 +1,203 @@ +//! Timestamped reference and estimate series with boundary validation +//! (ADR-300 §1, reusing ADR-290'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..0ab29101 --- /dev/null +++ b/v2/crates/ruview-groundtruth/src/source.rs @@ -0,0 +1,105 @@ +//! Named reference sources on the validation plane (ADR-300 §1). +//! +//! A reference source is an *independent observer* used only to check RF +//! inference — never an inference input (ADR-300 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-300 §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..a961201b --- /dev/null +++ b/v2/crates/ruview-hal/src/adapter.rs @@ -0,0 +1,234 @@ +//! The [`SensorHal`] trait (ADR-317 §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-297 rule 1). +//! +//! Two reference adapters ship here, one RF (CSI) and one non-RF (IMU), per the +//! ADR-317 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-317 "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-307), BLE, UWB, mmWave (ADR-063), +/// acoustic, camera, lidar, IMU, and `custom` — behind this single trait, so +/// the world model and fusion (ADR-308) 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-317 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..1e5a0583 --- /dev/null +++ b/v2/crates/ruview-hal/src/descriptor.rs @@ -0,0 +1,66 @@ +//! The sensor descriptor (ADR-317 §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-297 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-313) can +/// enumerate heterogeneous hardware uniformly. The `sensor_id` is the ontology +/// identity the device is authenticated as (ADR-302) before its observations +/// are trusted. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct SensorDescriptor { + /// The ontology sensor identity this device is authenticated as (ADR-302). + 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..d9285bd6 --- /dev/null +++ b/v2/crates/ruview-hal/src/lib.rs @@ -0,0 +1,343 @@ +//! # `ruview-hal` — the RuView sensor HAL (ADR-317, ADR-297 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-303) — its output is an +//! ontology `Observation` bound to a `Sensor` — and its observations feed real +//! sensor fusion (ADR-308). 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-297 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-302 `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-307 (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-317 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-317 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-317 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..93da1301 --- /dev/null +++ b/v2/crates/ruview-hal/src/modality.rs @@ -0,0 +1,91 @@ +//! The sensing modality tag (ADR-317 §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-297 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-317; [`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-307, 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..78ef213f --- /dev/null +++ b/v2/crates/ruview-hal/src/observation.rs @@ -0,0 +1,156 @@ +//! The HAL observation (ADR-317 §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-297 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-297 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-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..fc402636 --- /dev/null +++ b/v2/crates/ruview-track/src/config.rs @@ -0,0 +1,76 @@ +//! Tuning for the association / lifecycle / decay policy (ADR-304). +//! +//! 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..907bf42f --- /dev/null +++ b/v2/crates/ruview-track/src/error.rs @@ -0,0 +1,36 @@ +//! Boundary-validation errors (ADR-304). +//! +//! 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-297 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..c5fbd2bf --- /dev/null +++ b/v2/crates/ruview-track/src/feature.rs @@ -0,0 +1,152 @@ +//! Coarse, non-reversible appearance features (ADR-304 §3, privacy boundary). +//! +//! A [`CoarseFeature`] is the appearance channel used for short-horizon track +//! continuity (the ADR-303/ADR-304 `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..056b4d25 --- /dev/null +++ b/v2/crates/ruview-track/src/lib.rs @@ -0,0 +1,78 @@ +//! # `ruview-track` — persistent, privacy-preserving probabilistic tracking (ADR-304) +//! +//! 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-303 ontology types** +//! (`Track`, `Person`, `Container`, `EvidenceLevel`, `SemanticProvenance`) from +//! [`ruview_ontology`]; it invents no per-crate identity shape (ADR-297 rule 3). +//! +//! ## The four privacy invariants (ADR-304 §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-297 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-304 §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..8567a6d2 --- /dev/null +++ b/v2/crates/ruview-track/src/manager.rs @@ -0,0 +1,539 @@ +//! [`TrackManager`] — persistent, privacy-preserving probabilistic tracking +//! (ADR-304). +//! +//! # 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-297 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-303 `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-303 [`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-303 [`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..20a1161c --- /dev/null +++ b/v2/crates/ruview-track/src/topology.rs @@ -0,0 +1,93 @@ +//! Space/zone adjacency that constrains plausible hand-offs (ADR-304 §2). +//! +//! Association across containers is only allowed between the **same** container +//! or two **adjacent** ones (the ADR-303 `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..f0770701 --- /dev/null +++ b/v2/crates/ruview-track/tests/tracking.rs @@ -0,0 +1,277 @@ +//! ADR-304 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); +}