Files
ruvnet--RuView/v2/crates/ruview-groundtruth/src/align.rs
T
Claude 49c594822f 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
2026-08-11 03:16:33 +00:00

324 lines
12 KiB
Rust

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