#![forbid(unsafe_code)]
use web_time::{Duration, Instant};
#[derive(Debug, Clone)]
pub struct HoverStabilizerConfig {
pub drift_allowance: f32,
pub detection_threshold: f32,
pub hysteresis_cells: u16,
pub decay_rate: f32,
pub hold_timeout: Duration,
}
impl Default for HoverStabilizerConfig {
fn default() -> Self {
Self {
drift_allowance: 0.5,
detection_threshold: 2.0,
hysteresis_cells: 1,
decay_rate: 0.1,
hold_timeout: Duration::from_millis(500),
}
}
}
#[derive(Debug, Clone)]
struct CandidateTarget {
target_id: u64,
cusum_score: f32,
last_pos: (u16, u16),
}
#[derive(Debug)]
pub struct HoverStabilizer {
config: HoverStabilizerConfig,
current_target: Option<u64>,
current_target_pos: Option<(u16, u16)>,
last_update: Option<Instant>,
candidate: Option<CandidateTarget>,
switches: u64,
}
impl HoverStabilizer {
#[must_use]
pub fn new(config: HoverStabilizerConfig) -> Self {
Self {
config,
current_target: None,
current_target_pos: None,
last_update: None,
candidate: None,
switches: 0,
}
}
pub fn update(
&mut self,
hit_target: Option<u64>,
pos: (u16, u16),
now: Instant,
) -> Option<u64> {
if let Some(last) = self.last_update
&& now.saturating_duration_since(last) > self.config.hold_timeout
{
self.reset();
}
self.last_update = Some(now);
if self.current_target.is_none() {
if hit_target.is_some() {
self.current_target = hit_target;
self.current_target_pos = Some(pos);
self.switches += 1;
}
return self.current_target;
}
let current = self
.current_target
.expect("current_target guaranteed by is_none early return");
if hit_target == Some(current) {
self.decay_candidate();
self.current_target_pos = Some(pos);
return self.current_target;
}
let candidate_id = hit_target.unwrap_or(u64::MAX);
let distance = self.compute_distance_signal(pos);
self.update_candidate(candidate_id, distance, pos);
if let Some(ref cand) = self.candidate
&& cand.cusum_score >= self.config.detection_threshold
&& self.past_hysteresis_band(pos)
{
self.current_target = if candidate_id == u64::MAX {
None
} else {
Some(candidate_id)
};
self.current_target_pos = Some(pos);
self.candidate = None;
self.switches += 1;
}
self.current_target
}
#[inline]
#[must_use]
pub fn current_target(&self) -> Option<u64> {
self.current_target
}
pub fn reset(&mut self) {
self.current_target = None;
self.current_target_pos = None;
self.last_update = None;
self.candidate = None;
}
#[inline]
#[must_use]
pub fn switch_count(&self) -> u64 {
self.switches
}
#[inline]
#[must_use]
pub fn config(&self) -> &HoverStabilizerConfig {
&self.config
}
pub fn set_config(&mut self, config: HoverStabilizerConfig) {
self.config = config;
}
fn compute_distance_signal(&self, pos: (u16, u16)) -> f32 {
let Some(target_pos) = self.current_target_pos else {
return 1.0; };
let dx = (pos.0 as i32 - target_pos.0 as i32).abs();
let dy = (pos.1 as i32 - target_pos.1 as i32).abs();
let manhattan = (dx + dy) as f32;
let hysteresis = self.config.hysteresis_cells.max(1) as f32;
(manhattan - hysteresis) / hysteresis
}
fn update_candidate(&mut self, candidate_id: u64, distance_signal: f32, pos: (u16, u16)) {
let k = self.config.drift_allowance;
match &mut self.candidate {
Some(cand) if cand.target_id == candidate_id => {
cand.cusum_score = (cand.cusum_score + distance_signal - k).max(0.0);
cand.last_pos = pos;
}
_ => {
let initial_score = (distance_signal - k).max(0.0);
self.candidate = Some(CandidateTarget {
target_id: candidate_id,
cusum_score: initial_score,
last_pos: pos,
});
}
}
}
fn decay_candidate(&mut self) {
if let Some(ref mut cand) = self.candidate {
cand.cusum_score *= 1.0 - self.config.decay_rate;
if cand.cusum_score < 0.01 {
self.candidate = None;
}
}
}
fn past_hysteresis_band(&self, pos: (u16, u16)) -> bool {
let Some(target_pos) = self.current_target_pos else {
return true; };
let dx = (pos.0 as i32 - target_pos.0 as i32).unsigned_abs();
let dy = (pos.1 as i32 - target_pos.1 as i32).unsigned_abs();
let manhattan = dx + dy;
manhattan > u32::from(self.config.hysteresis_cells)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn now() -> Instant {
Instant::now()
}
fn stabilizer() -> HoverStabilizer {
HoverStabilizer::new(HoverStabilizerConfig::default())
}
#[test]
fn initial_state_is_none() {
let stab = stabilizer();
assert!(stab.current_target().is_none());
assert_eq!(stab.switch_count(), 0);
}
#[test]
fn first_hit_adopted_immediately() {
let mut stab = stabilizer();
let t = now();
let target = stab.update(Some(42), (10, 10), t);
assert_eq!(target, Some(42));
assert_eq!(stab.current_target(), Some(42));
assert_eq!(stab.switch_count(), 1);
}
#[test]
fn same_target_stays_stable() {
let mut stab = stabilizer();
let t = now();
stab.update(Some(42), (10, 10), t);
stab.update(Some(42), (10, 11), t);
stab.update(Some(42), (11, 10), t);
assert_eq!(stab.current_target(), Some(42));
assert_eq!(stab.switch_count(), 1); }
#[test]
fn jitter_does_not_switch() {
let mut stab = stabilizer();
let t = now();
stab.update(Some(42), (10, 10), t);
for i in 0..10 {
let target = if i % 2 == 0 { Some(99) } else { Some(42) };
stab.update(target, (10, 10 + (i % 2)), t);
}
assert_eq!(stab.current_target(), Some(42));
}
#[test]
fn sustained_crossing_triggers_switch() {
let mut stab = stabilizer();
let t = now();
stab.update(Some(42), (10, 10), t);
for i in 1..=5 {
stab.update(Some(99), (10, 10 + i * 2), t);
}
assert_eq!(stab.current_target(), Some(99));
assert!(stab.switch_count() >= 2);
}
#[test]
fn reset_clears_all_state() {
let mut stab = stabilizer();
let t = now();
stab.update(Some(42), (10, 10), t);
stab.reset();
assert!(stab.current_target().is_none());
}
#[test]
fn cusum_accumulates_on_consistent_signal() {
let mut stab = HoverStabilizer::new(HoverStabilizerConfig {
detection_threshold: 3.0,
hysteresis_cells: 0, ..Default::default()
});
let t = now();
stab.update(Some(42), (10, 10), t);
stab.update(Some(99), (15, 10), t);
stab.update(Some(99), (20, 10), t);
stab.update(Some(99), (25, 10), t);
assert_eq!(stab.current_target(), Some(99));
}
#[test]
fn cusum_resets_on_return() {
let mut stab = stabilizer();
let t = now();
stab.update(Some(42), (10, 10), t);
stab.update(Some(99), (12, 10), t);
stab.update(Some(42), (10, 10), t);
stab.update(Some(42), (10, 10), t);
stab.update(Some(42), (10, 10), t);
assert_eq!(stab.current_target(), Some(42));
}
#[test]
fn hysteresis_prevents_boundary_oscillation() {
let mut stab = HoverStabilizer::new(HoverStabilizerConfig {
hysteresis_cells: 2,
detection_threshold: 0.5, ..Default::default()
});
let t = now();
stab.update(Some(42), (10, 10), t);
stab.update(Some(99), (11, 10), t);
assert_eq!(stab.current_target(), Some(42));
stab.update(Some(99), (13, 10), t);
stab.update(Some(99), (14, 10), t);
stab.update(Some(99), (15, 10), t);
assert_eq!(stab.current_target(), Some(99));
}
#[test]
fn timeout_resets_target() {
let mut stab = HoverStabilizer::new(HoverStabilizerConfig {
hold_timeout: Duration::from_millis(100),
..Default::default()
});
let t = now();
stab.update(Some(42), (10, 10), t);
assert_eq!(stab.current_target(), Some(42));
let later = t + Duration::from_millis(200);
stab.update(Some(99), (20, 20), later);
assert_eq!(stab.current_target(), Some(99));
}
#[test]
fn transition_to_none_with_evidence() {
let mut stab = HoverStabilizer::new(HoverStabilizerConfig {
hysteresis_cells: 0,
detection_threshold: 1.0,
..Default::default()
});
let t = now();
stab.update(Some(42), (10, 10), t);
for i in 1..=5 {
stab.update(None, (10 + i * 3, 10), t);
}
assert!(stab.current_target().is_none());
}
#[test]
fn jitter_stability_rate() {
let mut stab = stabilizer();
let t = now();
stab.update(Some(42), (10, 10), t);
let mut stable_count = 0;
for i in 0..100 {
let target = if i % 2 == 0 { Some(99) } else { Some(42) };
stab.update(target, (10, 10), t);
if stab.current_target() == Some(42) {
stable_count += 1;
}
}
assert!(stable_count >= 99, "Stable count: {}", stable_count);
}
#[test]
fn crossing_detection_latency() {
let mut stab = HoverStabilizer::new(HoverStabilizerConfig {
hysteresis_cells: 1,
detection_threshold: 1.5,
drift_allowance: 0.3,
..Default::default()
});
let t = now();
stab.update(Some(42), (10, 10), t);
let mut frames = 0;
for i in 1..=10 {
stab.update(Some(99), (10, 10 + i * 2), t);
frames += 1;
if stab.current_target() == Some(99) {
break;
}
}
assert!(frames <= 3, "Switch took {} frames", frames);
}
#[test]
fn config_getter_and_setter() {
let mut stab = stabilizer();
assert_eq!(stab.config().detection_threshold, 2.0);
stab.set_config(HoverStabilizerConfig {
detection_threshold: 5.0,
..Default::default()
});
assert_eq!(stab.config().detection_threshold, 5.0);
}
#[test]
fn default_config_values() {
let config = HoverStabilizerConfig::default();
assert_eq!(config.drift_allowance, 0.5);
assert_eq!(config.detection_threshold, 2.0);
assert_eq!(config.hysteresis_cells, 1);
assert_eq!(config.decay_rate, 0.1);
assert_eq!(config.hold_timeout, Duration::from_millis(500));
}
#[test]
fn debug_format() {
let stab = stabilizer();
let dbg = format!("{:?}", stab);
assert!(dbg.contains("HoverStabilizer"));
}
#[test]
fn switch_count_preserved_after_reset() {
let mut stab = stabilizer();
let t = now();
stab.update(Some(42), (10, 10), t);
assert_eq!(stab.switch_count(), 1);
stab.reset();
assert_eq!(stab.switch_count(), 1);
assert!(stab.current_target().is_none());
}
#[test]
fn none_hit_when_no_current_target() {
let mut stab = stabilizer();
let t = now();
let target = stab.update(None, (10, 10), t);
assert_eq!(target, None);
assert_eq!(stab.switch_count(), 0);
}
#[test]
fn config_clone() {
let config = HoverStabilizerConfig::default();
let cloned = config.clone();
assert_eq!(cloned.drift_allowance, config.drift_allowance);
assert_eq!(cloned.detection_threshold, config.detection_threshold);
assert_eq!(cloned.hysteresis_cells, config.hysteresis_cells);
}
#[test]
fn config_debug_format() {
let config = HoverStabilizerConfig::default();
let dbg = format!("{:?}", config);
assert!(dbg.contains("HoverStabilizerConfig"));
assert!(dbg.contains("drift_allowance"));
}
#[test]
fn config_zero_hysteresis() {
let config = HoverStabilizerConfig {
hysteresis_cells: 0,
..Default::default()
};
assert_eq!(config.hysteresis_cells, 0);
}
#[test]
fn config_zero_hold_timeout() {
let config = HoverStabilizerConfig {
hold_timeout: Duration::ZERO,
..Default::default()
};
assert_eq!(config.hold_timeout, Duration::ZERO);
}
#[test]
fn new_with_custom_config() {
let config = HoverStabilizerConfig {
drift_allowance: 1.0,
detection_threshold: 10.0,
hysteresis_cells: 5,
decay_rate: 0.5,
hold_timeout: Duration::from_secs(2),
};
let stab = HoverStabilizer::new(config);
assert!(stab.current_target().is_none());
assert_eq!(stab.switch_count(), 0);
assert_eq!(stab.config().drift_allowance, 1.0);
assert_eq!(stab.config().detection_threshold, 10.0);
assert_eq!(stab.config().hysteresis_cells, 5);
}
#[test]
fn reset_then_adopt_new_target() {
let mut stab = stabilizer();
let t = now();
stab.update(Some(42), (10, 10), t);
assert_eq!(stab.current_target(), Some(42));
stab.reset();
assert!(stab.current_target().is_none());
let target = stab.update(Some(99), (20, 20), t);
assert_eq!(target, Some(99));
assert_eq!(stab.switch_count(), 2); }
#[test]
fn multiple_resets_are_idempotent() {
let mut stab = stabilizer();
let t = now();
stab.update(Some(42), (10, 10), t);
stab.reset();
stab.reset();
stab.reset();
assert!(stab.current_target().is_none());
assert_eq!(stab.switch_count(), 1);
}
#[test]
fn exactly_at_timeout_does_not_reset() {
let mut stab = HoverStabilizer::new(HoverStabilizerConfig {
hold_timeout: Duration::from_millis(100),
..Default::default()
});
let t = now();
stab.update(Some(42), (10, 10), t);
let at_boundary = t + Duration::from_millis(100);
let target = stab.update(Some(42), (10, 10), at_boundary);
assert_eq!(target, Some(42));
}
#[test]
fn just_past_timeout_resets() {
let mut stab = HoverStabilizer::new(HoverStabilizerConfig {
hold_timeout: Duration::from_millis(100),
..Default::default()
});
let t = now();
stab.update(Some(42), (10, 10), t);
let past = t + Duration::from_millis(101);
let target = stab.update(Some(99), (20, 20), past);
assert_eq!(target, Some(99));
}
#[test]
fn timeout_then_none_hit() {
let mut stab = HoverStabilizer::new(HoverStabilizerConfig {
hold_timeout: Duration::from_millis(50),
..Default::default()
});
let t = now();
stab.update(Some(42), (10, 10), t);
let later = t + Duration::from_millis(100);
let target = stab.update(None, (10, 10), later);
assert_eq!(target, None);
}
#[test]
fn position_at_origin() {
let mut stab = stabilizer();
let t = now();
let target = stab.update(Some(1), (0, 0), t);
assert_eq!(target, Some(1));
}
#[test]
fn position_at_u16_max() {
let mut stab = stabilizer();
let t = now();
let target = stab.update(Some(1), (u16::MAX, u16::MAX), t);
assert_eq!(target, Some(1));
assert_eq!(stab.current_target(), Some(1));
}
#[test]
fn same_position_different_targets_no_switch() {
let mut stab = stabilizer();
let t = now();
stab.update(Some(42), (10, 10), t);
for _ in 0..20 {
stab.update(Some(99), (10, 10), t);
}
assert_eq!(stab.current_target(), Some(42));
}
#[test]
fn candidate_resets_on_new_third_target() {
let mut stab = HoverStabilizer::new(HoverStabilizerConfig {
hysteresis_cells: 0,
detection_threshold: 5.0, ..Default::default()
});
let t = now();
stab.update(Some(42), (10, 10), t);
stab.update(Some(99), (15, 10), t);
stab.update(Some(99), (20, 10), t);
stab.update(Some(77), (25, 10), t);
stab.update(Some(77), (30, 10), t);
stab.update(Some(77), (35, 10), t);
stab.update(Some(77), (40, 10), t);
stab.update(Some(77), (45, 10), t);
assert!(
stab.current_target() == Some(77) || stab.current_target() == Some(42),
"target should be 77 or still 42, got {:?}",
stab.current_target()
);
}
#[test]
fn very_high_threshold_prevents_switching() {
let mut stab = HoverStabilizer::new(HoverStabilizerConfig {
detection_threshold: 100_000.0,
hysteresis_cells: 0,
..Default::default()
});
let t = now();
stab.update(Some(42), (10, 10), t);
for i in 1..=20 {
stab.update(Some(99), (10, 10 + i * 10), t);
}
assert_eq!(stab.current_target(), Some(42));
}
#[test]
fn very_low_threshold_allows_quick_switch() {
let mut stab = HoverStabilizer::new(HoverStabilizerConfig {
detection_threshold: 0.01,
hysteresis_cells: 0,
drift_allowance: 0.0,
..Default::default()
});
let t = now();
stab.update(Some(42), (10, 10), t);
stab.update(Some(99), (15, 10), t);
assert_eq!(stab.current_target(), Some(99));
assert_eq!(stab.switch_count(), 2);
}
#[test]
fn decay_rate_zero_no_decay() {
let mut stab = HoverStabilizer::new(HoverStabilizerConfig {
decay_rate: 0.0,
detection_threshold: 100.0, hysteresis_cells: 0,
..Default::default()
});
let t = now();
stab.update(Some(42), (10, 10), t);
stab.update(Some(99), (20, 10), t);
stab.update(Some(99), (30, 10), t);
stab.update(Some(42), (10, 10), t);
stab.update(Some(42), (10, 10), t);
assert_eq!(stab.current_target(), Some(42));
}
#[test]
fn decay_rate_one_instant_decay() {
let mut stab = HoverStabilizer::new(HoverStabilizerConfig {
decay_rate: 1.0,
detection_threshold: 2.0,
hysteresis_cells: 0,
..Default::default()
});
let t = now();
stab.update(Some(42), (10, 10), t);
stab.update(Some(99), (20, 10), t);
stab.update(Some(42), (10, 10), t);
for i in 1..=5 {
stab.update(Some(99), (10, 10 + i * 5), t);
}
let target = stab.current_target();
assert!(target == Some(42) || target == Some(99));
}
#[test]
fn zero_drift_allowance_fast_accumulation() {
let mut stab = HoverStabilizer::new(HoverStabilizerConfig {
drift_allowance: 0.0,
detection_threshold: 1.0,
hysteresis_cells: 0,
..Default::default()
});
let t = now();
stab.update(Some(42), (10, 10), t);
stab.update(Some(99), (15, 10), t);
assert_eq!(stab.current_target(), Some(99));
}
#[test]
fn target_id_zero() {
let mut stab = stabilizer();
let t = now();
let target = stab.update(Some(0), (10, 10), t);
assert_eq!(target, Some(0));
assert_eq!(stab.current_target(), Some(0));
}
#[test]
fn target_id_max_u64() {
let mut stab = stabilizer();
let t = now();
let target = stab.update(Some(u64::MAX), (10, 10), t);
assert_eq!(target, Some(u64::MAX));
}
#[test]
fn switch_count_increments_across_multiple_switches() {
let mut stab = HoverStabilizer::new(HoverStabilizerConfig {
detection_threshold: 0.01,
hysteresis_cells: 0,
drift_allowance: 0.0,
..Default::default()
});
let t = now();
stab.update(Some(1), (10, 10), t);
assert_eq!(stab.switch_count(), 1);
stab.update(Some(2), (30, 10), t);
assert_eq!(stab.current_target(), Some(2));
assert_eq!(stab.switch_count(), 2);
stab.update(Some(3), (60, 10), t);
assert_eq!(stab.current_target(), Some(3));
assert_eq!(stab.switch_count(), 3);
}
#[test]
fn set_config_preserves_state() {
let mut stab = stabilizer();
let t = now();
stab.update(Some(42), (10, 10), t);
assert_eq!(stab.current_target(), Some(42));
stab.set_config(HoverStabilizerConfig {
detection_threshold: 100.0,
..Default::default()
});
assert_eq!(stab.current_target(), Some(42));
assert_eq!(stab.config().detection_threshold, 100.0);
}
#[test]
fn large_hysteresis_requires_big_movement() {
let mut stab = HoverStabilizer::new(HoverStabilizerConfig {
hysteresis_cells: 100,
detection_threshold: 0.01,
drift_allowance: 0.0,
..Default::default()
});
let t = now();
stab.update(Some(42), (100, 100), t);
stab.update(Some(99), (150, 100), t);
stab.update(Some(99), (150, 100), t);
assert_eq!(stab.current_target(), Some(42));
for i in 1..=5 {
stab.update(Some(99), (100 + (i * 50), 100), t);
}
assert_eq!(stab.current_target(), Some(99));
}
}