diff --git a/v2/crates/wifi-densepose-signal/src/ruvsense/evolution.rs b/v2/crates/wifi-densepose-signal/src/ruvsense/evolution.rs new file mode 100644 index 00000000..e57b53d3 --- /dev/null +++ b/v2/crates/wifi-densepose-signal/src/ruvsense/evolution.rs @@ -0,0 +1,358 @@ +//! ADR-142 — Channel-state evolution tracking + temporal VoxelMap. +//! +//! Two cooperating pieces, both extending ADR-030's field-model tier: +//! +//! 1. [`EvolutionTracker`] — per-link rolling [`WelfordStats`] baselines with a +//! cross-link change-point detector (≥ `min_links` links exceeding `nσ` in +//! one window ⇒ a `ChangePoint`). This catches environment changes that a +//! single-link drift check misses. +//! 2. [`TemporalVoxelMap`] — a *temporal* occupancy grid (distinct from the +//! static `tomography::OccupancyVolume`): each [`TemporalVoxel`] accumulates +//! evidence with a Bayesian log-odds update, tracks `last_update_ns`, +//! `evidence_count`, and Welford amplitude variance, and is privacy-gated by +//! [`VoxelGate`] before any occupancy leaves the node. + +use crate::ruvsense::field_model::WelfordStats; + +/// Privacy posture applied to voxel output (mirrors the BFLD demotion ladder of +/// ADR-120/141 without taking a crate dependency on `wifi-densepose-bfld`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum VoxelPrivacy { + /// Full per-voxel detail (occupancy + confidence + doppler). + Full, + /// Drop per-voxel doppler + confidence detail; keep occupancy. + Anonymous, + /// Emit only an aggregate occupancy histogram; raw map never leaves node. + Restricted, +} + +/// A single temporal occupancy voxel (ADR-142 §2). +#[derive(Debug, Clone)] +pub struct TemporalVoxel { + /// Voxel centre (east, north, up) in metres. + pub center: [f64; 3], + /// Posterior occupancy probability in [0, 1]. + pub occupancy: f64, + /// Internal Bayesian log-odds (occupancy = sigmoid(log_odds)). + log_odds: f64, + /// Confidence in [0, 1]; grows with evidence count. + pub confidence: f64, + /// Number of evidence updates folded in. + pub evidence_count: u64, + /// Most recent doppler velocity (m/s) attributed to this voxel, if any. + pub doppler_velocity: Option, + /// Capture-clock time of the last update (ns). + pub last_update_ns: u64, + /// Welford stats over the occupancy-evidence stream (for variance). + welford: WelfordStats, +} + +impl TemporalVoxel { + /// Empty voxel at a centre, prior occupancy 0.5 (log-odds 0). + #[must_use] + pub fn new(center: [f64; 3]) -> Self { + Self { + center, + occupancy: 0.5, + log_odds: 0.0, + confidence: 0.0, + evidence_count: 0, + doppler_velocity: None, + last_update_ns: 0, + welford: WelfordStats::new(), + } + } + + /// Fold one occupancy-evidence probability `p ∈ (0, 1)` into the posterior + /// via a clamped log-odds update, and (optionally) attribute a doppler + /// velocity. Confidence saturates as `1 - exp(-count / 5)` — so a voxel with + /// fewer than ~5 updates is low-confidence (ADR-142 §2 5-frame rule). + pub fn observe(&mut self, p: f64, doppler: Option, ns: u64) { + let p = p.clamp(1e-4, 1.0 - 1e-4); + let evidence_logit = (p / (1.0 - p)).ln(); + // Clamp the running log-odds so a single bad frame cannot saturate. + self.log_odds = (self.log_odds + evidence_logit).clamp(-20.0, 20.0); + self.occupancy = 1.0 / (1.0 + (-self.log_odds).exp()); + self.welford.update(p); + self.evidence_count += 1; + self.confidence = 1.0 - (-(self.evidence_count as f64) / 5.0).exp(); + if doppler.is_some() { + self.doppler_velocity = doppler; + } + self.last_update_ns = ns; + } + + /// True if too few updates have accumulated for a trustworthy posterior. + #[must_use] + pub fn is_low_confidence(&self) -> bool { + self.evidence_count < 5 + } + + /// Welford variance of the occupancy-evidence stream. + #[must_use] + pub fn evidence_variance(&self) -> f64 { + self.welford.variance() + } +} + +/// A persistent temporal occupancy grid shared across reconstruct() cycles. +#[derive(Debug, Clone)] +pub struct TemporalVoxelMap { + voxels: Vec, +} + +impl TemporalVoxelMap { + /// Build a grid of voxels at the supplied centres. + #[must_use] + pub fn new(centers: Vec<[f64; 3]>) -> Self { + Self { voxels: centers.into_iter().map(TemporalVoxel::new).collect() } + } + + /// Number of voxels. + #[must_use] + pub fn len(&self) -> usize { + self.voxels.len() + } + + /// Whether the grid is empty. + #[must_use] + pub fn is_empty(&self) -> bool { + self.voxels.is_empty() + } + + /// Borrow a voxel. + #[must_use] + pub fn voxel(&self, idx: usize) -> Option<&TemporalVoxel> { + self.voxels.get(idx) + } + + /// Fold occupancy evidence into one voxel. + pub fn observe(&mut self, idx: usize, p: f64, doppler: Option, ns: u64) { + if let Some(v) = self.voxels.get_mut(idx) { + v.observe(p, doppler, ns); + } + } + + /// Indices of voxels still below the confidence floor. + #[must_use] + pub fn low_confidence_indices(&self) -> Vec { + self.voxels + .iter() + .enumerate() + .filter(|(_, v)| v.is_low_confidence()) + .map(|(i, _)| i) + .collect() + } + + /// Occupancy of every voxel (read view). + #[must_use] + pub fn occupancies(&self) -> Vec { + self.voxels.iter().map(|v| v.occupancy).collect() + } +} + +/// Privacy gate over voxel output (ADR-142 §2 — reuses the BFLD monotonic +/// demotion idea: information only ever removed, never added). +pub struct VoxelGate; + +impl VoxelGate { + /// Apply a privacy posture to the map, mutating it in place, and return an + /// optional aggregate histogram (Some only for `Restricted`, where the raw + /// map must not leave the node). + /// + /// - `Full`: unchanged. + /// - `Anonymous`: clear per-voxel doppler + zero the confidence detail + /// (occupancy retained). + /// - `Restricted`: produce an occupancy histogram (`bins` buckets over + /// [0,1]) and clear every voxel's occupancy/doppler/confidence so only the + /// aggregate survives. + pub fn demote(map: &mut TemporalVoxelMap, posture: VoxelPrivacy, bins: usize) -> Option> { + match posture { + VoxelPrivacy::Full => None, + VoxelPrivacy::Anonymous => { + for v in &mut map.voxels { + v.doppler_velocity = None; + v.confidence = 0.0; + } + None + } + VoxelPrivacy::Restricted => { + let bins = bins.max(1); + let mut hist = vec![0u32; bins]; + for v in &map.voxels { + let b = ((v.occupancy * bins as f64) as usize).min(bins - 1); + hist[b] += 1; + } + for v in &mut map.voxels { + v.occupancy = 0.0; + v.doppler_velocity = None; + v.confidence = 0.0; + } + Some(hist) + } + } + } +} + +/// A cross-link change-point: enough links diverged from baseline at once that +/// the environment itself likely changed (ADR-142 §2). +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct ChangePoint { + /// How many links exceeded the σ threshold this window. + pub diverging_links: usize, + /// The σ threshold used. + pub sigma_threshold: f64, +} + +/// Per-link rolling baseline tracker with cross-link change-point detection +/// (ADR-142 §2; extends ADR-030). +#[derive(Debug, Clone)] +pub struct EvolutionTracker { + links: Vec, + sigma_threshold: f64, + min_links: usize, +} + +impl EvolutionTracker { + /// Track `n_links` links; flag a change-point when at least `min_links` + /// links exceed `sigma_threshold`σ of their own baseline in one window. + #[must_use] + pub fn new(n_links: usize, sigma_threshold: f64, min_links: usize) -> Self { + Self { + links: (0..n_links).map(|_| WelfordStats::new()).collect(), + sigma_threshold, + min_links, + } + } + + /// Default: 2σ threshold, ≥3 links (ADR-142 §2). + #[must_use] + pub fn with_defaults(n_links: usize) -> Self { + Self::new(n_links, 2.0, 3) + } + + /// Number of links tracked. + #[must_use] + pub fn n_links(&self) -> usize { + self.links.len() + } + + /// True if `value` on `link_idx` is beyond `sigma_threshold`σ of that link's + /// established baseline (needs ≥2 prior observations). + #[must_use] + pub fn is_link_diverging(&self, link_idx: usize, value: f64) -> bool { + match self.links.get(link_idx) { + Some(w) if w.count >= 2 && w.std_dev() > 1e-9 => { + (value - w.mean).abs() / w.std_dev() > self.sigma_threshold + } + _ => false, + } + } + + /// Fold one observation per link, returning a [`ChangePoint`] when the + /// number of simultaneously-diverging links reaches `min_links`. Divergence + /// is evaluated against the *prior* baseline before this sample is folded in. + pub fn observe_window(&mut self, values: &[f64]) -> Option { + let mut diverging = 0usize; + for (i, &v) in values.iter().enumerate() { + if self.is_link_diverging(i, v) { + diverging += 1; + } + } + // Fold the samples in after the divergence check. + for (w, &v) in self.links.iter_mut().zip(values.iter()) { + w.update(v); + } + if diverging >= self.min_links { + Some(ChangePoint { diverging_links: diverging, sigma_threshold: self.sigma_threshold }) + } else { + None + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn voxel_bayesian_update_raises_occupancy_and_confidence() { + let mut v = TemporalVoxel::new([0.0, 0.0, 0.0]); + assert!((v.occupancy - 0.5).abs() < 1e-9); + assert!(v.is_low_confidence()); + for ns in 0..10 { + v.observe(0.8, Some(0.3), ns); + } + assert!(v.occupancy > 0.9, "repeated positive evidence → high occupancy"); + assert!(!v.is_low_confidence(), "10 updates ⇒ confident"); + assert!(v.confidence > 0.8); + assert_eq!(v.last_update_ns, 9); + assert_eq!(v.doppler_velocity, Some(0.3)); + } + + #[test] + fn voxel_low_confidence_below_five_frames() { + let mut v = TemporalVoxel::new([1.0, 1.0, 0.0]); + for ns in 0..4 { + v.observe(0.7, None, ns); + } + assert!(v.is_low_confidence()); + v.observe(0.7, None, 4); + assert!(!v.is_low_confidence(), "5th frame crosses the floor"); + } + + #[test] + fn voxel_map_tracks_low_confidence() { + let mut m = TemporalVoxelMap::new(vec![[0.0; 3], [1.0; 3]]); + assert_eq!(m.len(), 2); + for ns in 0..6 { + m.observe(0, 0.9, None, ns); + } + // Voxel 0 confident, voxel 1 never observed → low. + assert_eq!(m.low_confidence_indices(), vec![1]); + } + + #[test] + fn privacy_gate_anonymous_clears_doppler_keeps_occupancy() { + let mut m = TemporalVoxelMap::new(vec![[0.0; 3]]); + for ns in 0..6 { + m.observe(0, 0.9, Some(0.5), ns); + } + let occ_before = m.voxel(0).unwrap().occupancy; + assert!(VoxelGate::demote(&mut m, VoxelPrivacy::Anonymous, 4).is_none()); + let v = m.voxel(0).unwrap(); + assert_eq!(v.doppler_velocity, None); + assert_eq!(v.confidence, 0.0); + assert!((v.occupancy - occ_before).abs() < 1e-9, "occupancy retained"); + } + + #[test] + fn privacy_gate_restricted_yields_histogram_and_clears() { + let mut m = TemporalVoxelMap::new(vec![[0.0; 3], [1.0; 3], [2.0; 3]]); + for ns in 0..6 { + m.observe(0, 0.95, None, ns); + m.observe(1, 0.95, None, ns); + } + let hist = VoxelGate::demote(&mut m, VoxelPrivacy::Restricted, 4).expect("histogram"); + assert_eq!(hist.iter().sum::(), 3, "all 3 voxels binned"); + // Raw occupancy cleared. + assert!(m.occupancies().iter().all(|&o| o == 0.0)); + } + + #[test] + fn evolution_tracker_detects_cross_link_change_point() { + let mut t = EvolutionTracker::with_defaults(4); + // Establish stable baselines (~1.0) with realistic small jitter so each + // link has a non-zero std (a perfectly constant baseline has std 0 and + // divergence is undefined). + for i in 0..30 { + let jitter = if i % 2 == 0 { 0.99 } else { 1.01 }; + assert!(t.observe_window(&[jitter, jitter, jitter, jitter]).is_none()); + } + // A divergence on a single link must NOT trip a change-point (< min_links). + assert!(t.observe_window(&[5.0, 1.0, 1.0, 1.0]).is_none()); + // A large simultaneous excursion on 3 links → change-point. + let cp = t.observe_window(&[5.0, 5.0, 5.0, 1.0]); + assert!(matches!(cp, Some(ChangePoint { diverging_links, .. }) if diverging_links >= 3)); + } +} diff --git a/v2/crates/wifi-densepose-signal/src/ruvsense/mod.rs b/v2/crates/wifi-densepose-signal/src/ruvsense/mod.rs index f6c3c13f..c6640e87 100644 --- a/v2/crates/wifi-densepose-signal/src/ruvsense/mod.rs +++ b/v2/crates/wifi-densepose-signal/src/ruvsense/mod.rs @@ -64,6 +64,9 @@ pub mod fusion_quality; // ADR-138: Array coordinator — clock-quality gating + directional evidence pub mod array_coordinator; +// ADR-142: Evolution tracker + temporal VoxelMap (Bayesian, privacy-gated) +pub mod evolution; + // ADR-135: Empty-room baseline calibration (Welford online, circular phase) pub mod calibration; @@ -73,6 +76,9 @@ pub use coherence_gate::{GateDecision, GatePolicy}; pub use array_coordinator::{ ArrayCoordinator, ArrayCoordinatorConfig, ArrayNodeInput, DirectionalEvidence, }; +pub use evolution::{ + ChangePoint, EvolutionTracker, TemporalVoxel, TemporalVoxelMap, VoxelGate, VoxelPrivacy, +}; pub use fusion_quality::{ CalibrationId, ContradictionFlag, EvidenceRef, FamilyId, QualityScore, };