1#![forbid(unsafe_code)]
61
62use std::collections::VecDeque;
63use web_time::{Duration, Instant};
64
65const DEFAULT_MAX_INPUT_LATENCY_MS: u64 = 50;
67
68const DEFAULT_DOMINANCE_THRESHOLD: u32 = 3;
70
71const DEFAULT_FAIRNESS_THRESHOLD: f64 = 0.8;
73
74const FAIRNESS_WINDOW_SIZE: usize = 16;
76const FAIRNESS_THRESHOLD_EPSILON: f64 = 1e-12;
78
79#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81pub enum EventType {
82 Input,
84 Resize,
86 Tick,
88}
89
90pub type FairnessEventType = EventType;
92
93#[derive(Debug, Clone)]
95pub struct FairnessConfig {
96 pub input_priority_threshold: Duration,
98 pub enabled: bool,
100 pub dominance_threshold: u32,
102 pub fairness_threshold: f64,
104}
105
106impl Default for FairnessConfig {
107 fn default() -> Self {
108 Self {
109 input_priority_threshold: Duration::from_millis(DEFAULT_MAX_INPUT_LATENCY_MS),
110 enabled: true, dominance_threshold: DEFAULT_DOMINANCE_THRESHOLD,
112 fairness_threshold: DEFAULT_FAIRNESS_THRESHOLD,
113 }
114 }
115}
116
117impl FairnessConfig {
118 pub fn disabled() -> Self {
120 Self {
121 enabled: false,
122 ..Default::default()
123 }
124 }
125
126 #[must_use]
128 pub fn with_max_latency(mut self, latency: Duration) -> Self {
129 self.input_priority_threshold = latency;
130 self
131 }
132
133 #[must_use]
135 pub fn with_dominance_threshold(mut self, threshold: u32) -> Self {
136 self.dominance_threshold = threshold;
137 self
138 }
139}
140
141#[derive(Debug, Clone, Copy, PartialEq, Eq)]
143pub enum InterventionReason {
144 None,
146 InputLatency,
148 ResizeDominance,
150 FairnessIndex,
152}
153
154impl InterventionReason {
155 pub fn requires_intervention(&self) -> bool {
157 !matches!(self, InterventionReason::None)
158 }
159
160 #[must_use]
162 pub const fn as_str(self) -> &'static str {
163 match self {
164 Self::None => "none",
165 Self::InputLatency => "input_latency",
166 Self::ResizeDominance => "resize_dominance",
167 Self::FairnessIndex => "fairness_index",
168 }
169 }
170}
171
172#[derive(Debug, Clone)]
174pub struct FairnessDecision {
175 pub should_process: bool,
177 pub pending_input_latency: Option<Duration>,
179 pub reason: InterventionReason,
181 pub yield_to_input: bool,
183 pub jain_index: f64,
185}
186
187impl Default for FairnessDecision {
188 fn default() -> Self {
189 Self {
190 should_process: true,
191 pending_input_latency: None,
192 reason: InterventionReason::None,
193 yield_to_input: false,
194 jain_index: 1.0, }
196 }
197}
198
199#[derive(Debug, Clone)]
201pub struct FairnessLogEntry {
202 pub timestamp: Instant,
204 pub event_type: EventType,
206 pub duration: Duration,
208}
209
210#[derive(Debug, Clone, Default)]
212pub struct FairnessStats {
213 pub events_processed: u64,
215 pub input_events: u64,
217 pub resize_events: u64,
219 pub tick_events: u64,
221 pub total_checks: u64,
223 pub total_interventions: u64,
225 pub max_input_latency: Duration,
227}
228
229#[derive(Debug, Clone, Default)]
231pub struct InterventionCounts {
232 pub input_latency: u64,
234 pub resize_dominance: u64,
236 pub fairness_index: u64,
238}
239
240#[derive(Debug, Clone)]
242struct ProcessingRecord {
243 event_type: EventType,
245 duration: Duration,
247}
248
249#[derive(Debug)]
254pub struct InputFairnessGuard {
255 config: FairnessConfig,
256 stats: FairnessStats,
257 intervention_counts: InterventionCounts,
258
259 pending_input_arrival: Option<Instant>,
261 recent_input_arrival: Option<Instant>,
263
264 resize_dominance_count: u32,
266
267 processing_window: VecDeque<ProcessingRecord>,
269
270 input_time_us: u128,
272 resize_time_us: u128,
273}
274
275impl InputFairnessGuard {
276 pub fn new() -> Self {
278 Self::with_config(FairnessConfig::default())
279 }
280
281 pub fn with_config(config: FairnessConfig) -> Self {
283 Self {
284 config,
285 stats: FairnessStats::default(),
286 intervention_counts: InterventionCounts::default(),
287 pending_input_arrival: None,
288 recent_input_arrival: None,
289 resize_dominance_count: 0,
290 processing_window: VecDeque::with_capacity(FAIRNESS_WINDOW_SIZE),
291 input_time_us: 0,
292 resize_time_us: 0,
293 }
294 }
295
296 pub fn input_arrived(&mut self, now: Instant) {
300 if self.pending_input_arrival.is_none() {
301 self.pending_input_arrival = Some(now);
302 }
303 self.recent_input_arrival = Some(now);
305 }
306
307 pub fn check_fairness(&mut self, now: Instant) -> FairnessDecision {
311 self.stats.total_checks += 1;
312
313 if !self.config.enabled {
315 self.recent_input_arrival = None;
316 return FairnessDecision::default();
317 }
318
319 let jain = self.calculate_jain_index();
321
322 let has_pending_input = self.pending_input_arrival.is_some();
324 let pending_latency = self
325 .pending_input_arrival
326 .or(self.recent_input_arrival)
327 .map(|t| now.checked_duration_since(t).unwrap_or(Duration::ZERO));
328 if has_pending_input
329 && let Some(latency) = pending_latency
330 && latency > self.stats.max_input_latency
331 {
332 self.stats.max_input_latency = latency;
333 }
334
335 let reason = self.determine_intervention_reason(pending_latency, jain, has_pending_input);
337 let yield_to_input = reason.requires_intervention();
338
339 if yield_to_input {
340 self.stats.total_interventions += 1;
341 match reason {
342 InterventionReason::InputLatency => {
343 self.intervention_counts.input_latency += 1;
344 }
345 InterventionReason::ResizeDominance => {
346 self.intervention_counts.resize_dominance += 1;
347 }
348 InterventionReason::FairnessIndex => {
349 self.intervention_counts.fairness_index += 1;
350 }
351 InterventionReason::None => {}
352 }
353 self.resize_dominance_count = 0;
355 }
356
357 let decision = FairnessDecision {
358 should_process: !yield_to_input,
359 pending_input_latency: if has_pending_input {
360 pending_latency
361 } else {
362 None
363 },
364 reason,
365 yield_to_input,
366 jain_index: jain,
367 };
368
369 self.recent_input_arrival = None;
371
372 decision
373 }
374
375 pub fn event_processed(&mut self, event_type: EventType, duration: Duration, _now: Instant) {
377 self.stats.events_processed += 1;
378 match event_type {
379 EventType::Input => self.stats.input_events += 1,
380 EventType::Resize => self.stats.resize_events += 1,
381 EventType::Tick => self.stats.tick_events += 1,
382 }
383
384 if !self.config.enabled {
386 return;
387 }
388
389 let record = ProcessingRecord {
391 event_type,
392 duration,
393 };
394
395 if self.processing_window.len() >= FAIRNESS_WINDOW_SIZE
397 && let Some(old) = self.processing_window.pop_front()
398 {
399 match old.event_type {
400 EventType::Input => {
401 self.input_time_us =
402 self.input_time_us.saturating_sub(old.duration.as_micros());
403 }
404 EventType::Resize => {
405 self.resize_time_us =
406 self.resize_time_us.saturating_sub(old.duration.as_micros());
407 }
408 EventType::Tick => {}
409 }
410 }
411
412 match event_type {
414 EventType::Input => {
415 self.input_time_us = self.input_time_us.saturating_add(duration.as_micros());
416 self.pending_input_arrival = None;
417 self.resize_dominance_count = 0; }
419 EventType::Resize => {
420 self.resize_time_us = self.resize_time_us.saturating_add(duration.as_micros());
421 self.resize_dominance_count = self.resize_dominance_count.saturating_add(1);
422 }
423 EventType::Tick => {}
424 }
425
426 self.processing_window.push_back(record);
427 }
428
429 fn calculate_jain_index(&self) -> f64 {
431 let x = self.input_time_us as f64;
433 let y = self.resize_time_us as f64;
434
435 if x == 0.0 && y == 0.0 {
436 return 1.0; }
438
439 let sum = x + y;
440 let sum_sq = x * x + y * y;
441
442 if sum_sq == 0.0 {
443 return 1.0;
444 }
445
446 (sum * sum) / (2.0 * sum_sq)
447 }
448
449 fn determine_intervention_reason(
451 &self,
452 pending_latency: Option<Duration>,
453 jain: f64,
454 has_pending_input: bool,
455 ) -> InterventionReason {
456 if has_pending_input
458 && let Some(latency) = pending_latency
459 && latency >= self.config.input_priority_threshold
460 {
461 return InterventionReason::InputLatency;
462 }
463
464 if has_pending_input && self.resize_dominance_count >= self.config.dominance_threshold {
466 return InterventionReason::ResizeDominance;
467 }
468
469 if has_pending_input
478 && jain + FAIRNESS_THRESHOLD_EPSILON < self.config.fairness_threshold
479 && self.resize_time_us > self.input_time_us
480 {
481 return InterventionReason::FairnessIndex;
482 }
483
484 InterventionReason::None
485 }
486
487 pub fn stats(&self) -> &FairnessStats {
489 &self.stats
490 }
491
492 pub fn intervention_counts(&self) -> &InterventionCounts {
494 &self.intervention_counts
495 }
496
497 pub fn config(&self) -> &FairnessConfig {
499 &self.config
500 }
501
502 pub fn resize_dominance_count(&self) -> u32 {
504 self.resize_dominance_count
505 }
506
507 pub fn is_enabled(&self) -> bool {
509 self.config.enabled
510 }
511
512 pub fn jain_index(&self) -> f64 {
514 self.calculate_jain_index()
515 }
516
517 pub fn has_pending_input(&self) -> bool {
519 self.pending_input_arrival.is_some()
520 }
521
522 pub fn reset(&mut self) {
524 self.pending_input_arrival = None;
525 self.recent_input_arrival = None;
526 self.resize_dominance_count = 0;
527 self.processing_window.clear();
528 self.input_time_us = 0;
529 self.resize_time_us = 0;
530 self.stats = FairnessStats::default();
531 self.intervention_counts = InterventionCounts::default();
532 }
533}
534
535impl Default for InputFairnessGuard {
536 fn default() -> Self {
537 Self::new()
538 }
539}
540
541#[cfg(test)]
542mod tests {
543 use super::*;
544
545 #[test]
546 fn default_config_is_enabled() {
547 let config = FairnessConfig::default();
548 assert!(config.enabled);
549 }
550
551 #[test]
552 fn default_fairness_threshold_is_above_two_class_floor() {
553 let config = FairnessConfig::default();
554 assert!(config.fairness_threshold > 0.5 + FAIRNESS_THRESHOLD_EPSILON);
557 }
558
559 #[test]
560 fn disabled_config() {
561 let config = FairnessConfig::disabled();
562 assert!(!config.enabled);
563 }
564
565 #[test]
566 fn default_decision_allows_processing() {
567 let mut guard = InputFairnessGuard::default();
568 let decision = guard.check_fairness(Instant::now());
569 assert!(decision.should_process);
570 }
571
572 #[test]
573 fn event_processing_updates_stats() {
574 let mut guard = InputFairnessGuard::default();
575 let now = Instant::now();
576
577 guard.event_processed(EventType::Input, Duration::from_millis(10), now);
578 guard.event_processed(EventType::Resize, Duration::from_millis(5), now);
579 guard.event_processed(EventType::Tick, Duration::from_millis(1), now);
580
581 let stats = guard.stats();
582 assert_eq!(stats.events_processed, 3);
583 assert_eq!(stats.input_events, 1);
584 assert_eq!(stats.resize_events, 1);
585 assert_eq!(stats.tick_events, 1);
586 }
587
588 #[test]
589 fn event_processing_duration_counters_do_not_truncate() {
590 let mut guard = InputFairnessGuard::default();
591 let now = Instant::now();
592
593 guard.event_processed(EventType::Input, Duration::MAX, now);
594 guard.event_processed(EventType::Input, Duration::from_micros(1), now);
595 guard.event_processed(EventType::Resize, Duration::MAX, now);
596 guard.event_processed(EventType::Resize, Duration::from_micros(1), now);
597
598 let expected = Duration::MAX.as_micros().saturating_add(1);
599 assert_eq!(guard.input_time_us, expected);
600 assert_eq!(guard.resize_time_us, expected);
601 }
602
603 #[test]
604 fn test_jain_index_perfect_fairness() {
605 let mut guard = InputFairnessGuard::new();
606 let now = Instant::now();
607
608 guard.event_processed(EventType::Input, Duration::from_millis(10), now);
610 guard.event_processed(EventType::Resize, Duration::from_millis(10), now);
611
612 let jain = guard.jain_index();
613 assert!((jain - 1.0).abs() < 0.001, "Expected ~1.0, got {}", jain);
614 }
615
616 #[test]
617 fn test_jain_index_unfair() {
618 let mut guard = InputFairnessGuard::new();
619 let now = Instant::now();
620
621 guard.event_processed(EventType::Input, Duration::from_millis(1), now);
623 guard.event_processed(EventType::Resize, Duration::from_millis(100), now);
624
625 let jain = guard.jain_index();
626 assert!(jain < 0.6, "Expected unfair index < 0.6, got {}", jain);
628 }
629
630 #[test]
631 fn test_jain_index_empty() {
632 let guard = InputFairnessGuard::new();
633 let jain = guard.jain_index();
634 assert!((jain - 1.0).abs() < 0.001, "Empty should be fair (1.0)");
635 }
636
637 #[test]
638 fn test_latency_threshold_intervention() {
639 let config = FairnessConfig::default().with_max_latency(Duration::from_millis(20));
640 let mut guard = InputFairnessGuard::with_config(config);
641
642 let start = Instant::now();
643 guard.input_arrived(start);
644
645 let decision = guard.check_fairness(start + Duration::from_millis(25));
647 assert!(decision.yield_to_input);
648 assert_eq!(decision.reason, InterventionReason::InputLatency);
649 }
650
651 #[test]
652 fn test_resize_dominance_intervention() {
653 let config = FairnessConfig::default().with_dominance_threshold(2);
654 let mut guard = InputFairnessGuard::with_config(config);
655 let now = Instant::now();
656
657 guard.input_arrived(now);
659
660 guard.event_processed(EventType::Resize, Duration::from_millis(5), now);
662 guard.event_processed(EventType::Resize, Duration::from_millis(5), now);
663
664 let decision = guard.check_fairness(now);
665 assert!(decision.yield_to_input);
666 assert_eq!(decision.reason, InterventionReason::ResizeDominance);
667 }
668
669 #[test]
670 fn test_no_intervention_when_fair() {
671 let mut guard = InputFairnessGuard::new();
672 let now = Instant::now();
673
674 guard.event_processed(EventType::Input, Duration::from_millis(10), now);
676 guard.event_processed(EventType::Resize, Duration::from_millis(10), now);
677
678 let decision = guard.check_fairness(now);
679 assert!(!decision.yield_to_input);
680 assert_eq!(decision.reason, InterventionReason::None);
681 }
682
683 #[test]
684 fn test_fairness_index_intervention() {
685 let config = FairnessConfig {
686 input_priority_threshold: Duration::from_secs(10),
687 dominance_threshold: 100,
688 fairness_threshold: 0.9,
689 ..Default::default()
690 };
691 let mut guard = InputFairnessGuard::with_config(config);
692 let now = Instant::now();
693
694 guard.event_processed(EventType::Input, Duration::from_millis(1), now);
697 guard.input_arrived(now);
698 guard.event_processed(EventType::Resize, Duration::from_millis(100), now);
699
700 let decision = guard.check_fairness(now + Duration::from_millis(1));
701 assert!(decision.yield_to_input);
702 assert_eq!(decision.reason, InterventionReason::FairnessIndex);
703 }
704
705 #[test]
706 fn fairness_index_triggers_when_input_is_starved_in_window() {
707 let config = FairnessConfig {
708 input_priority_threshold: Duration::from_secs(10),
709 dominance_threshold: 100,
710 fairness_threshold: 0.9,
711 ..Default::default()
712 };
713 let mut guard = InputFairnessGuard::with_config(config);
714 let now = Instant::now();
715
716 guard.input_arrived(now);
719 guard.event_processed(EventType::Resize, Duration::from_millis(10), now);
720
721 let decision = guard.check_fairness(now);
722 assert_eq!(decision.reason, InterventionReason::FairnessIndex);
723 assert!(decision.yield_to_input);
724 }
725
726 #[test]
727 fn test_dominance_reset_on_input() {
728 let mut guard = InputFairnessGuard::new();
729 let now = Instant::now();
730
731 guard.event_processed(EventType::Resize, Duration::from_millis(5), now);
733 guard.event_processed(EventType::Resize, Duration::from_millis(5), now);
734 assert_eq!(guard.resize_dominance_count, 2);
735
736 guard.event_processed(EventType::Input, Duration::from_millis(5), now);
738 assert_eq!(guard.resize_dominance_count, 0);
739 }
740
741 #[test]
742 fn test_pending_input_cleared_on_processing() {
743 let mut guard = InputFairnessGuard::new();
744 let now = Instant::now();
745
746 guard.input_arrived(now);
747 assert!(guard.has_pending_input());
748
749 guard.event_processed(EventType::Input, Duration::from_millis(5), now);
750 assert!(!guard.has_pending_input());
751 }
752
753 #[test]
754 fn no_intervention_without_pending_input_under_resize_flood() {
755 let config = FairnessConfig {
756 input_priority_threshold: Duration::from_millis(1),
757 dominance_threshold: 1,
758 fairness_threshold: 0.99,
759 enabled: true,
760 };
761 let mut guard = InputFairnessGuard::with_config(config);
762 let now = Instant::now();
763
764 guard.event_processed(EventType::Resize, Duration::from_millis(50), now);
765 let decision = guard.check_fairness(now + Duration::from_millis(50));
766
767 assert!(!decision.yield_to_input);
768 assert_eq!(decision.reason, InterventionReason::None);
769 assert!(decision.pending_input_latency.is_none());
770 }
771
772 #[test]
773 fn processed_input_does_not_cause_spurious_followup_intervention() {
774 let config = FairnessConfig {
775 input_priority_threshold: Duration::from_millis(1),
776 dominance_threshold: 1,
777 fairness_threshold: 0.99,
778 enabled: true,
779 };
780 let mut guard = InputFairnessGuard::with_config(config);
781 let now = Instant::now();
782
783 guard.input_arrived(now);
784 guard.event_processed(EventType::Input, Duration::from_millis(1), now);
785 guard.event_processed(EventType::Resize, Duration::from_millis(50), now);
786
787 let decision = guard.check_fairness(now + Duration::from_millis(50));
788 assert!(!decision.yield_to_input);
789 assert_eq!(decision.reason, InterventionReason::None);
790 assert!(decision.pending_input_latency.is_none());
791 }
792
793 #[test]
794 fn test_stats_tracking() {
795 let mut guard = InputFairnessGuard::new();
796 let now = Instant::now();
797
798 guard.check_fairness(now);
800 guard.check_fairness(now);
801
802 assert_eq!(guard.stats().total_checks, 2);
803 }
804
805 #[test]
806 fn test_sliding_window_eviction() {
807 let mut guard = InputFairnessGuard::new();
808 let now = Instant::now();
809
810 for _ in 0..(FAIRNESS_WINDOW_SIZE + 5) {
812 guard.event_processed(EventType::Input, Duration::from_millis(1), now);
813 }
814
815 assert_eq!(guard.processing_window.len(), FAIRNESS_WINDOW_SIZE);
816 }
817
818 #[test]
819 fn test_reset() {
820 let mut guard = InputFairnessGuard::new();
821 let now = Instant::now();
822
823 guard.input_arrived(now);
824 guard.event_processed(EventType::Resize, Duration::from_millis(10), now);
825 guard.check_fairness(now);
826
827 guard.reset();
828
829 assert!(!guard.has_pending_input());
830 assert_eq!(guard.resize_dominance_count, 0);
831 assert_eq!(guard.stats().total_checks, 0);
832 assert!(guard.processing_window.is_empty());
833 }
834
835 #[test]
838 fn test_invariant_jain_index_bounds() {
839 let mut guard = InputFairnessGuard::new();
841 let now = Instant::now();
842
843 for (input_ms, resize_ms) in [(1, 1), (1, 100), (100, 1), (50, 50), (0, 100), (100, 0)] {
845 guard.reset();
846 if input_ms > 0 {
847 guard.event_processed(EventType::Input, Duration::from_millis(input_ms), now);
848 }
849 if resize_ms > 0 {
850 guard.event_processed(EventType::Resize, Duration::from_millis(resize_ms), now);
851 }
852
853 let jain = guard.jain_index();
854 assert!(
855 (0.5..=1.0).contains(&jain),
856 "Jain index {} out of bounds for input={}, resize={}",
857 jain,
858 input_ms,
859 resize_ms
860 );
861 }
862 }
863
864 #[test]
865 fn test_invariant_intervention_resets_dominance() {
866 let config = FairnessConfig::default().with_dominance_threshold(2);
867 let mut guard = InputFairnessGuard::with_config(config);
868 let now = Instant::now();
869
870 guard.input_arrived(now);
872 guard.event_processed(EventType::Resize, Duration::from_millis(5), now);
873 guard.event_processed(EventType::Resize, Duration::from_millis(5), now);
874
875 let decision = guard.check_fairness(now);
877 assert!(decision.yield_to_input);
878 assert_eq!(guard.resize_dominance_count, 0);
879 }
880
881 #[test]
882 fn test_invariant_monotonic_stats() {
883 let mut guard = InputFairnessGuard::new();
884 let now = Instant::now();
885
886 let mut prev_checks = 0u64;
887 for _ in 0..10 {
888 guard.check_fairness(now);
889 assert!(guard.stats().total_checks > prev_checks);
890 prev_checks = guard.stats().total_checks;
891 }
892 }
893
894 #[test]
895 fn test_disabled_returns_no_intervention() {
896 let config = FairnessConfig::disabled();
897 let mut guard = InputFairnessGuard::with_config(config);
898 let now = Instant::now();
899
900 guard.input_arrived(now);
902 guard.event_processed(EventType::Resize, Duration::from_millis(100), now);
903 guard.event_processed(EventType::Resize, Duration::from_millis(100), now);
904 guard.event_processed(EventType::Resize, Duration::from_millis(100), now);
905
906 let decision = guard.check_fairness(now);
907 assert!(!decision.yield_to_input);
908 assert_eq!(decision.reason, InterventionReason::None);
909 }
910
911 #[test]
916 fn fairness_decision_fields_match_state() {
917 let mut guard = InputFairnessGuard::new();
918 let now = Instant::now();
919
920 let d = guard.check_fairness(now);
922 assert!(d.pending_input_latency.is_none());
923 assert_eq!(d.reason, InterventionReason::None);
924 assert!(!d.yield_to_input);
925 assert!(d.should_process);
926 assert!((d.jain_index - 1.0).abs() < f64::EPSILON);
927
928 guard.input_arrived(now);
930 let later = now + Duration::from_millis(10);
931 let d = guard.check_fairness(later);
932 assert!(d.pending_input_latency.is_some());
933 let lat = d.pending_input_latency.unwrap();
934 assert!(lat >= Duration::from_millis(10));
935 }
936
937 #[test]
938 fn jain_index_exact_values() {
939 let mut guard = InputFairnessGuard::new();
940 let now = Instant::now();
941
942 guard.event_processed(EventType::Input, Duration::from_millis(100), now);
944 guard.event_processed(EventType::Resize, Duration::from_millis(100), now);
945 let j = guard.jain_index();
946 assert!(
947 (j - 1.0).abs() < 1e-9,
948 "Equal allocation should yield 1.0, got {j}"
949 );
950
951 guard.reset();
952
953 guard.event_processed(EventType::Input, Duration::from_millis(1), now);
956 guard.event_processed(EventType::Resize, Duration::from_millis(100), now);
957 let j = guard.jain_index();
958 assert!(j > 0.5, "F should be > 0.5 for two types, got {j}");
959 assert!(j < 0.6, "F should be < 0.6 for 1:100 ratio, got {j}");
960 }
961
962 #[test]
963 fn jain_index_bounded_across_ratios() {
964 let ratios: &[(u64, u64)] = &[
966 (0, 0),
967 (1, 0),
968 (0, 1),
969 (1, 1),
970 (1, 1000),
971 (1000, 1),
972 (50, 50),
973 (100, 1),
974 (999, 1),
975 ];
976 for &(input_ms, resize_ms) in ratios {
977 let mut guard = InputFairnessGuard::new();
978 let now = Instant::now();
979 if input_ms > 0 {
980 guard.event_processed(EventType::Input, Duration::from_millis(input_ms), now);
981 }
982 if resize_ms > 0 {
983 guard.event_processed(EventType::Resize, Duration::from_millis(resize_ms), now);
984 }
985 let j = guard.jain_index();
986 assert!(
987 (0.5..=1.0).contains(&j),
988 "Jain index out of bounds for ({input_ms}, {resize_ms}): {j}"
989 );
990 }
991 }
992
993 #[test]
994 fn intervention_reason_priority_order() {
995 let config = FairnessConfig {
997 input_priority_threshold: Duration::from_millis(20),
998 dominance_threshold: 2,
999 fairness_threshold: 0.9, enabled: true,
1001 };
1002 let mut guard = InputFairnessGuard::with_config(config);
1003 let now = Instant::now();
1004
1005 guard.input_arrived(now);
1008 guard.event_processed(EventType::Resize, Duration::from_millis(50), now);
1010 guard.event_processed(EventType::Resize, Duration::from_millis(50), now);
1011 guard.event_processed(EventType::Resize, Duration::from_millis(50), now);
1012
1013 let later = now + Duration::from_millis(100);
1015 let d = guard.check_fairness(later);
1016
1017 assert_eq!(
1019 d.reason,
1020 InterventionReason::InputLatency,
1021 "InputLatency should have highest priority"
1022 );
1023 assert!(d.yield_to_input);
1024 }
1025
1026 #[test]
1027 fn resize_dominance_triggers_after_threshold() {
1028 let config = FairnessConfig {
1029 dominance_threshold: 3,
1030 fairness_threshold: 0.5,
1033 ..FairnessConfig::default()
1034 };
1035 let mut guard = InputFairnessGuard::with_config(config);
1036 let now = Instant::now();
1037
1038 guard.input_arrived(now);
1040
1041 guard.event_processed(EventType::Resize, Duration::from_millis(10), now);
1043 guard.event_processed(EventType::Resize, Duration::from_millis(10), now);
1044 let d = guard.check_fairness(now);
1045 assert_eq!(d.reason, InterventionReason::None);
1046
1047 guard.input_arrived(now);
1049
1050 guard.event_processed(EventType::Resize, Duration::from_millis(10), now);
1052 let d = guard.check_fairness(now);
1053 assert_eq!(d.reason, InterventionReason::ResizeDominance);
1054 assert!(d.yield_to_input);
1055 }
1056
1057 #[test]
1058 fn intervention_counts_track_each_reason() {
1059 let config = FairnessConfig {
1060 input_priority_threshold: Duration::from_millis(10),
1061 dominance_threshold: 2,
1062 fairness_threshold: 0.8,
1063 enabled: true,
1064 };
1065 let mut guard = InputFairnessGuard::with_config(config);
1066 let now = Instant::now();
1067
1068 guard.input_arrived(now);
1070 let later = now + Duration::from_millis(50);
1071 guard.check_fairness(later);
1072
1073 let counts = guard.intervention_counts();
1074 assert_eq!(counts.input_latency, 1);
1075 assert_eq!(counts.resize_dominance, 0);
1076 assert_eq!(counts.fairness_index, 0);
1077
1078 guard.input_arrived(now);
1080 guard.event_processed(EventType::Resize, Duration::from_millis(10), now);
1081 guard.event_processed(EventType::Resize, Duration::from_millis(10), now);
1082 guard.check_fairness(now);
1083
1084 let counts = guard.intervention_counts();
1085 assert_eq!(counts.resize_dominance, 1);
1086 }
1087
1088 #[test]
1089 fn fairness_stable_across_repeated_check_cycles() {
1090 let mut guard = InputFairnessGuard::new();
1091 let now = Instant::now();
1092
1093 for i in 0..50 {
1095 let t = now + Duration::from_millis(i * 16);
1096 guard.event_processed(EventType::Input, Duration::from_millis(5), t);
1097 guard.event_processed(EventType::Resize, Duration::from_millis(5), t);
1098 let d = guard.check_fairness(t);
1099
1100 assert!(!d.yield_to_input, "Unexpected intervention at cycle {i}");
1102 assert!(
1104 d.jain_index > 0.95,
1105 "Jain index degraded at cycle {i}: {}",
1106 d.jain_index
1107 );
1108 }
1109
1110 let stats = guard.stats();
1111 assert_eq!(stats.events_processed, 100);
1112 assert_eq!(stats.input_events, 50);
1113 assert_eq!(stats.resize_events, 50);
1114 assert_eq!(stats.total_interventions, 0);
1115 }
1116
1117 #[test]
1118 fn fairness_index_degrades_under_resize_flood() {
1119 let mut guard = InputFairnessGuard::new();
1120 let now = Instant::now();
1121
1122 guard.event_processed(EventType::Input, Duration::from_millis(5), now);
1124 for _ in 0..15 {
1125 guard.event_processed(EventType::Resize, Duration::from_millis(20), now);
1126 }
1127
1128 let j = guard.jain_index();
1129 assert!(
1132 j < 0.55,
1133 "Jain index should be low under resize flood, got {j}"
1134 );
1135 }
1136
1137 #[test]
1138 fn max_input_latency_tracked_across_checks() {
1139 let mut guard = InputFairnessGuard::new();
1140 let now = Instant::now();
1141
1142 guard.input_arrived(now);
1143 guard.check_fairness(now + Duration::from_millis(30));
1144
1145 guard.input_arrived(now + Duration::from_millis(50));
1146 guard.check_fairness(now + Duration::from_millis(100));
1147
1148 let stats = guard.stats();
1149 assert!(stats.max_input_latency >= Duration::from_millis(30));
1151 }
1152
1153 #[test]
1154 fn max_input_latency_ignores_recent_when_no_pending_input() {
1155 let mut guard = InputFairnessGuard::new();
1156 let now = Instant::now();
1157
1158 guard.input_arrived(now);
1159 guard.event_processed(EventType::Input, Duration::from_millis(1), now);
1160
1161 guard.check_fairness(now + Duration::from_millis(100));
1163 assert_eq!(guard.stats().max_input_latency, Duration::ZERO);
1164 }
1165
1166 #[test]
1167 fn sliding_window_evicts_oldest_entries() {
1168 let mut guard = InputFairnessGuard::new();
1169 let now = Instant::now();
1170
1171 for _ in 0..16 {
1174 guard.event_processed(EventType::Resize, Duration::from_millis(10), now);
1175 }
1176
1177 for _ in 0..16 {
1179 guard.event_processed(EventType::Input, Duration::from_millis(10), now);
1180 }
1181
1182 let j = guard.jain_index();
1185 assert!(
1187 j < 0.6,
1188 "After full eviction to input-only, Jain should be ~0.5, got {j}"
1189 );
1190 }
1191
1192 #[test]
1193 fn custom_config_thresholds_work() {
1194 let config = FairnessConfig {
1195 input_priority_threshold: Duration::from_millis(200),
1196 dominance_threshold: 10,
1197 fairness_threshold: 0.3,
1198 enabled: true,
1199 };
1200 let mut guard = InputFairnessGuard::with_config(config);
1201 let now = Instant::now();
1202
1203 guard.input_arrived(now);
1205 guard.event_processed(EventType::Resize, Duration::from_millis(50), now);
1206 guard.event_processed(EventType::Resize, Duration::from_millis(50), now);
1207 guard.event_processed(EventType::Resize, Duration::from_millis(50), now);
1208
1209 let later = now + Duration::from_millis(100);
1210 let d = guard.check_fairness(later);
1211 assert_eq!(d.reason, InterventionReason::None);
1212 assert!(!d.yield_to_input);
1213 }
1214}