1#![forbid(unsafe_code)]
2
3use web_time::{Duration, Instant};
39
40#[derive(Debug, Clone)]
46pub struct HoverStabilizerConfig {
47 pub drift_allowance: f32,
50
51 pub detection_threshold: f32,
54
55 pub hysteresis_cells: u16,
59
60 pub decay_rate: f32,
64
65 pub hold_timeout: Duration,
69}
70
71impl Default for HoverStabilizerConfig {
72 fn default() -> Self {
73 Self {
74 drift_allowance: 0.5,
75 detection_threshold: 2.0,
76 hysteresis_cells: 1,
77 decay_rate: 0.1,
78 hold_timeout: Duration::from_millis(500),
79 }
80 }
81}
82
83#[derive(Debug, Clone)]
89struct CandidateTarget {
90 target_id: u64,
92 cusum_score: f32,
94 last_pos: (u16, u16),
96}
97
98#[derive(Debug)]
107pub struct HoverStabilizer {
108 config: HoverStabilizerConfig,
109
110 current_target: Option<u64>,
112
113 current_target_pos: Option<(u16, u16)>,
115
116 last_update: Option<Instant>,
118
119 candidate: Option<CandidateTarget>,
121
122 switches: u64,
124}
125
126impl HoverStabilizer {
127 #[must_use]
129 pub fn new(config: HoverStabilizerConfig) -> Self {
130 Self {
131 config,
132 current_target: None,
133 current_target_pos: None,
134 last_update: None,
135 candidate: None,
136 switches: 0,
137 }
138 }
139
140 pub fn update(
153 &mut self,
154 hit_target: Option<u64>,
155 pos: (u16, u16),
156 now: Instant,
157 ) -> Option<u64> {
158 if let Some(last) = self.last_update
160 && now.duration_since(last) > self.config.hold_timeout
161 {
162 self.reset();
163 }
164 self.last_update = Some(now);
165
166 if self.current_target.is_none() {
168 if hit_target.is_some() {
169 self.current_target = hit_target;
170 self.current_target_pos = Some(pos);
171 self.switches += 1;
172 }
173 return self.current_target;
174 }
175
176 let current = self
177 .current_target
178 .expect("current_target guaranteed by is_none early return");
179
180 if hit_target == Some(current) {
182 self.decay_candidate();
183 self.current_target_pos = Some(pos);
184 return self.current_target;
185 }
186
187 let candidate_id = hit_target.unwrap_or(u64::MAX); let distance = self.compute_distance_signal(pos);
192
193 self.update_candidate(candidate_id, distance, pos);
194
195 if let Some(ref cand) = self.candidate
197 && cand.cusum_score >= self.config.detection_threshold
198 && self.past_hysteresis_band(pos)
199 {
200 self.current_target = if candidate_id == u64::MAX {
202 None
203 } else {
204 Some(candidate_id)
205 };
206 self.current_target_pos = Some(pos);
207 self.candidate = None;
208 self.switches += 1;
209 }
210
211 self.current_target
212 }
213
214 #[inline]
216 #[must_use]
217 pub fn current_target(&self) -> Option<u64> {
218 self.current_target
219 }
220
221 pub fn reset(&mut self) {
223 self.current_target = None;
224 self.current_target_pos = None;
225 self.last_update = None;
226 self.candidate = None;
227 }
228
229 #[inline]
231 #[must_use]
232 pub fn switch_count(&self) -> u64 {
233 self.switches
234 }
235
236 #[inline]
238 #[must_use]
239 pub fn config(&self) -> &HoverStabilizerConfig {
240 &self.config
241 }
242
243 pub fn set_config(&mut self, config: HoverStabilizerConfig) {
245 self.config = config;
246 }
247
248 fn compute_distance_signal(&self, pos: (u16, u16)) -> f32 {
257 let Some(target_pos) = self.current_target_pos else {
258 return 1.0; };
260
261 let dx = (pos.0 as i32 - target_pos.0 as i32).abs();
263 let dy = (pos.1 as i32 - target_pos.1 as i32).abs();
264 let manhattan = (dx + dy) as f32;
265
266 let hysteresis = self.config.hysteresis_cells.max(1) as f32;
268
269 (manhattan - hysteresis) / hysteresis
272 }
273
274 fn update_candidate(&mut self, candidate_id: u64, distance_signal: f32, pos: (u16, u16)) {
276 let k = self.config.drift_allowance;
277
278 match &mut self.candidate {
279 Some(cand) if cand.target_id == candidate_id => {
280 cand.cusum_score = (cand.cusum_score + distance_signal - k).max(0.0);
283 cand.last_pos = pos;
284 }
285 _ => {
286 let initial_score = (distance_signal - k).max(0.0);
288 self.candidate = Some(CandidateTarget {
289 target_id: candidate_id,
290 cusum_score: initial_score,
291 last_pos: pos,
292 });
293 }
294 }
295 }
296
297 fn decay_candidate(&mut self) {
299 if let Some(ref mut cand) = self.candidate {
300 cand.cusum_score *= 1.0 - self.config.decay_rate;
301 if cand.cusum_score < 0.01 {
302 self.candidate = None;
303 }
304 }
305 }
306
307 fn past_hysteresis_band(&self, pos: (u16, u16)) -> bool {
309 let Some(target_pos) = self.current_target_pos else {
310 return true; };
312
313 let dx = (pos.0 as i32 - target_pos.0 as i32).unsigned_abs();
314 let dy = (pos.1 as i32 - target_pos.1 as i32).unsigned_abs();
315 let manhattan = dx + dy;
316
317 manhattan > u32::from(self.config.hysteresis_cells)
318 }
319}
320
321#[cfg(test)]
326mod tests {
327 use super::*;
328
329 fn now() -> Instant {
330 Instant::now()
331 }
332
333 fn stabilizer() -> HoverStabilizer {
334 HoverStabilizer::new(HoverStabilizerConfig::default())
335 }
336
337 #[test]
340 fn initial_state_is_none() {
341 let stab = stabilizer();
342 assert!(stab.current_target().is_none());
343 assert_eq!(stab.switch_count(), 0);
344 }
345
346 #[test]
347 fn first_hit_adopted_immediately() {
348 let mut stab = stabilizer();
349 let t = now();
350
351 let target = stab.update(Some(42), (10, 10), t);
352 assert_eq!(target, Some(42));
353 assert_eq!(stab.current_target(), Some(42));
354 assert_eq!(stab.switch_count(), 1);
355 }
356
357 #[test]
358 fn same_target_stays_stable() {
359 let mut stab = stabilizer();
360 let t = now();
361
362 stab.update(Some(42), (10, 10), t);
363 stab.update(Some(42), (10, 11), t);
364 stab.update(Some(42), (11, 10), t);
365
366 assert_eq!(stab.current_target(), Some(42));
367 assert_eq!(stab.switch_count(), 1); }
369
370 #[test]
371 fn jitter_does_not_switch() {
372 let mut stab = stabilizer();
373 let t = now();
374
375 stab.update(Some(42), (10, 10), t);
377
378 for i in 0..10 {
380 let target = if i % 2 == 0 { Some(99) } else { Some(42) };
381 stab.update(target, (10, 10 + (i % 2)), t);
382 }
383
384 assert_eq!(stab.current_target(), Some(42));
386 }
387
388 #[test]
389 fn sustained_crossing_triggers_switch() {
390 let mut stab = stabilizer();
391 let t = now();
392
393 stab.update(Some(42), (10, 10), t);
395
396 for i in 1..=5 {
399 stab.update(Some(99), (10, 10 + i * 2), t);
400 }
401
402 assert_eq!(stab.current_target(), Some(99));
404 assert!(stab.switch_count() >= 2);
405 }
406
407 #[test]
408 fn reset_clears_all_state() {
409 let mut stab = stabilizer();
410 let t = now();
411
412 stab.update(Some(42), (10, 10), t);
413 stab.reset();
414
415 assert!(stab.current_target().is_none());
416 }
417
418 #[test]
421 fn cusum_accumulates_on_consistent_signal() {
422 let mut stab = HoverStabilizer::new(HoverStabilizerConfig {
423 detection_threshold: 3.0,
424 hysteresis_cells: 0, ..Default::default()
426 });
427 let t = now();
428
429 stab.update(Some(42), (10, 10), t);
431
432 stab.update(Some(99), (15, 10), t);
434 stab.update(Some(99), (20, 10), t);
435 stab.update(Some(99), (25, 10), t);
436
437 assert_eq!(stab.current_target(), Some(99));
439 }
440
441 #[test]
442 fn cusum_resets_on_return() {
443 let mut stab = stabilizer();
444 let t = now();
445
446 stab.update(Some(42), (10, 10), t);
447
448 stab.update(Some(99), (12, 10), t);
450
451 stab.update(Some(42), (10, 10), t);
453 stab.update(Some(42), (10, 10), t);
454 stab.update(Some(42), (10, 10), t);
455
456 assert_eq!(stab.current_target(), Some(42));
458 }
459
460 #[test]
463 fn hysteresis_prevents_boundary_oscillation() {
464 let mut stab = HoverStabilizer::new(HoverStabilizerConfig {
465 hysteresis_cells: 2,
466 detection_threshold: 0.5, ..Default::default()
468 });
469 let t = now();
470
471 stab.update(Some(42), (10, 10), t);
472
473 stab.update(Some(99), (11, 10), t);
475 assert_eq!(stab.current_target(), Some(42));
476
477 stab.update(Some(99), (13, 10), t);
479 stab.update(Some(99), (14, 10), t);
480 stab.update(Some(99), (15, 10), t);
481
482 assert_eq!(stab.current_target(), Some(99));
484 }
485
486 #[test]
489 fn timeout_resets_target() {
490 let mut stab = HoverStabilizer::new(HoverStabilizerConfig {
491 hold_timeout: Duration::from_millis(100),
492 ..Default::default()
493 });
494 let t = now();
495
496 stab.update(Some(42), (10, 10), t);
497 assert_eq!(stab.current_target(), Some(42));
498
499 let later = t + Duration::from_millis(200);
501 stab.update(Some(99), (20, 20), later);
502
503 assert_eq!(stab.current_target(), Some(99));
505 }
506
507 #[test]
510 fn transition_to_none_with_evidence() {
511 let mut stab = HoverStabilizer::new(HoverStabilizerConfig {
512 hysteresis_cells: 0,
513 detection_threshold: 1.0,
514 ..Default::default()
515 });
516 let t = now();
517
518 stab.update(Some(42), (10, 10), t);
519
520 for i in 1..=5 {
522 stab.update(None, (10 + i * 3, 10), t);
523 }
524
525 assert!(stab.current_target().is_none());
527 }
528
529 #[test]
532 fn jitter_stability_rate() {
533 let mut stab = stabilizer();
534 let t = now();
535
536 stab.update(Some(42), (10, 10), t);
537
538 let mut stable_count = 0;
540 for i in 0..100 {
541 let target = if i % 2 == 0 { Some(99) } else { Some(42) };
542 stab.update(target, (10, 10), t);
543 if stab.current_target() == Some(42) {
544 stable_count += 1;
545 }
546 }
547
548 assert!(stable_count >= 99, "Stable count: {}", stable_count);
550 }
551
552 #[test]
553 fn crossing_detection_latency() {
554 let mut stab = HoverStabilizer::new(HoverStabilizerConfig {
555 hysteresis_cells: 1,
556 detection_threshold: 1.5,
557 drift_allowance: 0.3,
558 ..Default::default()
559 });
560 let t = now();
561
562 stab.update(Some(42), (10, 10), t);
563
564 let mut frames = 0;
566 for i in 1..=10 {
567 stab.update(Some(99), (10, 10 + i * 2), t);
568 frames += 1;
569 if stab.current_target() == Some(99) {
570 break;
571 }
572 }
573
574 assert!(frames <= 3, "Switch took {} frames", frames);
576 }
577
578 #[test]
581 fn config_getter_and_setter() {
582 let mut stab = stabilizer();
583
584 assert_eq!(stab.config().detection_threshold, 2.0);
585
586 stab.set_config(HoverStabilizerConfig {
587 detection_threshold: 5.0,
588 ..Default::default()
589 });
590
591 assert_eq!(stab.config().detection_threshold, 5.0);
592 }
593
594 #[test]
595 fn default_config_values() {
596 let config = HoverStabilizerConfig::default();
597 assert_eq!(config.drift_allowance, 0.5);
598 assert_eq!(config.detection_threshold, 2.0);
599 assert_eq!(config.hysteresis_cells, 1);
600 assert_eq!(config.decay_rate, 0.1);
601 assert_eq!(config.hold_timeout, Duration::from_millis(500));
602 }
603
604 #[test]
607 fn debug_format() {
608 let stab = stabilizer();
609 let dbg = format!("{:?}", stab);
610 assert!(dbg.contains("HoverStabilizer"));
611 }
612
613 #[test]
614 fn switch_count_preserved_after_reset() {
615 let mut stab = stabilizer();
616 let t = now();
617
618 stab.update(Some(42), (10, 10), t);
619 assert_eq!(stab.switch_count(), 1);
620
621 stab.reset();
622 assert_eq!(stab.switch_count(), 1);
624 assert!(stab.current_target().is_none());
625 }
626
627 #[test]
628 fn none_hit_when_no_current_target() {
629 let mut stab = stabilizer();
630 let t = now();
631
632 let target = stab.update(None, (10, 10), t);
634 assert_eq!(target, None);
635 assert_eq!(stab.switch_count(), 0);
636 }
637
638 #[test]
639 fn config_clone() {
640 let config = HoverStabilizerConfig::default();
641 let cloned = config.clone();
642 assert_eq!(cloned.drift_allowance, config.drift_allowance);
643 assert_eq!(cloned.detection_threshold, config.detection_threshold);
644 assert_eq!(cloned.hysteresis_cells, config.hysteresis_cells);
645 }
646
647 #[test]
650 fn config_debug_format() {
651 let config = HoverStabilizerConfig::default();
652 let dbg = format!("{:?}", config);
653 assert!(dbg.contains("HoverStabilizerConfig"));
654 assert!(dbg.contains("drift_allowance"));
655 }
656
657 #[test]
658 fn config_zero_hysteresis() {
659 let config = HoverStabilizerConfig {
660 hysteresis_cells: 0,
661 ..Default::default()
662 };
663 assert_eq!(config.hysteresis_cells, 0);
664 }
665
666 #[test]
667 fn config_zero_hold_timeout() {
668 let config = HoverStabilizerConfig {
669 hold_timeout: Duration::ZERO,
670 ..Default::default()
671 };
672 assert_eq!(config.hold_timeout, Duration::ZERO);
673 }
674
675 #[test]
678 fn new_with_custom_config() {
679 let config = HoverStabilizerConfig {
680 drift_allowance: 1.0,
681 detection_threshold: 10.0,
682 hysteresis_cells: 5,
683 decay_rate: 0.5,
684 hold_timeout: Duration::from_secs(2),
685 };
686 let stab = HoverStabilizer::new(config);
687 assert!(stab.current_target().is_none());
688 assert_eq!(stab.switch_count(), 0);
689 assert_eq!(stab.config().drift_allowance, 1.0);
690 assert_eq!(stab.config().detection_threshold, 10.0);
691 assert_eq!(stab.config().hysteresis_cells, 5);
692 }
693
694 #[test]
697 fn reset_then_adopt_new_target() {
698 let mut stab = stabilizer();
699 let t = now();
700
701 stab.update(Some(42), (10, 10), t);
702 assert_eq!(stab.current_target(), Some(42));
703
704 stab.reset();
705 assert!(stab.current_target().is_none());
706
707 let target = stab.update(Some(99), (20, 20), t);
709 assert_eq!(target, Some(99));
710 assert_eq!(stab.switch_count(), 2); }
712
713 #[test]
714 fn multiple_resets_are_idempotent() {
715 let mut stab = stabilizer();
716 let t = now();
717
718 stab.update(Some(42), (10, 10), t);
719 stab.reset();
720 stab.reset();
721 stab.reset();
722
723 assert!(stab.current_target().is_none());
724 assert_eq!(stab.switch_count(), 1);
726 }
727
728 #[test]
731 fn exactly_at_timeout_does_not_reset() {
732 let mut stab = HoverStabilizer::new(HoverStabilizerConfig {
733 hold_timeout: Duration::from_millis(100),
734 ..Default::default()
735 });
736 let t = now();
737
738 stab.update(Some(42), (10, 10), t);
739
740 let at_boundary = t + Duration::from_millis(100);
742 let target = stab.update(Some(42), (10, 10), at_boundary);
743 assert_eq!(target, Some(42));
745 }
746
747 #[test]
748 fn just_past_timeout_resets() {
749 let mut stab = HoverStabilizer::new(HoverStabilizerConfig {
750 hold_timeout: Duration::from_millis(100),
751 ..Default::default()
752 });
753 let t = now();
754
755 stab.update(Some(42), (10, 10), t);
756
757 let past = t + Duration::from_millis(101);
759 let target = stab.update(Some(99), (20, 20), past);
760 assert_eq!(target, Some(99));
762 }
763
764 #[test]
765 fn timeout_then_none_hit() {
766 let mut stab = HoverStabilizer::new(HoverStabilizerConfig {
767 hold_timeout: Duration::from_millis(50),
768 ..Default::default()
769 });
770 let t = now();
771
772 stab.update(Some(42), (10, 10), t);
773
774 let later = t + Duration::from_millis(100);
775 let target = stab.update(None, (10, 10), later);
776 assert_eq!(target, None);
778 }
779
780 #[test]
783 fn position_at_origin() {
784 let mut stab = stabilizer();
785 let t = now();
786
787 let target = stab.update(Some(1), (0, 0), t);
788 assert_eq!(target, Some(1));
789 }
790
791 #[test]
792 fn position_at_u16_max() {
793 let mut stab = stabilizer();
794 let t = now();
795
796 let target = stab.update(Some(1), (u16::MAX, u16::MAX), t);
797 assert_eq!(target, Some(1));
798 assert_eq!(stab.current_target(), Some(1));
799 }
800
801 #[test]
802 fn same_position_different_targets_no_switch() {
803 let mut stab = stabilizer();
804 let t = now();
805
806 stab.update(Some(42), (10, 10), t);
807
808 for _ in 0..20 {
811 stab.update(Some(99), (10, 10), t);
812 }
813
814 assert_eq!(stab.current_target(), Some(42));
816 }
817
818 #[test]
821 fn candidate_resets_on_new_third_target() {
822 let mut stab = HoverStabilizer::new(HoverStabilizerConfig {
823 hysteresis_cells: 0,
824 detection_threshold: 5.0, ..Default::default()
826 });
827 let t = now();
828
829 stab.update(Some(42), (10, 10), t);
830
831 stab.update(Some(99), (15, 10), t);
833 stab.update(Some(99), (20, 10), t);
834
835 stab.update(Some(77), (25, 10), t);
837 stab.update(Some(77), (30, 10), t);
838 stab.update(Some(77), (35, 10), t);
839 stab.update(Some(77), (40, 10), t);
840 stab.update(Some(77), (45, 10), t);
841
842 assert!(
844 stab.current_target() == Some(77) || stab.current_target() == Some(42),
845 "target should be 77 or still 42, got {:?}",
846 stab.current_target()
847 );
848 }
849
850 #[test]
853 fn very_high_threshold_prevents_switching() {
854 let mut stab = HoverStabilizer::new(HoverStabilizerConfig {
855 detection_threshold: 100_000.0,
856 hysteresis_cells: 0,
857 ..Default::default()
858 });
859 let t = now();
860
861 stab.update(Some(42), (10, 10), t);
862
863 for i in 1..=20 {
865 stab.update(Some(99), (10, 10 + i * 10), t);
866 }
867
868 assert_eq!(stab.current_target(), Some(42));
870 }
871
872 #[test]
873 fn very_low_threshold_allows_quick_switch() {
874 let mut stab = HoverStabilizer::new(HoverStabilizerConfig {
875 detection_threshold: 0.01,
876 hysteresis_cells: 0,
877 drift_allowance: 0.0,
878 ..Default::default()
879 });
880 let t = now();
881
882 stab.update(Some(42), (10, 10), t);
883
884 stab.update(Some(99), (15, 10), t);
886
887 assert_eq!(stab.current_target(), Some(99));
888 assert_eq!(stab.switch_count(), 2);
889 }
890
891 #[test]
894 fn decay_rate_zero_no_decay() {
895 let mut stab = HoverStabilizer::new(HoverStabilizerConfig {
896 decay_rate: 0.0,
897 detection_threshold: 100.0, hysteresis_cells: 0,
899 ..Default::default()
900 });
901 let t = now();
902
903 stab.update(Some(42), (10, 10), t);
904
905 stab.update(Some(99), (20, 10), t);
907 stab.update(Some(99), (30, 10), t);
908
909 stab.update(Some(42), (10, 10), t);
911 stab.update(Some(42), (10, 10), t);
912
913 assert_eq!(stab.current_target(), Some(42));
917 }
918
919 #[test]
920 fn decay_rate_one_instant_decay() {
921 let mut stab = HoverStabilizer::new(HoverStabilizerConfig {
922 decay_rate: 1.0,
923 detection_threshold: 2.0,
924 hysteresis_cells: 0,
925 ..Default::default()
926 });
927 let t = now();
928
929 stab.update(Some(42), (10, 10), t);
930
931 stab.update(Some(99), (20, 10), t);
933 stab.update(Some(42), (10, 10), t); for i in 1..=5 {
937 stab.update(Some(99), (10, 10 + i * 5), t);
938 }
939
940 let target = stab.current_target();
943 assert!(target == Some(42) || target == Some(99));
944 }
945
946 #[test]
949 fn zero_drift_allowance_fast_accumulation() {
950 let mut stab = HoverStabilizer::new(HoverStabilizerConfig {
951 drift_allowance: 0.0,
952 detection_threshold: 1.0,
953 hysteresis_cells: 0,
954 ..Default::default()
955 });
956 let t = now();
957
958 stab.update(Some(42), (10, 10), t);
959
960 stab.update(Some(99), (15, 10), t);
962
963 assert_eq!(stab.current_target(), Some(99));
965 }
966
967 #[test]
970 fn target_id_zero() {
971 let mut stab = stabilizer();
972 let t = now();
973
974 let target = stab.update(Some(0), (10, 10), t);
975 assert_eq!(target, Some(0));
976 assert_eq!(stab.current_target(), Some(0));
977 }
978
979 #[test]
980 fn target_id_max_u64() {
981 let mut stab = stabilizer();
982 let t = now();
983
984 let target = stab.update(Some(u64::MAX), (10, 10), t);
987 assert_eq!(target, Some(u64::MAX));
988 }
989
990 #[test]
993 fn switch_count_increments_across_multiple_switches() {
994 let mut stab = HoverStabilizer::new(HoverStabilizerConfig {
995 detection_threshold: 0.01,
996 hysteresis_cells: 0,
997 drift_allowance: 0.0,
998 ..Default::default()
999 });
1000 let t = now();
1001
1002 stab.update(Some(1), (10, 10), t);
1003 assert_eq!(stab.switch_count(), 1);
1004
1005 stab.update(Some(2), (30, 10), t);
1006 assert_eq!(stab.current_target(), Some(2));
1007 assert_eq!(stab.switch_count(), 2);
1008
1009 stab.update(Some(3), (60, 10), t);
1010 assert_eq!(stab.current_target(), Some(3));
1011 assert_eq!(stab.switch_count(), 3);
1012 }
1013
1014 #[test]
1017 fn set_config_preserves_state() {
1018 let mut stab = stabilizer();
1019 let t = now();
1020
1021 stab.update(Some(42), (10, 10), t);
1022 assert_eq!(stab.current_target(), Some(42));
1023
1024 stab.set_config(HoverStabilizerConfig {
1026 detection_threshold: 100.0,
1027 ..Default::default()
1028 });
1029
1030 assert_eq!(stab.current_target(), Some(42));
1032 assert_eq!(stab.config().detection_threshold, 100.0);
1033 }
1034
1035 #[test]
1038 fn large_hysteresis_requires_big_movement() {
1039 let mut stab = HoverStabilizer::new(HoverStabilizerConfig {
1040 hysteresis_cells: 100,
1041 detection_threshold: 0.01,
1042 drift_allowance: 0.0,
1043 ..Default::default()
1044 });
1045 let t = now();
1046
1047 stab.update(Some(42), (100, 100), t);
1048
1049 stab.update(Some(99), (150, 100), t);
1051 stab.update(Some(99), (150, 100), t);
1052 assert_eq!(stab.current_target(), Some(42)); for i in 1..=5 {
1056 stab.update(Some(99), (100 + (i * 50), 100), t);
1057 }
1058 assert_eq!(stab.current_target(), Some(99));
1060 }
1061}