Skip to main content

ftui_runtime/
input_fairness.rs

1//! Input Fairness Guard (bd-1rz0.17)
2//!
3//! Prevents resize scheduling from starving input/keyboard events by monitoring
4//! event latencies and intervening when fairness thresholds are violated.
5//!
6//! # Design Philosophy
7//!
8//! In a responsive TUI, keyboard input must feel instantaneous. Even during rapid
9//! resize sequences (e.g., user dragging terminal corner), keystrokes should be
10//! processed without noticeable delay. This module enforces that guarantee.
11//!
12//! # Mathematical Model
13//!
14//! ## Jain's Fairness Index
15//!
16//! We track fairness across event types using Jain's fairness index:
17//! ```text
18//! F(x₁..xₙ) = (Σxᵢ)² / (n × Σxᵢ²)
19//! ```
20//!
21//! When applied to processing time allocations:
22//! - F = 1.0: Perfect fairness (equal allocation)
23//! - F = 1/n: Maximal unfairness (all time to one type)
24//!
25//! We maintain `F ≥ fairness_threshold` (default 0.5 for two event types).
26//!
27//! ## Starvation Detection
28//!
29//! Input starvation is detected when:
30//! 1. Input latency exceeds `max_input_latency`, OR
31//! 2. Consecutive resize-dominated cycles exceed `dominance_threshold`
32//!
33//! ## Intervention
34//!
35//! When starvation is detected:
36//! 1. Force resize coalescer to yield (return `ApplyNow` instead of `ShowPlaceholder`)
37//! 2. Log the intervention with evidence
38//! 3. Reset dominance counter
39//!
40//! # Invariants
41//!
42//! 1. **Bounded Input Latency**: Input events are processed within `max_input_latency`
43//!    from their arrival time, guaranteed by intervention mechanism.
44//!
45//! 2. **Work Conservation**: The guard never blocks event processing; it only
46//!    changes priority ordering between event types.
47//!
48//! 3. **Monotonic Time**: All timestamps use `Instant` (monotonic) to prevent
49//!    clock drift from causing priority inversions.
50//!
51//! # Failure Modes
52//!
53//! | Condition | Behavior | Rationale |
54//! |-----------|----------|-----------|
55//! | Clock drift | Use monotonic `Instant` | Prevent priority inversion |
56//! | Resize storm | Force input processing | Bounded latency guarantee |
57//! | Input flood | Yield to BatchController | Not our concern; batch handles it |
58//! | Zero events | Return default (fair) | Safe default, no intervention |
59
60#![forbid(unsafe_code)]
61
62use std::collections::VecDeque;
63use std::time::{Duration, Instant};
64
65/// Default maximum input latency before intervention (50ms).
66const DEFAULT_MAX_INPUT_LATENCY_MS: u64 = 50;
67
68/// Default resize dominance threshold before intervention.
69const DEFAULT_DOMINANCE_THRESHOLD: u32 = 3;
70
71/// Default fairness threshold (Jain's index).
72const DEFAULT_FAIRNESS_THRESHOLD: f64 = 0.5;
73
74/// Sliding window size for fairness calculation.
75const FAIRNESS_WINDOW_SIZE: usize = 16;
76
77/// Event type for fairness classification.
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub enum EventType {
80    /// User input events (keyboard, mouse).
81    Input,
82    /// Terminal resize events.
83    Resize,
84    /// Timer tick events.
85    Tick,
86}
87
88/// Type alias for compatibility with program.rs
89pub type FairnessEventType = EventType;
90
91/// Configuration for input fairness.
92#[derive(Debug, Clone)]
93pub struct FairnessConfig {
94    /// Maximum latency for input events before they get priority.
95    pub input_priority_threshold: Duration,
96    /// Enable fairness scheduling.
97    pub enabled: bool,
98    /// Number of consecutive resize-dominated cycles before intervention.
99    pub dominance_threshold: u32,
100    /// Minimum Jain's fairness index to maintain.
101    pub fairness_threshold: f64,
102}
103
104impl Default for FairnessConfig {
105    fn default() -> Self {
106        Self {
107            input_priority_threshold: Duration::from_millis(DEFAULT_MAX_INPUT_LATENCY_MS),
108            enabled: true, // Enable by default for bd-1rz0.17
109            dominance_threshold: DEFAULT_DOMINANCE_THRESHOLD,
110            fairness_threshold: DEFAULT_FAIRNESS_THRESHOLD,
111        }
112    }
113}
114
115impl FairnessConfig {
116    /// Create config with fairness disabled.
117    pub fn disabled() -> Self {
118        Self {
119            enabled: false,
120            ..Default::default()
121        }
122    }
123
124    /// Create config with custom max input latency.
125    pub fn with_max_latency(mut self, latency: Duration) -> Self {
126        self.input_priority_threshold = latency;
127        self
128    }
129
130    /// Create config with custom dominance threshold.
131    pub fn with_dominance_threshold(mut self, threshold: u32) -> Self {
132        self.dominance_threshold = threshold;
133        self
134    }
135}
136
137/// Intervention reason for fairness.
138#[derive(Debug, Clone, Copy, PartialEq, Eq)]
139pub enum InterventionReason {
140    /// No intervention needed.
141    None,
142    /// Input latency exceeded threshold.
143    InputLatency,
144    /// Resize dominated too many consecutive cycles.
145    ResizeDominance,
146    /// Jain's fairness index dropped below threshold.
147    FairnessIndex,
148}
149
150impl InterventionReason {
151    /// Whether this reason requires intervention.
152    pub fn requires_intervention(&self) -> bool {
153        !matches!(self, InterventionReason::None)
154    }
155
156    /// Stable string representation for logs.
157    #[must_use]
158    pub const fn as_str(self) -> &'static str {
159        match self {
160            Self::None => "none",
161            Self::InputLatency => "input_latency",
162            Self::ResizeDominance => "resize_dominance",
163            Self::FairnessIndex => "fairness_index",
164        }
165    }
166}
167
168/// Fairness decision returned by the guard.
169#[derive(Debug, Clone)]
170pub struct FairnessDecision {
171    /// Whether to proceed with the event.
172    pub should_process: bool,
173    /// Pending input latency if any.
174    pub pending_input_latency: Option<Duration>,
175    /// Reason for the decision.
176    pub reason: InterventionReason,
177    /// Whether to yield to input processing.
178    pub yield_to_input: bool,
179    /// Jain fairness index (0.0-1.0).
180    pub jain_index: f64,
181}
182
183impl Default for FairnessDecision {
184    fn default() -> Self {
185        Self {
186            should_process: true,
187            pending_input_latency: None,
188            reason: InterventionReason::None,
189            yield_to_input: false,
190            jain_index: 1.0, // Perfect fairness when no events
191        }
192    }
193}
194
195/// Fairness log entry for telemetry.
196#[derive(Debug, Clone)]
197pub struct FairnessLogEntry {
198    /// Timestamp of the entry.
199    pub timestamp: Instant,
200    /// Event type processed.
201    pub event_type: EventType,
202    /// Duration of processing.
203    pub duration: Duration,
204}
205
206/// Statistics about fairness scheduling.
207#[derive(Debug, Clone, Default)]
208pub struct FairnessStats {
209    /// Total events processed.
210    pub events_processed: u64,
211    /// Input events processed.
212    pub input_events: u64,
213    /// Resize events processed.
214    pub resize_events: u64,
215    /// Tick events processed.
216    pub tick_events: u64,
217    /// Total fairness checks.
218    pub total_checks: u64,
219    /// Total interventions triggered.
220    pub total_interventions: u64,
221    /// Maximum observed input latency.
222    pub max_input_latency: Duration,
223}
224
225/// Counts of interventions by type.
226#[derive(Debug, Clone, Default)]
227pub struct InterventionCounts {
228    /// Input latency interventions.
229    pub input_latency: u64,
230    /// Resize dominance interventions.
231    pub resize_dominance: u64,
232    /// Fairness index interventions.
233    pub fairness_index: u64,
234}
235
236/// Record of an event processing cycle.
237#[derive(Debug, Clone)]
238struct ProcessingRecord {
239    /// Event type processed.
240    event_type: EventType,
241    /// Processing duration.
242    duration: Duration,
243}
244
245/// Guard for input fairness scheduling.
246///
247/// Monitors event processing fairness and triggers interventions when input
248/// events are at risk of starvation due to resize processing.
249#[derive(Debug)]
250pub struct InputFairnessGuard {
251    config: FairnessConfig,
252    stats: FairnessStats,
253    intervention_counts: InterventionCounts,
254
255    /// Time when an input event arrived but hasn't been fully processed.
256    pending_input_arrival: Option<Instant>,
257    /// Most recent input arrival since the last fairness check.
258    recent_input_arrival: Option<Instant>,
259
260    /// Number of consecutive resize-dominated cycles.
261    resize_dominance_count: u32,
262
263    /// Sliding window of processing records for fairness calculation.
264    processing_window: VecDeque<ProcessingRecord>,
265
266    /// Accumulated processing time by event type (for Jain's index).
267    input_time_us: u64,
268    resize_time_us: u64,
269}
270
271impl InputFairnessGuard {
272    /// Create a new fairness guard with default configuration.
273    pub fn new() -> Self {
274        Self::with_config(FairnessConfig::default())
275    }
276
277    /// Create a new fairness guard with the given configuration.
278    pub fn with_config(config: FairnessConfig) -> Self {
279        Self {
280            config,
281            stats: FairnessStats::default(),
282            intervention_counts: InterventionCounts::default(),
283            pending_input_arrival: None,
284            recent_input_arrival: None,
285            resize_dominance_count: 0,
286            processing_window: VecDeque::with_capacity(FAIRNESS_WINDOW_SIZE),
287            input_time_us: 0,
288            resize_time_us: 0,
289        }
290    }
291
292    /// Signal that an input event has arrived.
293    ///
294    /// Call this when an input event is received but before processing.
295    pub fn input_arrived(&mut self, now: Instant) {
296        if self.pending_input_arrival.is_none() {
297            self.pending_input_arrival = Some(now);
298        }
299        if self.recent_input_arrival.is_none() {
300            self.recent_input_arrival = Some(now);
301        }
302    }
303
304    /// Check fairness and return a decision.
305    ///
306    /// Call this before processing a resize event to check if input is starving.
307    pub fn check_fairness(&mut self, now: Instant) -> FairnessDecision {
308        self.stats.total_checks += 1;
309
310        // If disabled, return default (no intervention)
311        if !self.config.enabled {
312            self.recent_input_arrival = None;
313            return FairnessDecision::default();
314        }
315
316        // Calculate Jain's index for input vs resize
317        let jain = self.calculate_jain_index();
318
319        // Check pending input latency (including recent input seen this cycle).
320        let pending_latency = self
321            .recent_input_arrival
322            .or(self.pending_input_arrival)
323            .map(|t| now.duration_since(t));
324        if let Some(latency) = pending_latency
325            && latency > self.stats.max_input_latency
326        {
327            self.stats.max_input_latency = latency;
328        }
329
330        // Determine if intervention is needed
331        let reason = self.determine_intervention_reason(pending_latency, jain);
332        let yield_to_input = reason.requires_intervention();
333
334        if yield_to_input {
335            self.stats.total_interventions += 1;
336            match reason {
337                InterventionReason::InputLatency => {
338                    self.intervention_counts.input_latency += 1;
339                }
340                InterventionReason::ResizeDominance => {
341                    self.intervention_counts.resize_dominance += 1;
342                }
343                InterventionReason::FairnessIndex => {
344                    self.intervention_counts.fairness_index += 1;
345                }
346                InterventionReason::None => {}
347            }
348            // Reset dominance counter on intervention
349            self.resize_dominance_count = 0;
350        }
351
352        let decision = FairnessDecision {
353            should_process: !yield_to_input,
354            pending_input_latency: pending_latency,
355            reason,
356            yield_to_input,
357            jain_index: jain,
358        };
359
360        // Clear recent input marker after evaluating fairness.
361        self.recent_input_arrival = None;
362
363        decision
364    }
365
366    /// Record that an event was processed.
367    pub fn event_processed(&mut self, event_type: EventType, duration: Duration, _now: Instant) {
368        self.stats.events_processed += 1;
369        match event_type {
370            EventType::Input => self.stats.input_events += 1,
371            EventType::Resize => self.stats.resize_events += 1,
372            EventType::Tick => self.stats.tick_events += 1,
373        }
374
375        // Skip fairness tracking if disabled
376        if !self.config.enabled {
377            return;
378        }
379
380        // Record processing
381        let record = ProcessingRecord {
382            event_type,
383            duration,
384        };
385
386        // Update sliding window
387        if self.processing_window.len() >= FAIRNESS_WINDOW_SIZE
388            && let Some(old) = self.processing_window.pop_front()
389        {
390            match old.event_type {
391                EventType::Input => {
392                    self.input_time_us = self
393                        .input_time_us
394                        .saturating_sub(old.duration.as_micros() as u64);
395                }
396                EventType::Resize => {
397                    self.resize_time_us = self
398                        .resize_time_us
399                        .saturating_sub(old.duration.as_micros() as u64);
400                }
401                EventType::Tick => {}
402            }
403        }
404
405        // Add new record
406        match event_type {
407            EventType::Input => {
408                self.input_time_us += duration.as_micros() as u64;
409                self.pending_input_arrival = None;
410                self.resize_dominance_count = 0; // Reset dominance on input
411            }
412            EventType::Resize => {
413                self.resize_time_us += duration.as_micros() as u64;
414                self.resize_dominance_count += 1;
415            }
416            EventType::Tick => {}
417        }
418
419        self.processing_window.push_back(record);
420    }
421
422    /// Calculate Jain's fairness index for input vs resize processing time.
423    fn calculate_jain_index(&self) -> f64 {
424        // F(x,y) = (x + y)² / (2 × (x² + y²))
425        let x = self.input_time_us as f64;
426        let y = self.resize_time_us as f64;
427
428        if x == 0.0 && y == 0.0 {
429            return 1.0; // Perfect fairness when no events
430        }
431
432        let sum = x + y;
433        let sum_sq = x * x + y * y;
434
435        if sum_sq == 0.0 {
436            return 1.0;
437        }
438
439        (sum * sum) / (2.0 * sum_sq)
440    }
441
442    /// Determine if and why intervention is needed.
443    fn determine_intervention_reason(
444        &self,
445        pending_latency: Option<Duration>,
446        jain: f64,
447    ) -> InterventionReason {
448        // Priority 1: Latency threshold (most urgent)
449        if let Some(latency) = pending_latency
450            && latency >= self.config.input_priority_threshold
451        {
452            return InterventionReason::InputLatency;
453        }
454
455        // Priority 2: Resize dominance
456        if self.resize_dominance_count >= self.config.dominance_threshold {
457            return InterventionReason::ResizeDominance;
458        }
459
460        // Priority 3: Fairness index
461        if jain < self.config.fairness_threshold && pending_latency.is_some() {
462            return InterventionReason::FairnessIndex;
463        }
464
465        InterventionReason::None
466    }
467
468    /// Get current statistics.
469    pub fn stats(&self) -> &FairnessStats {
470        &self.stats
471    }
472
473    /// Get intervention counts.
474    pub fn intervention_counts(&self) -> &InterventionCounts {
475        &self.intervention_counts
476    }
477
478    /// Get current configuration.
479    pub fn config(&self) -> &FairnessConfig {
480        &self.config
481    }
482
483    /// Current resize dominance count.
484    pub fn resize_dominance_count(&self) -> u32 {
485        self.resize_dominance_count
486    }
487
488    /// Check if fairness is enabled.
489    pub fn is_enabled(&self) -> bool {
490        self.config.enabled
491    }
492
493    /// Get current Jain's fairness index.
494    pub fn jain_index(&self) -> f64 {
495        self.calculate_jain_index()
496    }
497
498    /// Check if there is pending input.
499    pub fn has_pending_input(&self) -> bool {
500        self.pending_input_arrival.is_some()
501    }
502
503    /// Reset the guard state (useful for testing).
504    pub fn reset(&mut self) {
505        self.pending_input_arrival = None;
506        self.recent_input_arrival = None;
507        self.resize_dominance_count = 0;
508        self.processing_window.clear();
509        self.input_time_us = 0;
510        self.resize_time_us = 0;
511        self.stats = FairnessStats::default();
512        self.intervention_counts = InterventionCounts::default();
513    }
514}
515
516impl Default for InputFairnessGuard {
517    fn default() -> Self {
518        Self::new()
519    }
520}
521
522#[cfg(test)]
523mod tests {
524    use super::*;
525
526    #[test]
527    fn default_config_is_enabled() {
528        let config = FairnessConfig::default();
529        assert!(config.enabled);
530    }
531
532    #[test]
533    fn disabled_config() {
534        let config = FairnessConfig::disabled();
535        assert!(!config.enabled);
536    }
537
538    #[test]
539    fn default_decision_allows_processing() {
540        let mut guard = InputFairnessGuard::default();
541        let decision = guard.check_fairness(Instant::now());
542        assert!(decision.should_process);
543    }
544
545    #[test]
546    fn event_processing_updates_stats() {
547        let mut guard = InputFairnessGuard::default();
548        let now = Instant::now();
549
550        guard.event_processed(EventType::Input, Duration::from_millis(10), now);
551        guard.event_processed(EventType::Resize, Duration::from_millis(5), now);
552        guard.event_processed(EventType::Tick, Duration::from_millis(1), now);
553
554        let stats = guard.stats();
555        assert_eq!(stats.events_processed, 3);
556        assert_eq!(stats.input_events, 1);
557        assert_eq!(stats.resize_events, 1);
558        assert_eq!(stats.tick_events, 1);
559    }
560
561    #[test]
562    fn test_jain_index_perfect_fairness() {
563        let mut guard = InputFairnessGuard::new();
564        let now = Instant::now();
565
566        // Equal time for input and resize
567        guard.event_processed(EventType::Input, Duration::from_millis(10), now);
568        guard.event_processed(EventType::Resize, Duration::from_millis(10), now);
569
570        let jain = guard.jain_index();
571        assert!((jain - 1.0).abs() < 0.001, "Expected ~1.0, got {}", jain);
572    }
573
574    #[test]
575    fn test_jain_index_unfair() {
576        let mut guard = InputFairnessGuard::new();
577        let now = Instant::now();
578
579        // Much more resize time than input
580        guard.event_processed(EventType::Input, Duration::from_millis(1), now);
581        guard.event_processed(EventType::Resize, Duration::from_millis(100), now);
582
583        let jain = guard.jain_index();
584        // F = (1+100)² / (2 × (1² + 100²)) = 10201 / 20002 ≈ 0.51
585        assert!(jain < 0.6, "Expected unfair index < 0.6, got {}", jain);
586    }
587
588    #[test]
589    fn test_jain_index_empty() {
590        let guard = InputFairnessGuard::new();
591        let jain = guard.jain_index();
592        assert!((jain - 1.0).abs() < 0.001, "Empty should be fair (1.0)");
593    }
594
595    #[test]
596    fn test_latency_threshold_intervention() {
597        let config = FairnessConfig::default().with_max_latency(Duration::from_millis(20));
598        let mut guard = InputFairnessGuard::with_config(config);
599
600        let start = Instant::now();
601        guard.input_arrived(start);
602
603        // Advance logical time beyond threshold (deterministic)
604        let decision = guard.check_fairness(start + Duration::from_millis(25));
605        assert!(decision.yield_to_input);
606        assert_eq!(decision.reason, InterventionReason::InputLatency);
607    }
608
609    #[test]
610    fn test_resize_dominance_intervention() {
611        let config = FairnessConfig::default().with_dominance_threshold(2);
612        let mut guard = InputFairnessGuard::with_config(config);
613        let now = Instant::now();
614
615        // Signal pending input
616        guard.input_arrived(now);
617
618        // Process resize events (dominance)
619        guard.event_processed(EventType::Resize, Duration::from_millis(5), now);
620        guard.event_processed(EventType::Resize, Duration::from_millis(5), now);
621
622        let decision = guard.check_fairness(now);
623        assert!(decision.yield_to_input);
624        assert_eq!(decision.reason, InterventionReason::ResizeDominance);
625    }
626
627    #[test]
628    fn test_no_intervention_when_fair() {
629        let mut guard = InputFairnessGuard::new();
630        let now = Instant::now();
631
632        // Balanced processing
633        guard.event_processed(EventType::Input, Duration::from_millis(10), now);
634        guard.event_processed(EventType::Resize, Duration::from_millis(10), now);
635
636        let decision = guard.check_fairness(now);
637        assert!(!decision.yield_to_input);
638        assert_eq!(decision.reason, InterventionReason::None);
639    }
640
641    #[test]
642    fn test_fairness_index_intervention() {
643        let config = FairnessConfig {
644            input_priority_threshold: Duration::from_secs(10),
645            dominance_threshold: 100,
646            fairness_threshold: 0.9,
647            ..Default::default()
648        };
649        let mut guard = InputFairnessGuard::with_config(config);
650        let now = Instant::now();
651
652        guard.input_arrived(now);
653        guard.event_processed(EventType::Input, Duration::from_millis(1), now);
654        guard.event_processed(EventType::Resize, Duration::from_millis(100), now);
655
656        let decision = guard.check_fairness(now + Duration::from_millis(1));
657        assert!(decision.yield_to_input);
658        assert_eq!(decision.reason, InterventionReason::FairnessIndex);
659    }
660
661    #[test]
662    fn test_dominance_reset_on_input() {
663        let mut guard = InputFairnessGuard::new();
664        let now = Instant::now();
665
666        // Build up resize dominance
667        guard.event_processed(EventType::Resize, Duration::from_millis(5), now);
668        guard.event_processed(EventType::Resize, Duration::from_millis(5), now);
669        assert_eq!(guard.resize_dominance_count, 2);
670
671        // Process input - should reset
672        guard.event_processed(EventType::Input, Duration::from_millis(5), now);
673        assert_eq!(guard.resize_dominance_count, 0);
674    }
675
676    #[test]
677    fn test_pending_input_cleared_on_processing() {
678        let mut guard = InputFairnessGuard::new();
679        let now = Instant::now();
680
681        guard.input_arrived(now);
682        assert!(guard.has_pending_input());
683
684        guard.event_processed(EventType::Input, Duration::from_millis(5), now);
685        assert!(!guard.has_pending_input());
686    }
687
688    #[test]
689    fn test_stats_tracking() {
690        let mut guard = InputFairnessGuard::new();
691        let now = Instant::now();
692
693        // Perform some checks
694        guard.check_fairness(now);
695        guard.check_fairness(now);
696
697        assert_eq!(guard.stats().total_checks, 2);
698    }
699
700    #[test]
701    fn test_sliding_window_eviction() {
702        let mut guard = InputFairnessGuard::new();
703        let now = Instant::now();
704
705        // Fill window beyond capacity
706        for _ in 0..(FAIRNESS_WINDOW_SIZE + 5) {
707            guard.event_processed(EventType::Input, Duration::from_millis(1), now);
708        }
709
710        assert_eq!(guard.processing_window.len(), FAIRNESS_WINDOW_SIZE);
711    }
712
713    #[test]
714    fn test_reset() {
715        let mut guard = InputFairnessGuard::new();
716        let now = Instant::now();
717
718        guard.input_arrived(now);
719        guard.event_processed(EventType::Resize, Duration::from_millis(10), now);
720        guard.check_fairness(now);
721
722        guard.reset();
723
724        assert!(!guard.has_pending_input());
725        assert_eq!(guard.resize_dominance_count, 0);
726        assert_eq!(guard.stats().total_checks, 0);
727        assert!(guard.processing_window.is_empty());
728    }
729
730    // Property tests for core invariants
731
732    #[test]
733    fn test_invariant_jain_index_bounds() {
734        // Jain's index is always in [0.5, 1.0] for two event types
735        let mut guard = InputFairnessGuard::new();
736        let now = Instant::now();
737
738        // Test various ratios
739        for (input_ms, resize_ms) in [(1, 1), (1, 100), (100, 1), (50, 50), (0, 100), (100, 0)] {
740            guard.reset();
741            if input_ms > 0 {
742                guard.event_processed(EventType::Input, Duration::from_millis(input_ms), now);
743            }
744            if resize_ms > 0 {
745                guard.event_processed(EventType::Resize, Duration::from_millis(resize_ms), now);
746            }
747
748            let jain = guard.jain_index();
749            assert!(
750                (0.5..=1.0).contains(&jain),
751                "Jain index {} out of bounds for input={}, resize={}",
752                jain,
753                input_ms,
754                resize_ms
755            );
756        }
757    }
758
759    #[test]
760    fn test_invariant_intervention_resets_dominance() {
761        let config = FairnessConfig::default().with_dominance_threshold(2);
762        let mut guard = InputFairnessGuard::with_config(config);
763        let now = Instant::now();
764
765        // Build dominance
766        guard.input_arrived(now);
767        guard.event_processed(EventType::Resize, Duration::from_millis(5), now);
768        guard.event_processed(EventType::Resize, Duration::from_millis(5), now);
769
770        // Intervention should reset
771        let decision = guard.check_fairness(now);
772        assert!(decision.yield_to_input);
773        assert_eq!(guard.resize_dominance_count, 0);
774    }
775
776    #[test]
777    fn test_invariant_monotonic_stats() {
778        let mut guard = InputFairnessGuard::new();
779        let now = Instant::now();
780
781        let mut prev_checks = 0u64;
782        for _ in 0..10 {
783            guard.check_fairness(now);
784            assert!(guard.stats().total_checks > prev_checks);
785            prev_checks = guard.stats().total_checks;
786        }
787    }
788
789    #[test]
790    fn test_disabled_returns_no_intervention() {
791        let config = FairnessConfig::disabled();
792        let mut guard = InputFairnessGuard::with_config(config);
793        let now = Instant::now();
794
795        // Even with pending input, disabled guard should not intervene
796        guard.input_arrived(now);
797        guard.event_processed(EventType::Resize, Duration::from_millis(100), now);
798        guard.event_processed(EventType::Resize, Duration::from_millis(100), now);
799        guard.event_processed(EventType::Resize, Duration::from_millis(100), now);
800
801        let decision = guard.check_fairness(now);
802        assert!(!decision.yield_to_input);
803        assert_eq!(decision.reason, InterventionReason::None);
804    }
805}