mirror of
https://github.com/ruvnet/RuView
synced 2026-08-06 19:51:43 +00:00
004a63e82d
- Upgrade openssl to 0.10.78 (CVE-2026-41676), jsonwebtoken to 9.4 - Suppress unmaintained-only/no-CVE advisories in .cargo/audit.toml with per-entry rationale - Fix all `cargo clippy --all-targets -- -D warnings` errors across 35 crates: derivable_impls, needless_range_loop, map_or→is_some_and/ is_none_or, await_holding_lock (drop MutexGuard before .await), ptr_arg (&mut Vec→&mut [T]), useless_conversion, approximate_constant (2.718→E, 3.14→PI), field_reassign_with_default, manual_inspect, useless_vec, lines_filter_map_ok, print_literal, dead_code - Apply `cargo fmt --all` - Pre-existing test failure in wifi-densepose-signal (test_estimate_occupancy_noise_only) is not introduced by this PR
305 lines
9.8 KiB
Rust
305 lines
9.8 KiB
Rust
//! Track lifecycle state machine for survivor tracking.
|
||
//!
|
||
//! Manages the lifecycle of a tracked survivor:
|
||
//! Tentative → Active → Lost → Terminated (or Rescued)
|
||
|
||
/// Configuration for SurvivorTracker behaviour.
|
||
#[derive(Debug, Clone)]
|
||
pub struct TrackerConfig {
|
||
/// Consecutive hits required to promote Tentative → Active (default: 2)
|
||
pub birth_hits_required: u32,
|
||
/// Consecutive misses to transition Active → Lost (default: 3)
|
||
pub max_active_misses: u32,
|
||
/// Seconds a Lost track is eligible for re-identification (default: 30.0)
|
||
pub max_lost_age_secs: f64,
|
||
/// Fingerprint distance threshold for re-identification (default: 0.35)
|
||
pub reid_threshold: f32,
|
||
/// Mahalanobis distance² gate for data association (default: 9.0 = 3σ in 3D)
|
||
pub gate_mahalanobis_sq: f64,
|
||
/// Kalman measurement noise variance σ²_obs in m² (default: 2.25 = 1.5m²)
|
||
pub obs_noise_var: f64,
|
||
/// Kalman process noise variance σ²_a in (m/s²)² (default: 0.01)
|
||
pub process_noise_var: f64,
|
||
}
|
||
|
||
impl Default for TrackerConfig {
|
||
fn default() -> Self {
|
||
Self {
|
||
birth_hits_required: 2,
|
||
max_active_misses: 3,
|
||
max_lost_age_secs: 30.0,
|
||
reid_threshold: 0.35,
|
||
gate_mahalanobis_sq: 9.0,
|
||
obs_noise_var: 2.25,
|
||
process_noise_var: 0.01,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Current lifecycle state of a tracked survivor.
|
||
#[derive(Debug, Clone, PartialEq)]
|
||
pub enum TrackState {
|
||
/// Newly detected; awaiting confirmation hits.
|
||
Tentative {
|
||
/// Number of consecutive matched observations received.
|
||
hits: u32,
|
||
},
|
||
/// Confirmed active track; receiving regular observations.
|
||
Active,
|
||
/// Signal lost; Kalman predicts position; re-ID window open.
|
||
Lost {
|
||
/// Consecutive frames missed since going Lost.
|
||
miss_count: u32,
|
||
/// Instant when the track entered Lost state.
|
||
lost_since: std::time::Instant,
|
||
},
|
||
/// Re-ID window expired or explicitly terminated. Cannot recover.
|
||
Terminated,
|
||
/// Operator confirmed rescue. Terminal state.
|
||
Rescued,
|
||
}
|
||
|
||
/// Controls lifecycle transitions for a single track.
|
||
pub struct TrackLifecycle {
|
||
state: TrackState,
|
||
birth_hits_required: u32,
|
||
max_active_misses: u32,
|
||
#[allow(dead_code)]
|
||
max_lost_age_secs: f64,
|
||
/// Consecutive misses while Active (resets on hit).
|
||
active_miss_count: u32,
|
||
}
|
||
|
||
impl TrackLifecycle {
|
||
/// Create a new lifecycle starting in Tentative { hits: 0 }.
|
||
pub fn new(config: &TrackerConfig) -> Self {
|
||
Self {
|
||
state: TrackState::Tentative { hits: 0 },
|
||
birth_hits_required: config.birth_hits_required,
|
||
max_active_misses: config.max_active_misses,
|
||
max_lost_age_secs: config.max_lost_age_secs,
|
||
active_miss_count: 0,
|
||
}
|
||
}
|
||
|
||
/// Register a matched observation this frame.
|
||
///
|
||
/// - Tentative: increment hits; if hits >= birth_hits_required → Active
|
||
/// - Active: reset active_miss_count
|
||
/// - Lost: transition back to Active, reset miss_count
|
||
pub fn hit(&mut self) {
|
||
match &self.state {
|
||
TrackState::Tentative { hits } => {
|
||
let new_hits = hits + 1;
|
||
if new_hits >= self.birth_hits_required {
|
||
self.state = TrackState::Active;
|
||
self.active_miss_count = 0;
|
||
} else {
|
||
self.state = TrackState::Tentative { hits: new_hits };
|
||
}
|
||
}
|
||
TrackState::Active => {
|
||
self.active_miss_count = 0;
|
||
}
|
||
TrackState::Lost { .. } => {
|
||
self.state = TrackState::Active;
|
||
self.active_miss_count = 0;
|
||
}
|
||
// Terminal states: no transition
|
||
TrackState::Terminated | TrackState::Rescued => {}
|
||
}
|
||
}
|
||
|
||
/// Register a frame with no matching observation.
|
||
///
|
||
/// - Tentative: → Terminated immediately (not enough evidence)
|
||
/// - Active: increment active_miss_count; if >= max_active_misses → Lost
|
||
/// - Lost: increment miss_count
|
||
pub fn miss(&mut self) {
|
||
match &self.state {
|
||
TrackState::Tentative { .. } => {
|
||
self.state = TrackState::Terminated;
|
||
}
|
||
TrackState::Active => {
|
||
self.active_miss_count += 1;
|
||
if self.active_miss_count >= self.max_active_misses {
|
||
self.state = TrackState::Lost {
|
||
miss_count: 0,
|
||
lost_since: std::time::Instant::now(),
|
||
};
|
||
}
|
||
}
|
||
TrackState::Lost {
|
||
miss_count,
|
||
lost_since,
|
||
} => {
|
||
let new_count = miss_count + 1;
|
||
let since = *lost_since;
|
||
self.state = TrackState::Lost {
|
||
miss_count: new_count,
|
||
lost_since: since,
|
||
};
|
||
}
|
||
// Terminal states: no transition
|
||
TrackState::Terminated | TrackState::Rescued => {}
|
||
}
|
||
}
|
||
|
||
/// Operator marks survivor as rescued.
|
||
pub fn rescue(&mut self) {
|
||
self.state = TrackState::Rescued;
|
||
}
|
||
|
||
/// Called each tick to check if Lost track has expired.
|
||
pub fn check_lost_expiry(&mut self, now: std::time::Instant, max_lost_age_secs: f64) {
|
||
if let TrackState::Lost { lost_since, .. } = &self.state {
|
||
let elapsed = now.duration_since(*lost_since).as_secs_f64();
|
||
if elapsed > max_lost_age_secs {
|
||
self.state = TrackState::Terminated;
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Get the current state.
|
||
pub fn state(&self) -> &TrackState {
|
||
&self.state
|
||
}
|
||
|
||
/// True if track is Active or Tentative (should keep in active pool).
|
||
pub fn is_active_or_tentative(&self) -> bool {
|
||
matches!(
|
||
self.state,
|
||
TrackState::Active | TrackState::Tentative { .. }
|
||
)
|
||
}
|
||
|
||
/// True if track is in Lost state.
|
||
pub fn is_lost(&self) -> bool {
|
||
matches!(self.state, TrackState::Lost { .. })
|
||
}
|
||
|
||
/// True if track is Terminated or Rescued (remove from pool eventually).
|
||
pub fn is_terminal(&self) -> bool {
|
||
matches!(self.state, TrackState::Terminated | TrackState::Rescued)
|
||
}
|
||
|
||
/// True if a Lost track is still within re-ID window.
|
||
pub fn can_reidentify(&self, now: std::time::Instant, max_lost_age_secs: f64) -> bool {
|
||
if let TrackState::Lost { lost_since, .. } = &self.state {
|
||
let elapsed = now.duration_since(*lost_since).as_secs_f64();
|
||
elapsed <= max_lost_age_secs
|
||
} else {
|
||
false
|
||
}
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use std::time::{Duration, Instant};
|
||
|
||
fn default_lifecycle() -> TrackLifecycle {
|
||
TrackLifecycle::new(&TrackerConfig::default())
|
||
}
|
||
|
||
#[test]
|
||
fn test_tentative_confirmation() {
|
||
// Default config: birth_hits_required = 2
|
||
let mut lc = default_lifecycle();
|
||
assert!(matches!(lc.state(), TrackState::Tentative { hits: 0 }));
|
||
|
||
lc.hit();
|
||
assert!(matches!(lc.state(), TrackState::Tentative { hits: 1 }));
|
||
|
||
lc.hit();
|
||
// 2 hits → Active
|
||
assert!(matches!(lc.state(), TrackState::Active));
|
||
assert!(lc.is_active_or_tentative());
|
||
assert!(!lc.is_lost());
|
||
assert!(!lc.is_terminal());
|
||
}
|
||
|
||
#[test]
|
||
fn test_tentative_miss_terminates() {
|
||
let mut lc = default_lifecycle();
|
||
assert!(matches!(lc.state(), TrackState::Tentative { .. }));
|
||
|
||
// 1 miss while Tentative → Terminated
|
||
lc.miss();
|
||
assert!(matches!(lc.state(), TrackState::Terminated));
|
||
assert!(lc.is_terminal());
|
||
assert!(!lc.is_active_or_tentative());
|
||
}
|
||
|
||
#[test]
|
||
fn test_active_to_lost() {
|
||
let mut lc = default_lifecycle();
|
||
// Confirm the track first
|
||
lc.hit();
|
||
lc.hit();
|
||
assert!(matches!(lc.state(), TrackState::Active));
|
||
|
||
// Default: max_active_misses = 3
|
||
lc.miss();
|
||
assert!(matches!(lc.state(), TrackState::Active));
|
||
lc.miss();
|
||
assert!(matches!(lc.state(), TrackState::Active));
|
||
lc.miss();
|
||
// 3 misses → Lost
|
||
assert!(lc.is_lost());
|
||
assert!(!lc.is_active_or_tentative());
|
||
}
|
||
|
||
#[test]
|
||
fn test_lost_to_active_via_hit() {
|
||
let mut lc = default_lifecycle();
|
||
lc.hit();
|
||
lc.hit();
|
||
// Drive to Lost
|
||
lc.miss();
|
||
lc.miss();
|
||
lc.miss();
|
||
assert!(lc.is_lost());
|
||
|
||
// Hit while Lost → Active
|
||
lc.hit();
|
||
assert!(matches!(lc.state(), TrackState::Active));
|
||
assert!(lc.is_active_or_tentative());
|
||
}
|
||
|
||
#[test]
|
||
fn test_lost_expiry() {
|
||
let mut lc = default_lifecycle();
|
||
lc.hit();
|
||
lc.hit();
|
||
lc.miss();
|
||
lc.miss();
|
||
lc.miss();
|
||
assert!(lc.is_lost());
|
||
|
||
// Simulate expiry: use an Instant far in the past for lost_since
|
||
// by calling check_lost_expiry with a "now" that is 31 seconds ahead
|
||
// We need to get the lost_since from the state and fake expiry.
|
||
// Since Instant is opaque, we call check_lost_expiry with a now
|
||
// that is at least max_lost_age_secs after lost_since.
|
||
// We achieve this by sleeping briefly then using a future-shifted now.
|
||
let future_now = Instant::now() + Duration::from_secs(31);
|
||
lc.check_lost_expiry(future_now, 30.0);
|
||
assert!(matches!(lc.state(), TrackState::Terminated));
|
||
assert!(lc.is_terminal());
|
||
}
|
||
|
||
#[test]
|
||
fn test_rescue() {
|
||
let mut lc = default_lifecycle();
|
||
lc.hit();
|
||
lc.hit();
|
||
assert!(matches!(lc.state(), TrackState::Active));
|
||
|
||
lc.rescue();
|
||
assert!(matches!(lc.state(), TrackState::Rescued));
|
||
assert!(lc.is_terminal());
|
||
}
|
||
}
|