feat: implement ADR-297 phase-2 world-model core — HAL, ground-truth, tracking, fusion

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 <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_015TcKegTS7QqhWPC2L2SzaS
This commit is contained in:
Claude
2026-08-11 03:16:33 +00:00
parent 516331461a
commit 49c594822f
36 changed files with 5252 additions and 4 deletions
@@ -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
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
Generated
+42
View File
@@ -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"
+5
View File
@@ -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`.
+16
View File
@@ -0,0 +1,16 @@
[package]
name = "ruview-fusion"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
thiserror.workspace = true
serde = { workspace = true, features = ["derive"] }
ruview-ontology = { path = "../ruview-ontology" }
ruview-hal = { path = "../ruview-hal" }
[dev-dependencies]
serde_json.workspace = true
+200
View File
@@ -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<usize>> = BTreeMap::new();
for (i, obs) in observations.iter().enumerate() {
let (kind, id) = container_key(&obs.hal.observation.located_in);
groups.entry((kind, id.to_string())).or_default().push(i);
}
let at_unix_ms = observations
.iter()
.map(|o| o.hal.observation.at_unix_ms)
.max()
.unwrap_or(0);
let zones = groups
.into_values()
.map(|idxs| self.fuse_zone(observations, &idxs))
.collect();
WorldState { at_unix_ms, zones }
}
/// Fuse the observations that share one container into a single zone state.
fn fuse_zone(&self, observations: &[PresenceObservation], idxs: &[usize]) -> ZoneState {
let container = observations[idxs[0]].hal.observation.located_in.clone();
// Canonical order: sort by observation id so the fused value and the
// provenance ordering do not depend on input order.
let mut order = idxs.to_vec();
order.sort_by(|&a, &b| {
observations[a]
.hal
.observation
.id
.as_str()
.cmp(observations[b].hal.observation.id.as_str())
});
// Split into contributing (usable estimate) and abstaining sources.
let mut contributors: Vec<(usize, Estimate)> = Vec::new();
for &i in &order {
if let Some(est) = observations[i].usable_estimate() {
contributors.push((i, est));
}
}
let mut weight_by_idx: BTreeMap<usize, f64> = BTreeMap::new();
let (presence, evidence_level) = if contributors.len() < self.config.effective_min() {
// Not enough usable coverage to resolve this zone.
(
Presence::Unknown {
reason: UnknownReason::InsufficientCoverage,
},
EvidenceLevel::L0,
)
} else {
let estimates: Vec<Estimate> = contributors.iter().map(|(_, e)| *e).collect();
// Safe: contributors is non-empty here (>= effective_min >= 1).
let combined = combine(&estimates).expect("non-empty contributor set");
// Evidence never rises above the weakest contributing input.
let evidence = contributors
.iter()
.map(|(i, _)| observations[*i].hal.evidence_level())
.min()
.unwrap_or(EvidenceLevel::L0);
// Record normalized inverse-variance weights for auditability.
let precision_sum: f64 = estimates.iter().map(Estimate::precision).sum();
for (i, e) in &contributors {
weight_by_idx.insert(*i, e.precision() / precision_sum);
}
let presence = if combined.reduced_chi_square > self.config.conflict_reduced_chi_square {
// Sources disagree beyond their stated uncertainty: refuse to
// emit a confident average of irreconcilable evidence.
Presence::Unknown {
reason: UnknownReason::IrreconcilableConflict {
reduced_chi_square: combined.reduced_chi_square,
},
}
} else {
Presence::Estimated {
probability: combined.probability,
variance: combined.variance,
}
};
(presence, evidence)
};
// Per-observation provenance for every observation in the group.
let contributions = order
.iter()
.map(|&i| {
let obs = &observations[i];
Contribution {
observation: obs.hal.observation.id.clone(),
sensor: obs.hal.sensor().clone(),
modality: obs.hal.modality.clone(),
evidence_level: obs.hal.evidence_level(),
estimate: obs.usable_estimate(),
weight: weight_by_idx.get(&i).copied().unwrap_or(0.0),
}
})
.collect();
ZoneState {
container,
presence,
evidence_level,
contributions,
}
}
}
/// A stable ordering/grouping key for a container: a kind discriminant plus its
/// id string. Two containers with the same key are the same container.
fn container_key(container: &Container) -> (u8, &str) {
match container {
Container::Space { id } => (0, id.as_str()),
Container::Zone { id } => (1, id.as_str()),
}
}
+99
View File
@@ -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<Self> {
if !probability.is_finite() || !variance.is_finite() || variance <= 0.0 {
return None;
}
Some(Self {
probability: probability.clamp(0.0, 1.0),
variance,
})
}
/// The precision (inverse variance) — the weight this estimate carries in an
/// inverse-variance combination.
#[must_use]
pub fn precision(&self) -> f64 {
1.0 / self.variance
}
}
/// The result of inverse-variance combination over a non-empty set of
/// estimates: the fused mean/variance plus the reduced chi-square disagreement
/// statistic used to detect irreconcilable conflict.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Combined {
/// The inverse-variance-weighted mean probability, clamped to `[0.0, 1.0]`.
pub probability: f64,
/// The fused variance `1 / Σ precision` — never larger than the smallest
/// contributing variance, so agreeing estimates sharpen the belief.
pub variance: f64,
/// Reduced chi-square `Σ wᵢ (pᵢ mean)² / dof` (dof = `n 1`, floored at
/// 1). Near 0 when sources agree relative to their stated uncertainty;
/// large when they disagree by more than that uncertainty allows.
pub reduced_chi_square: f64,
}
/// Combine independent presence estimates by inverse-variance weighting.
///
/// Returns `None` for an empty input (there is nothing to fuse — the caller
/// resolves that to UNKNOWN / insufficient coverage). For a single estimate the
/// fused mean and variance are that estimate's own (pass-through) and the
/// disagreement statistic is 0.
#[must_use]
pub fn combine(estimates: &[Estimate]) -> Option<Combined> {
if estimates.is_empty() {
return None;
}
let precision_sum: f64 = estimates.iter().map(Estimate::precision).sum();
// precision_sum is strictly positive because every Estimate has variance > 0.
let mean = estimates
.iter()
.map(|e| e.probability * e.precision())
.sum::<f64>()
/ precision_sum;
let variance = 1.0 / precision_sum;
let chi_square: f64 = estimates
.iter()
.map(|e| e.precision() * (e.probability - mean).powi(2))
.sum();
let dof = (estimates.len() - 1).max(1) as f64;
Some(Combined {
probability: mean.clamp(0.0, 1.0),
variance,
reduced_chi_square: chi_square / dof,
})
}
+489
View File
@@ -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);
}
}
+116
View File
@@ -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<Estimate> {
match *self {
Self::Estimated {
probability,
variance,
} => Estimate::new(probability, variance),
Self::Unknown => None,
}
}
}
/// One fusion input: a canonical HAL observation and its presence claim.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct PresenceObservation {
/// The canonical HAL observation (modality, evidence, sensor, container,
/// provenance, and per-observation uncertainty).
pub hal: HalObservation,
/// This sensor's occupancy claim for its container.
pub claim: Claim,
}
impl PresenceObservation {
/// Build a fusion input with a quantified claim (validated; see
/// [`Claim::estimated`]).
#[must_use]
pub fn estimated(hal: HalObservation, probability: f64, variance: f64) -> Self {
Self {
claim: Claim::estimated(probability, variance),
hal,
}
}
/// Build a fusion input whose source abstains ([`Claim::Unknown`]).
#[must_use]
pub fn unknown(hal: HalObservation) -> Self {
Self {
hal,
claim: Claim::Unknown,
}
}
/// The usable presence estimate this observation contributes, or `None` when
/// it abstains.
///
/// An observation abstains when its HAL frame is `degraded` (malformed /
/// out-of-bounds raw input — the HAL already flagged it UNKNOWN) or when its
/// claim is not a weightable estimate. Abstaining observations still carry
/// their provenance into the fused state; they simply do not move the fused
/// value. A merely *high-variance* claim is **not** abstaining — it
/// contributes, but is down-weighted by inverse-variance.
#[must_use]
pub fn usable_estimate(&self) -> Option<Estimate> {
if self.hal.uncertainty.degraded {
return None;
}
self.claim.estimate()
}
}
+147
View File
@@ -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<Estimate>,
/// Its normalized weight in the fused value, in `[0.0, 1.0]`.
pub weight: f64,
}
impl Contribution {
/// True when this observation contributed a weighted estimate (did not
/// abstain).
#[must_use]
pub fn contributed(&self) -> bool {
self.estimate.is_some()
}
}
/// The fused belief and provenance for a single container.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ZoneState {
/// The container (space or zone) this state describes.
pub container: Container,
/// The fused occupancy belief, or UNKNOWN.
pub presence: Presence,
/// Aggregate evidence level — the minimum over contributing observations,
/// never above the weakest necessary input; `L0` when nothing contributed.
pub evidence_level: EvidenceLevel,
/// Per-observation provenance for every observation grouped into this zone,
/// in a deterministic (observation-id) order.
pub contributions: Vec<Contribution>,
}
impl ZoneState {
/// True when this zone resolved to UNKNOWN.
#[must_use]
pub fn is_unknown(&self) -> bool {
self.presence.is_unknown()
}
/// The ids of the observations that contributed a weighted estimate.
pub fn contributing_observations(&self) -> impl Iterator<Item = &ObservationId> {
self.contributions
.iter()
.filter(|c| c.contributed())
.map(|c| &c.observation)
}
}
/// One probabilistic world state fused from many observations.
///
/// This is the single object every downstream consumer reads (ADR-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<ZoneState>,
}
impl WorldState {
/// The fused state for a container, if present.
#[must_use]
pub fn zone(&self, container: &Container) -> Option<&ZoneState> {
self.zones.iter().find(|z| &z.container == container)
}
}
+16
View File
@@ -0,0 +1,16 @@
[package]
name = "ruview-groundtruth"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
thiserror.workspace = true
serde = { workspace = true, features = ["derive"] }
ruview-ontology = { path = "../ruview-ontology" }
ruview-evidence = { path = "../ruview-evidence" }
[dev-dependencies]
serde_json.workspace = true
@@ -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<String>,
}
impl GradingPolicy {
/// Construct and validate a grading policy.
///
/// # Errors
/// [`GroundTruthError::InvalidCoverage`] if `min_coverage` is outside
/// `[0, 1]`, [`GroundTruthError::GradeLevelConflict`] if `level` is `L0`,
/// or [`GroundTruthError::TooLong`] for an over-length reproducer.
pub fn new(
min_coverage: f64,
level: OntEvidenceLevel,
reproducer: Option<String>,
) -> Result<Self, GroundTruthError> {
if !min_coverage.is_finite() || !(0.0..=1.0).contains(&min_coverage) {
return Err(GroundTruthError::InvalidCoverage {
value: min_coverage,
});
}
if level == OntEvidenceLevel::L0 {
return Err(GroundTruthError::GradeLevelConflict {
reason: "L0 is reserved for synthetic input; use L1+ for a graded record",
});
}
if let Some(r) = &reproducer {
check_bound("reproducer", r)?;
}
Ok(Self {
min_coverage,
level,
reproducer,
})
}
}
/// A validation-plane agreement report: how RF inference compared against an
/// independent reference, under a mandatory session scope, graded on the
/// evidence ladder and ready to feed the evidence ledger.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct AgreementReport {
/// The independent reference source.
pub source: ReferenceSource,
/// The measurand compared.
pub measurand: Measurand,
/// The estimating model version.
pub model_version: String,
/// Mandatory session scope — a report cannot exist without it.
pub scope: SessionScope,
/// The recovered time alignment.
pub alignment: Alignment,
/// Number of aligned pairs the metrics summarize.
pub n_pairs: usize,
/// Coverage: paired points over total overlap grid points, `[0, 1]`.
pub coverage: f64,
/// The agreement metrics.
pub metrics: AgreementMetrics,
/// The honesty grade.
pub grade: EvidenceGrade,
/// The evidence level (canonical ADR-303 ladder) stamped on emission.
pub evidence_level: OntEvidenceLevel,
/// The reproducer handle, when the report is MEASURED.
pub reproducer: Option<String>,
/// Whether the estimate data was real or synthetic.
pub data_provenance: DataProvenance,
}
impl AgreementReport {
/// Build an agreement report. `scope` is a required argument, so a report
/// can never be constructed without it (ADR-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<Self, GroundTruthError> {
if estimate.measurand != reference.measurand {
return Err(GroundTruthError::MeasurandMismatch {
estimate: estimate.measurand.label(),
reference: reference.measurand.label(),
});
}
if !tolerance.is_finite() || tolerance < 0.0 {
return Err(GroundTruthError::InvalidTolerance { value: tolerance });
}
let alignment = estimate_alignment(estimate, reference, align_cfg)?;
let (total, pairs) = paired_at(estimate, reference, alignment.offset_ms, align_cfg);
let n_pairs = pairs.len();
let coverage = if total == 0 {
0.0
} else {
n_pairs as f64 / total as f64
};
let metrics = compute_metrics(estimate.measurand, &pairs, tolerance);
// Grade: synthetic input is always Synthetic; otherwise MEASURED only
// with an independent reference, coverage, paired samples, and a
// reproducer — else Claimed.
let grade = match estimate.provenance {
DataProvenance::Synthetic => EvidenceGrade::Synthetic,
DataProvenance::Real => {
let reproducer_ok = policy
.reproducer
.as_deref()
.is_some_and(|r| !r.is_empty());
if reference.source.modality.is_independent_reference()
&& n_pairs > 0
&& coverage >= policy.min_coverage
&& reproducer_ok
{
EvidenceGrade::Measured
} else {
EvidenceGrade::Claimed
}
}
};
let (evidence_level, reproducer) = match grade {
EvidenceGrade::Synthetic => (OntEvidenceLevel::L0, None),
EvidenceGrade::Claimed => (policy.level, None),
EvidenceGrade::Measured => (policy.level, policy.reproducer.clone()),
};
Ok(Self {
source: reference.source.clone(),
measurand: estimate.measurand,
model_version: estimate.model_version.clone(),
scope,
alignment,
n_pairs,
coverage,
metrics,
grade,
evidence_level,
reproducer,
data_provenance: estimate.provenance,
})
}
/// Emit this report as an evidence-ledger record, keyed by `context`, with
/// caller-supplied per-context [`AccuracyMetrics`]. The record's provenance
/// class and level follow the report's grade: `Synthetic → L0 synthetic`,
/// `Claimed → claimed`, `Measured → measured` (with the reproducer). The
/// evidence crate enforces the honesty invariants; failures surface as
/// [`GroundTruthError::Evidence`].
///
/// The agreement statistics (MAE/RMSE/coverage/label-agreement) live on the
/// report for the benchmark (ADR-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<EvidenceRecord, GroundTruthError> {
let level = to_ledger_level(self.evidence_level);
let record = match self.grade {
EvidenceGrade::Synthetic => {
EvidenceRecord::synthetic(context, metrics, timestamp_ns)?
}
EvidenceGrade::Claimed => {
EvidenceRecord::claimed(context, metrics, level, timestamp_ns)?
}
EvidenceGrade::Measured => {
let reproducer = self.reproducer.as_deref().ok_or(
GroundTruthError::GradeLevelConflict {
reason: "measured report is missing its reproducer handle",
},
)?;
EvidenceRecord::measured(context, metrics, level, reproducer, timestamp_ns)?
}
};
Ok(record)
}
}
/// Compute agreement metrics for the measurand's family from aligned pairs.
fn compute_metrics(
measurand: Measurand,
pairs: &[(Reading, Reading)],
tolerance: f64,
) -> AgreementMetrics {
if measurand.is_continuous() {
let n = pairs.len();
if n == 0 {
return AgreementMetrics::Continuous {
mae: 0.0,
rmse: 0.0,
bias: 0.0,
within_tolerance: 0.0,
};
}
let mut sum_abs = 0.0;
let mut sum_sq = 0.0;
let mut sum_err = 0.0;
let mut within = 0usize;
for (e, r) in pairs {
// Both are scalars for a continuous measurand (validated at ingest).
let ev = e.as_scalar().unwrap_or(0.0);
let rv = r.as_scalar().unwrap_or(0.0);
let err = ev - rv;
sum_abs += err.abs();
sum_sq += err * err;
sum_err += err;
if err.abs() <= tolerance {
within += 1;
}
}
let nf = n as f64;
AgreementMetrics::Continuous {
mae: sum_abs / nf,
rmse: (sum_sq / nf).sqrt(),
bias: sum_err / nf,
within_tolerance: within as f64 / nf,
}
} else {
let n = pairs.len();
let n_agree = pairs
.iter()
.filter(|(e, r)| e.as_label() == r.as_label())
.count();
let agreement = if n == 0 {
0.0
} else {
n_agree as f64 / n as f64
};
AgreementMetrics::Categorical { agreement, n_agree }
}
}
+323
View File
@@ -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<f64>,
/// Total grid points spanning the overlap at the chosen offset.
pub grid_points: usize,
/// Grid points where both series had a sample within `max_gap_ms`.
pub paired_points: usize,
}
/// Resample `samples` (sorted by time) onto `grid` by nearest sample within
/// `max_gap_ms`; a grid point with no sample in range yields `None` (no
/// bridging). `sample_times` must correspond 1:1 to `samples`.
fn resample(
samples: &[ReferenceObservation],
sample_times: &[i64],
grid: &[i64],
max_gap_ms: i64,
) -> Vec<Option<Reading>> {
let mut out = Vec::with_capacity(grid.len());
for &t in grid {
// Nearest neighbour by binary search over the sorted timestamps.
let idx = sample_times.partition_point(|&x| x < t);
let mut best: Option<(i64, usize)> = None;
for cand in [idx.wrapping_sub(1), idx] {
if cand < samples.len() {
let dt = (sample_times[cand] - t).abs();
let better = match best {
None => true,
Some((bd, _)) => dt < bd,
};
if better {
best = Some((dt, cand));
}
}
}
match best {
Some((dt, ci)) if dt <= max_gap_ms => out.push(Some(samples[ci].reading.clone())),
_ => out.push(None),
}
}
out
}
/// Score a set of aligned readings: NCC for scalars, agreement fraction for
/// labels. `None` when not computable (fewer than two paired scalars, zero
/// variance, or no paired labels).
fn score_pairs(pairs: &[(Reading, Reading)]) -> Option<f64> {
if pairs.is_empty() {
return None;
}
match &pairs[0].0 {
Reading::Scalar(_) => {
let xs: Vec<f64> = pairs.iter().filter_map(|(e, _)| e.as_scalar()).collect();
let ys: Vec<f64> = pairs.iter().filter_map(|(_, r)| r.as_scalar()).collect();
if xs.len() < 2 || xs.len() != ys.len() {
return None;
}
normalized_cross_correlation(&xs, &ys)
}
Reading::Label(_) => {
let n = pairs.len();
let agree = pairs
.iter()
.filter(|(e, r)| e.as_label() == r.as_label())
.count();
Some(agree as f64 / n as f64)
}
}
}
/// Normalized cross-correlation of two equal-length vectors; `None` if either
/// has zero variance.
fn normalized_cross_correlation(xs: &[f64], ys: &[f64]) -> Option<f64> {
let n = xs.len() as f64;
let mx = xs.iter().sum::<f64>() / n;
let my = ys.iter().sum::<f64>() / n;
let mut num = 0.0;
let mut dx = 0.0;
let mut dy = 0.0;
for (&x, &y) in xs.iter().zip(ys.iter()) {
let a = x - mx;
let b = y - my;
num += a * b;
dx += a * a;
dy += b * b;
}
let denom = (dx * dy).sqrt();
if denom <= 0.0 || !denom.is_finite() {
return None;
}
Some(num / denom)
}
/// Build the grid over the overlap of the estimate and offset reference ranges,
/// on the estimate timeline. Returns an empty vector when there is no overlap.
fn overlap_grid(
est_lo: i64,
est_hi: i64,
ref_lo: i64,
ref_hi: i64,
offset: i64,
grid_ms: i64,
) -> Vec<i64> {
// Reference is sampled at grid_time + offset, so the reference range maps to
// [ref_lo - offset, ref_hi - offset] on the estimate timeline.
let lo = est_lo.max(ref_lo.saturating_sub(offset));
let hi = est_hi.min(ref_hi.saturating_sub(offset));
if lo > hi {
return Vec::new();
}
let mut grid = Vec::new();
let mut t = lo;
while t <= hi {
grid.push(t);
// grid_ms > 0 guaranteed by config validation.
match t.checked_add(grid_ms) {
Some(next) => t = next,
None => break,
}
}
grid
}
/// Produce the aligned reading pairs at a given offset, plus the total grid
/// point count over the overlap (used for coverage).
pub(crate) fn paired_at(
estimate: &EstimateSeries,
reference: &ReferenceSeries,
offset: i64,
cfg: &AlignmentConfig,
) -> (usize, Vec<(Reading, Reading)>) {
let est = estimate.samples();
let refs = reference.samples();
let est_times: Vec<i64> = est.iter().map(|o| o.at_unix_ms).collect();
let ref_times: Vec<i64> = refs.iter().map(|o| o.at_unix_ms).collect();
let (est_lo, est_hi) = (est_times[0], est_times[est_times.len() - 1]);
let (ref_lo, ref_hi) = (ref_times[0], ref_times[ref_times.len() - 1]);
let grid = overlap_grid(est_lo, est_hi, ref_lo, ref_hi, offset, cfg.grid_ms);
let total = grid.len();
if total == 0 {
return (0, Vec::new());
}
// Estimate sampled on the grid; reference sampled at grid + offset.
let ref_grid: Vec<i64> = grid
.iter()
.map(|&g| g.saturating_add(offset))
.collect();
let est_r = resample(est, &est_times, &grid, cfg.max_gap_ms);
let ref_r = resample(refs, &ref_times, &ref_grid, cfg.max_gap_ms);
let mut pairs = Vec::new();
for (e, r) in est_r.into_iter().zip(ref_r.into_iter()) {
if let (Some(e), Some(r)) = (e, r) {
pairs.push((e, r));
}
}
(total, pairs)
}
/// Estimate the constant offset that best aligns `estimate` to `reference`.
///
/// Searches offsets in `[-max_lag_ms, +max_lag_ms]` stepped by `grid_ms`,
/// scoring each by NCC (continuous) or agreement (categorical). Ties are broken
/// deterministically toward the smallest absolute offset, then the smallest
/// signed offset. When no offset yields any paired points the result reports
/// offset `0` with a `None` score — a first-class UNKNOWN.
///
/// # Errors
/// [`GroundTruthError::MeasurandMismatch`] if the two series describe different
/// measurands, or a configuration error from [`AlignmentConfig::validate`].
pub fn estimate_alignment(
estimate: &EstimateSeries,
reference: &ReferenceSeries,
cfg: &AlignmentConfig,
) -> Result<Alignment, GroundTruthError> {
if estimate.measurand != reference.measurand {
return Err(GroundTruthError::MeasurandMismatch {
estimate: estimate.measurand.label(),
reference: reference.measurand.label(),
});
}
let est_times = estimate.samples();
let span = est_times[est_times.len() - 1].at_unix_ms - est_times[0].at_unix_ms;
cfg.validate(span.max(0))?;
let mut best_offset: i64 = 0;
let mut best_score: Option<f64> = None;
let mut offset = -cfg.max_lag_ms;
while offset <= cfg.max_lag_ms {
let (_, pairs) = paired_at(estimate, reference, offset, cfg);
let score = score_pairs(&pairs);
if let Some(s) = score {
let replace = match best_score {
None => true,
Some(b) => {
s > b + SCORE_EPS
|| ((s - b).abs() <= SCORE_EPS && offset.abs() < best_offset.abs())
}
};
if replace {
best_score = Some(s);
best_offset = offset;
}
}
match offset.checked_add(cfg.grid_ms) {
Some(next) => offset = next,
None => break,
}
}
let (total, pairs) = paired_at(estimate, reference, best_offset, cfg);
Ok(Alignment {
offset_ms: best_offset,
grid_ms: cfg.grid_ms,
score: best_score,
grid_points: total,
paired_points: pairs.len(),
})
}
+148
View File
@@ -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(())
}
+532
View File
@@ -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 { .. }
));
}
}
+129
View File
@@ -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<f64> {
match self {
Reading::Scalar(v) => Some(*v),
Reading::Label(_) => None,
}
}
/// Borrow the label, if this is a label reading.
#[must_use]
pub fn as_label(&self) -> Option<&str> {
match self {
Reading::Label(s) => Some(s.as_str()),
Reading::Scalar(_) => None,
}
}
}
/// Whether the compared data is real inference/measurement or a generated
/// (SYNTHETIC/L0) fixture. This is what forces an [`crate::EvidenceGrade`] to
/// `Synthetic`; it is never inferred, it is declared by the producer (mirrors
/// ADR-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,
}
+93
View File
@@ -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 25 m).
Mid,
/// Far (roughly > 5 m).
Far,
}
/// Mandatory metadata attached to every [`crate::AgreementReport`]. A report
/// cannot exist without it, so an agreement number always states the conditions
/// it was measured under.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct SessionScope {
/// Number of subjects present (0 is valid for an empty-room session).
pub subject_count: u16,
/// Motion state during the session.
pub motion: MotionState,
/// Line-of-sight condition.
pub line_of_sight: LineOfSight,
/// Distance band.
pub distance: DistanceBand,
}
impl SessionScope {
/// Construct a validated session scope. `subject_count` is bounded to
/// [`MAX_SUBJECTS`] so untrusted metadata cannot claim an absurd count.
///
/// # Errors
/// [`GroundTruthError::SubjectCountTooLarge`] if `subject_count` exceeds
/// [`MAX_SUBJECTS`].
pub fn new(
subject_count: u16,
motion: MotionState,
line_of_sight: LineOfSight,
distance: DistanceBand,
) -> Result<Self, GroundTruthError> {
if subject_count > MAX_SUBJECTS {
return Err(GroundTruthError::SubjectCountTooLarge {
count: u32::from(subject_count),
max: u32::from(MAX_SUBJECTS),
});
}
Ok(Self {
subject_count,
motion,
line_of_sight,
distance,
})
}
}
+203
View File
@@ -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<String>) -> Self {
Self {
at_unix_ms,
reading: Reading::Label(label.into()),
}
}
}
/// Validate a sample vector: non-empty, bounded, strictly increasing
/// timestamps, finite scalars, and reading family matching `measurand`.
fn validate_samples(
measurand: Measurand,
samples: &[ReferenceObservation],
) -> Result<(), GroundTruthError> {
if samples.is_empty() {
return Err(GroundTruthError::EmptySeries);
}
if samples.len() > MAX_SAMPLES {
return Err(GroundTruthError::TooManySamples {
len: samples.len(),
max: MAX_SAMPLES,
});
}
let want = measurand.kind();
let mut prev: Option<i64> = None;
for (index, s) in samples.iter().enumerate() {
if s.reading.kind() != want {
return Err(GroundTruthError::ReadingKindMismatch {
index,
measurand: measurand.label(),
});
}
if let Reading::Scalar(v) = &s.reading {
if !v.is_finite() {
return Err(GroundTruthError::NonFiniteValue { index });
}
}
if let Reading::Label(l) = &s.reading {
check_bound("label", l)?;
}
if let Some(p) = prev {
if s.at_unix_ms <= p {
return Err(GroundTruthError::NonMonotonic {
index,
prev_ms: p,
this_ms: s.at_unix_ms,
});
}
}
prev = Some(s.at_unix_ms);
}
Ok(())
}
/// A validated series of independent reference observations for one measurand.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ReferenceSeries {
/// The named reference source.
pub source: ReferenceSource,
/// The measurand observed.
pub measurand: Measurand,
samples: Vec<ReferenceObservation>,
}
impl ReferenceSeries {
/// Ingest and validate a reference series at the boundary.
///
/// # Errors
/// [`GroundTruthError::EmptySeries`], [`GroundTruthError::TooManySamples`],
/// [`GroundTruthError::NonMonotonic`], [`GroundTruthError::NonFiniteValue`],
/// [`GroundTruthError::ReadingKindMismatch`], or
/// [`GroundTruthError::TooLong`].
pub fn new(
source: ReferenceSource,
measurand: Measurand,
samples: Vec<ReferenceObservation>,
) -> Result<Self, GroundTruthError> {
validate_samples(measurand, &samples)?;
Ok(Self {
source,
measurand,
samples,
})
}
/// The validated samples, in time order.
#[must_use]
pub fn samples(&self) -> &[ReferenceObservation] {
&self.samples
}
/// The number of samples.
#[must_use]
pub fn len(&self) -> usize {
self.samples.len()
}
/// Whether the series is empty. Always `false` for a constructed series
/// (empty input is rejected), provided so clippy's `len`-without-`is_empty`
/// lint is satisfied.
#[must_use]
pub fn is_empty(&self) -> bool {
self.samples.is_empty()
}
}
/// A validated series of RF-estimate observations for one measurand, carrying
/// the producing model version and whether the data is real or synthetic.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct EstimateSeries {
/// The measurand estimated.
pub measurand: Measurand,
/// The model version that produced the estimates (ADR-136).
pub model_version: String,
/// Whether the estimates are real inference or synthetic input.
pub provenance: DataProvenance,
samples: Vec<ReferenceObservation>,
}
impl EstimateSeries {
/// Ingest and validate an estimate series at the boundary. `model_version`
/// must be non-empty and length-bounded.
///
/// # Errors
/// As [`ReferenceSeries::new`], plus [`GroundTruthError::EmptyField`] for a
/// missing `model_version`.
pub fn new(
measurand: Measurand,
model_version: impl Into<String>,
provenance: DataProvenance,
samples: Vec<ReferenceObservation>,
) -> Result<Self, GroundTruthError> {
let model_version = model_version.into();
check_bound("model_version", &model_version)?;
check_nonempty("model_version", &model_version)?;
validate_samples(measurand, &samples)?;
Ok(Self {
measurand,
model_version,
provenance,
samples,
})
}
/// The validated samples, in time order.
#[must_use]
pub fn samples(&self) -> &[ReferenceObservation] {
&self.samples
}
/// The number of samples.
#[must_use]
pub fn len(&self) -> usize {
self.samples.len()
}
/// Whether the series is empty (always `false` for a constructed series).
#[must_use]
pub fn is_empty(&self) -> bool {
self.samples.is_empty()
}
}
+105
View File
@@ -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<String>,
device: impl Into<String>,
principle: impl Into<String>,
) -> Result<Self, GroundTruthError> {
let name = name.into();
let device = device.into();
let principle = principle.into();
check_bound("name", &name)?;
check_bound("device", &device)?;
check_bound("principle", &principle)?;
check_nonempty("name", &name)?;
check_nonempty("device", &device)?;
Ok(Self {
modality,
name,
device,
principle,
})
}
}
+15
View File
@@ -0,0 +1,15 @@
[package]
name = "ruview-hal"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
thiserror.workspace = true
serde = { workspace = true, features = ["derive"] }
ruview-ontology = { path = "../ruview-ontology" }
[dev-dependencies]
serde_json.workspace = true
+234
View File
@@ -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<String>) -> SemanticProvenance {
SemanticProvenance {
evidence: Vec::new(),
model_version: model_version.into(),
calibration_version: crate::SYNTHETIC_CALIBRATION.to_string(),
privacy_decision: "synthetic".to_string(),
}
}
/// Maximum CSI taps a reference adapter will read, bounding allocation/compute
/// on untrusted input.
pub const MAX_CSI_TAPS: usize = 4096;
/// A native CSI raw sample: per-subcarrier amplitude and phase.
///
/// This is the *native* frame the adapter keeps — the pipeline never sees it,
/// only the [`HalObservation`] it is lifted into.
#[derive(Clone, Debug, PartialEq)]
pub struct CsiSample {
/// Per-subcarrier amplitudes (linear).
pub amplitudes: Vec<f32>,
/// Per-subcarrier phases (radians).
pub phases: Vec<f32>,
}
/// A deterministic, synthetic CSI reference adapter (SYNTHETIC / L0).
///
/// Mirrors the ADR-279 per-device latent adapters in shape without claiming any
/// real device: it demonstrates that a CSI producer lifts into the canonical
/// observation. It makes no MEASURED claim.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SyntheticCsiAdapter {
/// The ontology sensor identity this adapter is authenticated as.
pub sensor_id: SensorId,
/// Declared native subcarrier count.
pub subcarriers: u32,
}
impl SensorHal for SyntheticCsiAdapter {
type Raw = CsiSample;
fn describe(&self) -> SensorDescriptor {
SensorDescriptor {
sensor_id: self.sensor_id.clone(),
modality: Modality::Csi,
capabilities: vec![
CapabilityTag::new("amplitude").expect("static tag is valid"),
CapabilityTag::new("phase").expect("static tag is valid"),
],
sampling: SamplingSpec {
sample_rate_hz: Some(100.0),
unit: "csi-complex".to_string(),
dimensions: self.subcarriers,
},
}
}
fn normalize(&self, raw: Self::Raw, ctx: &NormalizeCtx) -> HalObservation {
let observation = synthetic_observation(self.sensor_id.clone(), ctx, "synthetic-csi-adapter@0");
// Boundary validation: empty, mismatched, over-bounded, or non-finite
// input degrades to UNKNOWN rather than panicking or fabricating a
// confident value.
let malformed = raw.amplitudes.is_empty()
|| raw.amplitudes.len() != raw.phases.len()
|| raw.amplitudes.len() > MAX_CSI_TAPS
|| raw.amplitudes.iter().any(|v| !v.is_finite())
|| raw.phases.iter().any(|v| !v.is_finite());
let uncertainty = if malformed {
Uncertainty::degraded()
} else {
// Deterministic confidence from the mean amplitude, bounded to
// [0, 1) by a saturating map. No randomness, no clock.
let sum: f64 = raw.amplitudes.iter().map(|&v| f64::from(v).abs()).sum();
let mean = sum / raw.amplitudes.len() as f64;
Uncertainty::known(mean / (mean + 1.0))
};
HalObservation {
modality: Modality::Csi,
uncertainty,
observation,
}
}
}
/// A native IMU raw sample: 3-axis acceleration and angular rate.
#[derive(Clone, Debug, PartialEq)]
pub struct ImuSample {
/// Acceleration `[x, y, z]` in m/s².
pub accel: [f32; 3],
/// Angular rate `[x, y, z]` in rad/s.
pub gyro: [f32; 3],
}
/// A deterministic, synthetic IMU reference adapter (SYNTHETIC / L0).
///
/// The required non-RF second modality (ADR-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::<f64>()
.sqrt();
let closeness = 1.0 - ((g - 9.81).abs() / 9.81);
Uncertainty::known(closeness)
};
HalObservation {
modality: Modality::Imu,
uncertainty,
observation,
}
}
}
+66
View File
@@ -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<f64>,
/// Native unit label for one raw sample (e.g. `"csi-complex"`, `"m/s^2"`,
/// `"dBm"`). Descriptive free-form metadata, not a parsed quantity.
pub unit: String,
/// Native dimensionality of one raw frame (e.g. subcarriers × antennas, or
/// IMU axes). `0` means unknown.
pub dimensions: u32,
}
/// A canonical description of one sensing device.
///
/// Round-trips losslessly through serde so a fleet controller (ADR-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<CapabilityTag>,
/// Native sampling / units metadata for the raw frame.
pub sampling: SamplingSpec,
}
impl SensorDescriptor {
/// Re-validate a descriptor received from an untrusted source. Checks the
/// modality label bounds; ids and capability tags are validated when
/// constructed. Returns UNKNOWN-friendly `Ok(())` for any well-formed
/// descriptor.
pub fn validate(&self) -> Result<(), crate::label::LabelError> {
self.modality.validate()
}
}
+82
View File
@@ -0,0 +1,82 @@
//! Bounded-string validation shared by the HAL's boundary types.
//!
//! Capability tags and `Modality::Custom` payloads arrive from potentially
//! untrusted hardware descriptors. They are validated at construction with the
//! same discipline the ontology applies to ids: non-empty, length-bounded, and
//! free of ASCII control characters (CLAUDE.md: validate untrusted input at
//! every boundary; bound allocation).
use serde::{Deserialize, Serialize};
use thiserror::Error;
/// Maximum accepted label length, in bytes. Bounds allocation on untrusted
/// input.
pub const MAX_LABEL_LEN: usize = 128;
/// Reasons a raw label string is rejected at the boundary.
#[derive(Clone, Debug, PartialEq, Eq, Error)]
pub enum LabelError {
/// The label was empty.
#[error("label must not be empty")]
Empty,
/// The label exceeded [`MAX_LABEL_LEN`] bytes.
#[error("label length {len} exceeds maximum {max}")]
TooLong {
/// Actual length in bytes.
len: usize,
/// The enforced maximum.
max: usize,
},
/// The label contained an ASCII control character.
#[error("label contains a control character at byte {pos}")]
ControlChar {
/// Byte offset of the offending control character.
pos: usize,
},
}
/// Validate a raw label: non-empty, bounded length, no control characters.
pub(crate) fn validate_label(raw: &str) -> Result<(), LabelError> {
if raw.is_empty() {
return Err(LabelError::Empty);
}
if raw.len() > MAX_LABEL_LEN {
return Err(LabelError::TooLong {
len: raw.len(),
max: MAX_LABEL_LEN,
});
}
if let Some(pos) = raw.bytes().position(|b| b.is_ascii_control()) {
return Err(LabelError::ControlChar { pos });
}
Ok(())
}
/// A validated, bounded capability tag describing one phenomenon a sensor can
/// observe (e.g. `"amplitude"`, `"range"`, `"accel"`). Reuses the ontology's
/// id-style validation discipline rather than accepting a raw `String`.
#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(transparent)]
pub struct CapabilityTag(String);
impl CapabilityTag {
/// Construct a validated tag, rejecting empty, over-long, or
/// control-character input at the boundary.
pub fn new(raw: impl Into<String>) -> Result<Self, LabelError> {
let s = raw.into();
validate_label(&s)?;
Ok(Self(s))
}
/// Borrow the underlying tag string.
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl core::fmt::Display for CapabilityTag {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.write_str(&self.0)
}
}
+343
View File
@@ -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);
}
}
+91
View File
@@ -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<String>) -> Result<Self, LabelError> {
let s = raw.into();
validate_label(&s)?;
Ok(Self::Custom(s))
}
/// Re-validate a modality received from an untrusted source (e.g. after
/// deserialization). Closed variants are always valid; a `Custom` payload
/// must satisfy the label bounds.
pub fn validate(&self) -> Result<(), LabelError> {
match self {
Self::Custom(s) => validate_label(s),
_ => Ok(()),
}
}
/// A stable lowercase label for this modality, matching its serialized tag.
/// For [`Modality::Custom`] this is the inner label.
#[must_use]
pub fn label(&self) -> &str {
match self {
Self::Csi => "csi",
Self::Ieee80211bf => "ieee80211bf",
Self::Ble => "ble",
Self::Uwb => "uwb",
Self::Mmwave => "mmwave",
Self::Acoustic => "acoustic",
Self::Camera => "camera",
Self::Lidar => "lidar",
Self::Imu => "imu",
Self::Custom(s) => s,
}
}
/// True for radio-frequency modalities, which reuse the ADR-279 native RF
/// frame / shared-latent adapters wholesale.
#[must_use]
pub fn is_rf(&self) -> bool {
matches!(
self,
Self::Csi | Self::Ieee80211bf | Self::Ble | Self::Uwb | Self::Mmwave
)
}
}
+156
View File
@@ -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
}
}
+15
View File
@@ -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
+76
View File
@@ -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
}
}
}
+36
View File
@@ -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,
}
+152
View File
@@ -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<u8>,
}
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<Self, TrackError> {
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());
}
}
+78
View File
@@ -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<dyn std::error::Error>>(())
//! ```
#![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;
+539
View File
@@ -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<Self, TrackError> {
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<Waypoint>,
}
/// Persistent probabilistic tracker producing ADR-303 `Track`/`Person` nodes
/// without civil identity.
#[derive(Clone, Debug)]
pub struct TrackManager {
config: TrackerConfig,
topology: Topology,
entities: BTreeMap<TrackId, Entity>,
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<TrackId> {
self.entities.keys().cloned().collect()
}
/// The lifecycle state of a track, if held.
#[must_use]
pub fn state(&self, track: &TrackId) -> Option<TrackState> {
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<Vec<Container>> {
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<TrackId> {
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<IngestOutcome, TrackError> {
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<Vec<IngestOutcome>, 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<UnknownReason> = 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<Option<(TrackId, f64)>> = vec![None; n];
let mut track_used: BTreeMap<TrackId, ()> = 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<PersonId, TrackError> {
// 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<Track> {
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<Person> {
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<TrackId, TrackError> {
self.track_counter += 1;
Ok(TrackId::new(format!("track_{}", self.track_counter))?)
}
fn next_person_id(&mut self) -> Result<PersonId, TrackError> {
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)
}
+93
View File
@@ -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")));
}
}
+277
View File
@@ -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);
}