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: u64,
272 resize_time_us: u64,
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 = self
402 .input_time_us
403 .saturating_sub(old.duration.as_micros() as u64);
404 }
405 EventType::Resize => {
406 self.resize_time_us = self
407 .resize_time_us
408 .saturating_sub(old.duration.as_micros() as u64);
409 }
410 EventType::Tick => {}
411 }
412 }
413
414 match event_type {
416 EventType::Input => {
417 self.input_time_us += duration.as_micros() as u64;
418 self.pending_input_arrival = None;
419 self.resize_dominance_count = 0; }
421 EventType::Resize => {
422 self.resize_time_us += duration.as_micros() as u64;
423 self.resize_dominance_count += 1;
424 }
425 EventType::Tick => {}
426 }
427
428 self.processing_window.push_back(record);
429 }
430
431 fn calculate_jain_index(&self) -> f64 {
433 let x = self.input_time_us as f64;
435 let y = self.resize_time_us as f64;
436
437 if x == 0.0 && y == 0.0 {
438 return 1.0; }
440
441 let sum = x + y;
442 let sum_sq = x * x + y * y;
443
444 if sum_sq == 0.0 {
445 return 1.0;
446 }
447
448 (sum * sum) / (2.0 * sum_sq)
449 }
450
451 fn determine_intervention_reason(
453 &self,
454 pending_latency: Option<Duration>,
455 jain: f64,
456 has_pending_input: bool,
457 ) -> InterventionReason {
458 if has_pending_input
460 && let Some(latency) = pending_latency
461 && latency >= self.config.input_priority_threshold
462 {
463 return InterventionReason::InputLatency;
464 }
465
466 if has_pending_input && self.resize_dominance_count >= self.config.dominance_threshold {
468 return InterventionReason::ResizeDominance;
469 }
470
471 if has_pending_input
480 && jain + FAIRNESS_THRESHOLD_EPSILON < self.config.fairness_threshold
481 && self.resize_time_us > self.input_time_us
482 {
483 return InterventionReason::FairnessIndex;
484 }
485
486 InterventionReason::None
487 }
488
489 pub fn stats(&self) -> &FairnessStats {
491 &self.stats
492 }
493
494 pub fn intervention_counts(&self) -> &InterventionCounts {
496 &self.intervention_counts
497 }
498
499 pub fn config(&self) -> &FairnessConfig {
501 &self.config
502 }
503
504 pub fn resize_dominance_count(&self) -> u32 {
506 self.resize_dominance_count
507 }
508
509 pub fn is_enabled(&self) -> bool {
511 self.config.enabled
512 }
513
514 pub fn jain_index(&self) -> f64 {
516 self.calculate_jain_index()
517 }
518
519 pub fn has_pending_input(&self) -> bool {
521 self.pending_input_arrival.is_some()
522 }
523
524 pub fn reset(&mut self) {
526 self.pending_input_arrival = None;
527 self.recent_input_arrival = None;
528 self.resize_dominance_count = 0;
529 self.processing_window.clear();
530 self.input_time_us = 0;
531 self.resize_time_us = 0;
532 self.stats = FairnessStats::default();
533 self.intervention_counts = InterventionCounts::default();
534 }
535}
536
537impl Default for InputFairnessGuard {
538 fn default() -> Self {
539 Self::new()
540 }
541}
542
543#[cfg(test)]
544mod tests {
545 use super::*;
546
547 #[test]
548 fn default_config_is_enabled() {
549 let config = FairnessConfig::default();
550 assert!(config.enabled);
551 }
552
553 #[test]
554 fn default_fairness_threshold_is_above_two_class_floor() {
555 let config = FairnessConfig::default();
556 assert!(config.fairness_threshold > 0.5 + FAIRNESS_THRESHOLD_EPSILON);
559 }
560
561 #[test]
562 fn disabled_config() {
563 let config = FairnessConfig::disabled();
564 assert!(!config.enabled);
565 }
566
567 #[test]
568 fn default_decision_allows_processing() {
569 let mut guard = InputFairnessGuard::default();
570 let decision = guard.check_fairness(Instant::now());
571 assert!(decision.should_process);
572 }
573
574 #[test]
575 fn event_processing_updates_stats() {
576 let mut guard = InputFairnessGuard::default();
577 let now = Instant::now();
578
579 guard.event_processed(EventType::Input, Duration::from_millis(10), now);
580 guard.event_processed(EventType::Resize, Duration::from_millis(5), now);
581 guard.event_processed(EventType::Tick, Duration::from_millis(1), now);
582
583 let stats = guard.stats();
584 assert_eq!(stats.events_processed, 3);
585 assert_eq!(stats.input_events, 1);
586 assert_eq!(stats.resize_events, 1);
587 assert_eq!(stats.tick_events, 1);
588 }
589
590 #[test]
591 fn test_jain_index_perfect_fairness() {
592 let mut guard = InputFairnessGuard::new();
593 let now = Instant::now();
594
595 guard.event_processed(EventType::Input, Duration::from_millis(10), now);
597 guard.event_processed(EventType::Resize, Duration::from_millis(10), now);
598
599 let jain = guard.jain_index();
600 assert!((jain - 1.0).abs() < 0.001, "Expected ~1.0, got {}", jain);
601 }
602
603 #[test]
604 fn test_jain_index_unfair() {
605 let mut guard = InputFairnessGuard::new();
606 let now = Instant::now();
607
608 guard.event_processed(EventType::Input, Duration::from_millis(1), now);
610 guard.event_processed(EventType::Resize, Duration::from_millis(100), now);
611
612 let jain = guard.jain_index();
613 assert!(jain < 0.6, "Expected unfair index < 0.6, got {}", jain);
615 }
616
617 #[test]
618 fn test_jain_index_empty() {
619 let guard = InputFairnessGuard::new();
620 let jain = guard.jain_index();
621 assert!((jain - 1.0).abs() < 0.001, "Empty should be fair (1.0)");
622 }
623
624 #[test]
625 fn test_latency_threshold_intervention() {
626 let config = FairnessConfig::default().with_max_latency(Duration::from_millis(20));
627 let mut guard = InputFairnessGuard::with_config(config);
628
629 let start = Instant::now();
630 guard.input_arrived(start);
631
632 let decision = guard.check_fairness(start + Duration::from_millis(25));
634 assert!(decision.yield_to_input);
635 assert_eq!(decision.reason, InterventionReason::InputLatency);
636 }
637
638 #[test]
639 fn test_resize_dominance_intervention() {
640 let config = FairnessConfig::default().with_dominance_threshold(2);
641 let mut guard = InputFairnessGuard::with_config(config);
642 let now = Instant::now();
643
644 guard.input_arrived(now);
646
647 guard.event_processed(EventType::Resize, Duration::from_millis(5), now);
649 guard.event_processed(EventType::Resize, Duration::from_millis(5), now);
650
651 let decision = guard.check_fairness(now);
652 assert!(decision.yield_to_input);
653 assert_eq!(decision.reason, InterventionReason::ResizeDominance);
654 }
655
656 #[test]
657 fn test_no_intervention_when_fair() {
658 let mut guard = InputFairnessGuard::new();
659 let now = Instant::now();
660
661 guard.event_processed(EventType::Input, Duration::from_millis(10), now);
663 guard.event_processed(EventType::Resize, Duration::from_millis(10), now);
664
665 let decision = guard.check_fairness(now);
666 assert!(!decision.yield_to_input);
667 assert_eq!(decision.reason, InterventionReason::None);
668 }
669
670 #[test]
671 fn test_fairness_index_intervention() {
672 let config = FairnessConfig {
673 input_priority_threshold: Duration::from_secs(10),
674 dominance_threshold: 100,
675 fairness_threshold: 0.9,
676 ..Default::default()
677 };
678 let mut guard = InputFairnessGuard::with_config(config);
679 let now = Instant::now();
680
681 guard.event_processed(EventType::Input, Duration::from_millis(1), now);
684 guard.input_arrived(now);
685 guard.event_processed(EventType::Resize, Duration::from_millis(100), now);
686
687 let decision = guard.check_fairness(now + Duration::from_millis(1));
688 assert!(decision.yield_to_input);
689 assert_eq!(decision.reason, InterventionReason::FairnessIndex);
690 }
691
692 #[test]
693 fn fairness_index_triggers_when_input_is_starved_in_window() {
694 let config = FairnessConfig {
695 input_priority_threshold: Duration::from_secs(10),
696 dominance_threshold: 100,
697 fairness_threshold: 0.9,
698 ..Default::default()
699 };
700 let mut guard = InputFairnessGuard::with_config(config);
701 let now = Instant::now();
702
703 guard.input_arrived(now);
706 guard.event_processed(EventType::Resize, Duration::from_millis(10), now);
707
708 let decision = guard.check_fairness(now);
709 assert_eq!(decision.reason, InterventionReason::FairnessIndex);
710 assert!(decision.yield_to_input);
711 }
712
713 #[test]
714 fn test_dominance_reset_on_input() {
715 let mut guard = InputFairnessGuard::new();
716 let now = Instant::now();
717
718 guard.event_processed(EventType::Resize, Duration::from_millis(5), now);
720 guard.event_processed(EventType::Resize, Duration::from_millis(5), now);
721 assert_eq!(guard.resize_dominance_count, 2);
722
723 guard.event_processed(EventType::Input, Duration::from_millis(5), now);
725 assert_eq!(guard.resize_dominance_count, 0);
726 }
727
728 #[test]
729 fn test_pending_input_cleared_on_processing() {
730 let mut guard = InputFairnessGuard::new();
731 let now = Instant::now();
732
733 guard.input_arrived(now);
734 assert!(guard.has_pending_input());
735
736 guard.event_processed(EventType::Input, Duration::from_millis(5), now);
737 assert!(!guard.has_pending_input());
738 }
739
740 #[test]
741 fn no_intervention_without_pending_input_under_resize_flood() {
742 let config = FairnessConfig {
743 input_priority_threshold: Duration::from_millis(1),
744 dominance_threshold: 1,
745 fairness_threshold: 0.99,
746 enabled: true,
747 };
748 let mut guard = InputFairnessGuard::with_config(config);
749 let now = Instant::now();
750
751 guard.event_processed(EventType::Resize, Duration::from_millis(50), now);
752 let decision = guard.check_fairness(now + Duration::from_millis(50));
753
754 assert!(!decision.yield_to_input);
755 assert_eq!(decision.reason, InterventionReason::None);
756 assert!(decision.pending_input_latency.is_none());
757 }
758
759 #[test]
760 fn processed_input_does_not_cause_spurious_followup_intervention() {
761 let config = FairnessConfig {
762 input_priority_threshold: Duration::from_millis(1),
763 dominance_threshold: 1,
764 fairness_threshold: 0.99,
765 enabled: true,
766 };
767 let mut guard = InputFairnessGuard::with_config(config);
768 let now = Instant::now();
769
770 guard.input_arrived(now);
771 guard.event_processed(EventType::Input, Duration::from_millis(1), now);
772 guard.event_processed(EventType::Resize, Duration::from_millis(50), now);
773
774 let decision = guard.check_fairness(now + Duration::from_millis(50));
775 assert!(!decision.yield_to_input);
776 assert_eq!(decision.reason, InterventionReason::None);
777 assert!(decision.pending_input_latency.is_none());
778 }
779
780 #[test]
781 fn test_stats_tracking() {
782 let mut guard = InputFairnessGuard::new();
783 let now = Instant::now();
784
785 guard.check_fairness(now);
787 guard.check_fairness(now);
788
789 assert_eq!(guard.stats().total_checks, 2);
790 }
791
792 #[test]
793 fn test_sliding_window_eviction() {
794 let mut guard = InputFairnessGuard::new();
795 let now = Instant::now();
796
797 for _ in 0..(FAIRNESS_WINDOW_SIZE + 5) {
799 guard.event_processed(EventType::Input, Duration::from_millis(1), now);
800 }
801
802 assert_eq!(guard.processing_window.len(), FAIRNESS_WINDOW_SIZE);
803 }
804
805 #[test]
806 fn test_reset() {
807 let mut guard = InputFairnessGuard::new();
808 let now = Instant::now();
809
810 guard.input_arrived(now);
811 guard.event_processed(EventType::Resize, Duration::from_millis(10), now);
812 guard.check_fairness(now);
813
814 guard.reset();
815
816 assert!(!guard.has_pending_input());
817 assert_eq!(guard.resize_dominance_count, 0);
818 assert_eq!(guard.stats().total_checks, 0);
819 assert!(guard.processing_window.is_empty());
820 }
821
822 #[test]
825 fn test_invariant_jain_index_bounds() {
826 let mut guard = InputFairnessGuard::new();
828 let now = Instant::now();
829
830 for (input_ms, resize_ms) in [(1, 1), (1, 100), (100, 1), (50, 50), (0, 100), (100, 0)] {
832 guard.reset();
833 if input_ms > 0 {
834 guard.event_processed(EventType::Input, Duration::from_millis(input_ms), now);
835 }
836 if resize_ms > 0 {
837 guard.event_processed(EventType::Resize, Duration::from_millis(resize_ms), now);
838 }
839
840 let jain = guard.jain_index();
841 assert!(
842 (0.5..=1.0).contains(&jain),
843 "Jain index {} out of bounds for input={}, resize={}",
844 jain,
845 input_ms,
846 resize_ms
847 );
848 }
849 }
850
851 #[test]
852 fn test_invariant_intervention_resets_dominance() {
853 let config = FairnessConfig::default().with_dominance_threshold(2);
854 let mut guard = InputFairnessGuard::with_config(config);
855 let now = Instant::now();
856
857 guard.input_arrived(now);
859 guard.event_processed(EventType::Resize, Duration::from_millis(5), now);
860 guard.event_processed(EventType::Resize, Duration::from_millis(5), now);
861
862 let decision = guard.check_fairness(now);
864 assert!(decision.yield_to_input);
865 assert_eq!(guard.resize_dominance_count, 0);
866 }
867
868 #[test]
869 fn test_invariant_monotonic_stats() {
870 let mut guard = InputFairnessGuard::new();
871 let now = Instant::now();
872
873 let mut prev_checks = 0u64;
874 for _ in 0..10 {
875 guard.check_fairness(now);
876 assert!(guard.stats().total_checks > prev_checks);
877 prev_checks = guard.stats().total_checks;
878 }
879 }
880
881 #[test]
882 fn test_disabled_returns_no_intervention() {
883 let config = FairnessConfig::disabled();
884 let mut guard = InputFairnessGuard::with_config(config);
885 let now = Instant::now();
886
887 guard.input_arrived(now);
889 guard.event_processed(EventType::Resize, Duration::from_millis(100), now);
890 guard.event_processed(EventType::Resize, Duration::from_millis(100), now);
891 guard.event_processed(EventType::Resize, Duration::from_millis(100), now);
892
893 let decision = guard.check_fairness(now);
894 assert!(!decision.yield_to_input);
895 assert_eq!(decision.reason, InterventionReason::None);
896 }
897
898 #[test]
903 fn fairness_decision_fields_match_state() {
904 let mut guard = InputFairnessGuard::new();
905 let now = Instant::now();
906
907 let d = guard.check_fairness(now);
909 assert!(d.pending_input_latency.is_none());
910 assert_eq!(d.reason, InterventionReason::None);
911 assert!(!d.yield_to_input);
912 assert!(d.should_process);
913 assert!((d.jain_index - 1.0).abs() < f64::EPSILON);
914
915 guard.input_arrived(now);
917 let later = now + Duration::from_millis(10);
918 let d = guard.check_fairness(later);
919 assert!(d.pending_input_latency.is_some());
920 let lat = d.pending_input_latency.unwrap();
921 assert!(lat >= Duration::from_millis(10));
922 }
923
924 #[test]
925 fn jain_index_exact_values() {
926 let mut guard = InputFairnessGuard::new();
927 let now = Instant::now();
928
929 guard.event_processed(EventType::Input, Duration::from_millis(100), now);
931 guard.event_processed(EventType::Resize, Duration::from_millis(100), now);
932 let j = guard.jain_index();
933 assert!(
934 (j - 1.0).abs() < 1e-9,
935 "Equal allocation should yield 1.0, got {j}"
936 );
937
938 guard.reset();
939
940 guard.event_processed(EventType::Input, Duration::from_millis(1), now);
943 guard.event_processed(EventType::Resize, Duration::from_millis(100), now);
944 let j = guard.jain_index();
945 assert!(j > 0.5, "F should be > 0.5 for two types, got {j}");
946 assert!(j < 0.6, "F should be < 0.6 for 1:100 ratio, got {j}");
947 }
948
949 #[test]
950 fn jain_index_bounded_across_ratios() {
951 let ratios: &[(u64, u64)] = &[
953 (0, 0),
954 (1, 0),
955 (0, 1),
956 (1, 1),
957 (1, 1000),
958 (1000, 1),
959 (50, 50),
960 (100, 1),
961 (999, 1),
962 ];
963 for &(input_ms, resize_ms) in ratios {
964 let mut guard = InputFairnessGuard::new();
965 let now = Instant::now();
966 if input_ms > 0 {
967 guard.event_processed(EventType::Input, Duration::from_millis(input_ms), now);
968 }
969 if resize_ms > 0 {
970 guard.event_processed(EventType::Resize, Duration::from_millis(resize_ms), now);
971 }
972 let j = guard.jain_index();
973 assert!(
974 (0.5..=1.0).contains(&j),
975 "Jain index out of bounds for ({input_ms}, {resize_ms}): {j}"
976 );
977 }
978 }
979
980 #[test]
981 fn intervention_reason_priority_order() {
982 let config = FairnessConfig {
984 input_priority_threshold: Duration::from_millis(20),
985 dominance_threshold: 2,
986 fairness_threshold: 0.9, enabled: true,
988 };
989 let mut guard = InputFairnessGuard::with_config(config);
990 let now = Instant::now();
991
992 guard.input_arrived(now);
995 guard.event_processed(EventType::Resize, Duration::from_millis(50), now);
997 guard.event_processed(EventType::Resize, Duration::from_millis(50), now);
998 guard.event_processed(EventType::Resize, Duration::from_millis(50), now);
999
1000 let later = now + Duration::from_millis(100);
1002 let d = guard.check_fairness(later);
1003
1004 assert_eq!(
1006 d.reason,
1007 InterventionReason::InputLatency,
1008 "InputLatency should have highest priority"
1009 );
1010 assert!(d.yield_to_input);
1011 }
1012
1013 #[test]
1014 fn resize_dominance_triggers_after_threshold() {
1015 let config = FairnessConfig {
1016 dominance_threshold: 3,
1017 fairness_threshold: 0.5,
1020 ..FairnessConfig::default()
1021 };
1022 let mut guard = InputFairnessGuard::with_config(config);
1023 let now = Instant::now();
1024
1025 guard.input_arrived(now);
1027
1028 guard.event_processed(EventType::Resize, Duration::from_millis(10), now);
1030 guard.event_processed(EventType::Resize, Duration::from_millis(10), now);
1031 let d = guard.check_fairness(now);
1032 assert_eq!(d.reason, InterventionReason::None);
1033
1034 guard.input_arrived(now);
1036
1037 guard.event_processed(EventType::Resize, Duration::from_millis(10), now);
1039 let d = guard.check_fairness(now);
1040 assert_eq!(d.reason, InterventionReason::ResizeDominance);
1041 assert!(d.yield_to_input);
1042 }
1043
1044 #[test]
1045 fn intervention_counts_track_each_reason() {
1046 let config = FairnessConfig {
1047 input_priority_threshold: Duration::from_millis(10),
1048 dominance_threshold: 2,
1049 fairness_threshold: 0.8,
1050 enabled: true,
1051 };
1052 let mut guard = InputFairnessGuard::with_config(config);
1053 let now = Instant::now();
1054
1055 guard.input_arrived(now);
1057 let later = now + Duration::from_millis(50);
1058 guard.check_fairness(later);
1059
1060 let counts = guard.intervention_counts();
1061 assert_eq!(counts.input_latency, 1);
1062 assert_eq!(counts.resize_dominance, 0);
1063 assert_eq!(counts.fairness_index, 0);
1064
1065 guard.input_arrived(now);
1067 guard.event_processed(EventType::Resize, Duration::from_millis(10), now);
1068 guard.event_processed(EventType::Resize, Duration::from_millis(10), now);
1069 guard.check_fairness(now);
1070
1071 let counts = guard.intervention_counts();
1072 assert_eq!(counts.resize_dominance, 1);
1073 }
1074
1075 #[test]
1076 fn fairness_stable_across_repeated_check_cycles() {
1077 let mut guard = InputFairnessGuard::new();
1078 let now = Instant::now();
1079
1080 for i in 0..50 {
1082 let t = now + Duration::from_millis(i * 16);
1083 guard.event_processed(EventType::Input, Duration::from_millis(5), t);
1084 guard.event_processed(EventType::Resize, Duration::from_millis(5), t);
1085 let d = guard.check_fairness(t);
1086
1087 assert!(!d.yield_to_input, "Unexpected intervention at cycle {i}");
1089 assert!(
1091 d.jain_index > 0.95,
1092 "Jain index degraded at cycle {i}: {}",
1093 d.jain_index
1094 );
1095 }
1096
1097 let stats = guard.stats();
1098 assert_eq!(stats.events_processed, 100);
1099 assert_eq!(stats.input_events, 50);
1100 assert_eq!(stats.resize_events, 50);
1101 assert_eq!(stats.total_interventions, 0);
1102 }
1103
1104 #[test]
1105 fn fairness_index_degrades_under_resize_flood() {
1106 let mut guard = InputFairnessGuard::new();
1107 let now = Instant::now();
1108
1109 guard.event_processed(EventType::Input, Duration::from_millis(5), now);
1111 for _ in 0..15 {
1112 guard.event_processed(EventType::Resize, Duration::from_millis(20), now);
1113 }
1114
1115 let j = guard.jain_index();
1116 assert!(
1119 j < 0.55,
1120 "Jain index should be low under resize flood, got {j}"
1121 );
1122 }
1123
1124 #[test]
1125 fn max_input_latency_tracked_across_checks() {
1126 let mut guard = InputFairnessGuard::new();
1127 let now = Instant::now();
1128
1129 guard.input_arrived(now);
1130 guard.check_fairness(now + Duration::from_millis(30));
1131
1132 guard.input_arrived(now + Duration::from_millis(50));
1133 guard.check_fairness(now + Duration::from_millis(100));
1134
1135 let stats = guard.stats();
1136 assert!(stats.max_input_latency >= Duration::from_millis(30));
1138 }
1139
1140 #[test]
1141 fn max_input_latency_ignores_recent_when_no_pending_input() {
1142 let mut guard = InputFairnessGuard::new();
1143 let now = Instant::now();
1144
1145 guard.input_arrived(now);
1146 guard.event_processed(EventType::Input, Duration::from_millis(1), now);
1147
1148 guard.check_fairness(now + Duration::from_millis(100));
1150 assert_eq!(guard.stats().max_input_latency, Duration::ZERO);
1151 }
1152
1153 #[test]
1154 fn sliding_window_evicts_oldest_entries() {
1155 let mut guard = InputFairnessGuard::new();
1156 let now = Instant::now();
1157
1158 for _ in 0..16 {
1161 guard.event_processed(EventType::Resize, Duration::from_millis(10), now);
1162 }
1163
1164 for _ in 0..16 {
1166 guard.event_processed(EventType::Input, Duration::from_millis(10), now);
1167 }
1168
1169 let j = guard.jain_index();
1172 assert!(
1174 j < 0.6,
1175 "After full eviction to input-only, Jain should be ~0.5, got {j}"
1176 );
1177 }
1178
1179 #[test]
1180 fn custom_config_thresholds_work() {
1181 let config = FairnessConfig {
1182 input_priority_threshold: Duration::from_millis(200),
1183 dominance_threshold: 10,
1184 fairness_threshold: 0.3,
1185 enabled: true,
1186 };
1187 let mut guard = InputFairnessGuard::with_config(config);
1188 let now = Instant::now();
1189
1190 guard.input_arrived(now);
1192 guard.event_processed(EventType::Resize, Duration::from_millis(50), now);
1193 guard.event_processed(EventType::Resize, Duration::from_millis(50), now);
1194 guard.event_processed(EventType::Resize, Duration::from_millis(50), now);
1195
1196 let later = now + Duration::from_millis(100);
1197 let d = guard.check_fairness(later);
1198 assert_eq!(d.reason, InterventionReason::None);
1199 assert!(!d.yield_to_input);
1200 }
1201}