mirror of
https://github.com/ruvnet/RuView
synced 2026-08-11 20:41:44 +00:00
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:
@@ -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,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,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
@@ -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"
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
@@ -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()),
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
})
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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 { .. }
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
//! Mandatory session scope (ADR-300 §3, mirroring ADR-290).
|
||||
//!
|
||||
//! An agreement report without scope cannot be constructed: WiFi-sensing
|
||||
//! numbers without stated scope (subject count, motion, line-of-sight,
|
||||
//! distance) are systematically misleading (ADR-290 Context). [`SessionScope`]
|
||||
//! is a required argument to [`crate::AgreementReport::build`], so the type
|
||||
//! system enforces the rule.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::GroundTruthError;
|
||||
|
||||
/// The largest subject count accepted, bounding untrusted input.
|
||||
pub const MAX_SUBJECTS: u16 = 4096;
|
||||
|
||||
/// Whether subjects were static or moving during the session.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum MotionState {
|
||||
/// Subject(s) static / at rest.
|
||||
Static,
|
||||
/// Subject(s) moving.
|
||||
Moving,
|
||||
/// A mix of static and moving intervals.
|
||||
Mixed,
|
||||
}
|
||||
|
||||
/// The propagation condition between sensor and subject.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum LineOfSight {
|
||||
/// Line-of-sight.
|
||||
Los,
|
||||
/// Non-line-of-sight (obstructed, same room).
|
||||
Nlos,
|
||||
/// Through-wall.
|
||||
ThroughWall,
|
||||
}
|
||||
|
||||
/// A coarse distance band between sensor and subject.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum DistanceBand {
|
||||
/// Near (roughly < 2 m).
|
||||
Near,
|
||||
/// Mid (roughly 2–5 m).
|
||||
Mid,
|
||||
/// Far (roughly > 5 m).
|
||||
Far,
|
||||
}
|
||||
|
||||
/// Mandatory metadata attached to every [`crate::AgreementReport`]. A report
|
||||
/// cannot exist without it, so an agreement number always states the conditions
|
||||
/// it was measured under.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct SessionScope {
|
||||
/// Number of subjects present (0 is valid for an empty-room session).
|
||||
pub subject_count: u16,
|
||||
/// Motion state during the session.
|
||||
pub motion: MotionState,
|
||||
/// Line-of-sight condition.
|
||||
pub line_of_sight: LineOfSight,
|
||||
/// Distance band.
|
||||
pub distance: DistanceBand,
|
||||
}
|
||||
|
||||
impl SessionScope {
|
||||
/// Construct a validated session scope. `subject_count` is bounded to
|
||||
/// [`MAX_SUBJECTS`] so untrusted metadata cannot claim an absurd count.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`GroundTruthError::SubjectCountTooLarge`] if `subject_count` exceeds
|
||||
/// [`MAX_SUBJECTS`].
|
||||
pub fn new(
|
||||
subject_count: u16,
|
||||
motion: MotionState,
|
||||
line_of_sight: LineOfSight,
|
||||
distance: DistanceBand,
|
||||
) -> Result<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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user