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 web_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    #[must_use]
126    pub fn with_max_latency(mut self, latency: Duration) -> Self {
127        self.input_priority_threshold = latency;
128        self
129    }
130
131    /// Create config with custom dominance threshold.
132    #[must_use]
133    pub fn with_dominance_threshold(mut self, threshold: u32) -> Self {
134        self.dominance_threshold = threshold;
135        self
136    }
137}
138
139/// Intervention reason for fairness.
140#[derive(Debug, Clone, Copy, PartialEq, Eq)]
141pub enum InterventionReason {
142    /// No intervention needed.
143    None,
144    /// Input latency exceeded threshold.
145    InputLatency,
146    /// Resize dominated too many consecutive cycles.
147    ResizeDominance,
148    /// Jain's fairness index dropped below threshold.
149    FairnessIndex,
150}
151
152impl InterventionReason {
153    /// Whether this reason requires intervention.
154    pub fn requires_intervention(&self) -> bool {
155        !matches!(self, InterventionReason::None)
156    }
157
158    /// Stable string representation for logs.
159    #[must_use]
160    pub const fn as_str(self) -> &'static str {
161        match self {
162            Self::None => "none",
163            Self::InputLatency => "input_latency",
164            Self::ResizeDominance => "resize_dominance",
165            Self::FairnessIndex => "fairness_index",
166        }
167    }
168}
169
170/// Fairness decision returned by the guard.
171#[derive(Debug, Clone)]
172pub struct FairnessDecision {
173    /// Whether to proceed with the event.
174    pub should_process: bool,
175    /// Pending input latency if any.
176    pub pending_input_latency: Option<Duration>,
177    /// Reason for the decision.
178    pub reason: InterventionReason,
179    /// Whether to yield to input processing.
180    pub yield_to_input: bool,
181    /// Jain fairness index (0.0-1.0).
182    pub jain_index: f64,
183}
184
185impl Default for FairnessDecision {
186    fn default() -> Self {
187        Self {
188            should_process: true,
189            pending_input_latency: None,
190            reason: InterventionReason::None,
191            yield_to_input: false,
192            jain_index: 1.0, // Perfect fairness when no events
193        }
194    }
195}
196
197/// Fairness log entry for telemetry.
198#[derive(Debug, Clone)]
199pub struct FairnessLogEntry {
200    /// Timestamp of the entry.
201    pub timestamp: Instant,
202    /// Event type processed.
203    pub event_type: EventType,
204    /// Duration of processing.
205    pub duration: Duration,
206}
207
208/// Statistics about fairness scheduling.
209#[derive(Debug, Clone, Default)]
210pub struct FairnessStats {
211    /// Total events processed.
212    pub events_processed: u64,
213    /// Input events processed.
214    pub input_events: u64,
215    /// Resize events processed.
216    pub resize_events: u64,
217    /// Tick events processed.
218    pub tick_events: u64,
219    /// Total fairness checks.
220    pub total_checks: u64,
221    /// Total interventions triggered.
222    pub total_interventions: u64,
223    /// Maximum observed input latency.
224    pub max_input_latency: Duration,
225}
226
227/// Counts of interventions by type.
228#[derive(Debug, Clone, Default)]
229pub struct InterventionCounts {
230    /// Input latency interventions.
231    pub input_latency: u64,
232    /// Resize dominance interventions.
233    pub resize_dominance: u64,
234    /// Fairness index interventions.
235    pub fairness_index: u64,
236}
237
238/// Record of an event processing cycle.
239#[derive(Debug, Clone)]
240struct ProcessingRecord {
241    /// Event type processed.
242    event_type: EventType,
243    /// Processing duration.
244    duration: Duration,
245}
246
247/// Guard for input fairness scheduling.
248///
249/// Monitors event processing fairness and triggers interventions when input
250/// events are at risk of starvation due to resize processing.
251#[derive(Debug)]
252pub struct InputFairnessGuard {
253    config: FairnessConfig,
254    stats: FairnessStats,
255    intervention_counts: InterventionCounts,
256
257    /// Time when an input event arrived but hasn't been fully processed.
258    pending_input_arrival: Option<Instant>,
259    /// Most recent input arrival since the last fairness check.
260    recent_input_arrival: Option<Instant>,
261
262    /// Number of consecutive resize-dominated cycles.
263    resize_dominance_count: u32,
264
265    /// Sliding window of processing records for fairness calculation.
266    processing_window: VecDeque<ProcessingRecord>,
267
268    /// Accumulated processing time by event type (for Jain's index).
269    input_time_us: u64,
270    resize_time_us: u64,
271}
272
273impl InputFairnessGuard {
274    /// Create a new fairness guard with default configuration.
275    pub fn new() -> Self {
276        Self::with_config(FairnessConfig::default())
277    }
278
279    /// Create a new fairness guard with the given configuration.
280    pub fn with_config(config: FairnessConfig) -> Self {
281        Self {
282            config,
283            stats: FairnessStats::default(),
284            intervention_counts: InterventionCounts::default(),
285            pending_input_arrival: None,
286            recent_input_arrival: None,
287            resize_dominance_count: 0,
288            processing_window: VecDeque::with_capacity(FAIRNESS_WINDOW_SIZE),
289            input_time_us: 0,
290            resize_time_us: 0,
291        }
292    }
293
294    /// Signal that an input event has arrived.
295    ///
296    /// Call this when an input event is received but before processing.
297    pub fn input_arrived(&mut self, now: Instant) {
298        if self.pending_input_arrival.is_none() {
299            self.pending_input_arrival = Some(now);
300        }
301        if self.recent_input_arrival.is_none() {
302            self.recent_input_arrival = Some(now);
303        }
304    }
305
306    /// Check fairness and return a decision.
307    ///
308    /// Call this before processing a resize event to check if input is starving.
309    pub fn check_fairness(&mut self, now: Instant) -> FairnessDecision {
310        self.stats.total_checks += 1;
311
312        // If disabled, return default (no intervention)
313        if !self.config.enabled {
314            self.recent_input_arrival = None;
315            return FairnessDecision::default();
316        }
317
318        // Calculate Jain's index for input vs resize
319        let jain = self.calculate_jain_index();
320
321        // Check pending input latency (including recent input seen this cycle).
322        let pending_latency = self
323            .recent_input_arrival
324            .or(self.pending_input_arrival)
325            .map(|t| now.duration_since(t));
326        if let Some(latency) = pending_latency
327            && latency > self.stats.max_input_latency
328        {
329            self.stats.max_input_latency = latency;
330        }
331
332        // Determine if intervention is needed
333        let reason = self.determine_intervention_reason(pending_latency, jain);
334        let yield_to_input = reason.requires_intervention();
335
336        if yield_to_input {
337            self.stats.total_interventions += 1;
338            match reason {
339                InterventionReason::InputLatency => {
340                    self.intervention_counts.input_latency += 1;
341                }
342                InterventionReason::ResizeDominance => {
343                    self.intervention_counts.resize_dominance += 1;
344                }
345                InterventionReason::FairnessIndex => {
346                    self.intervention_counts.fairness_index += 1;
347                }
348                InterventionReason::None => {}
349            }
350            // Reset dominance counter on intervention
351            self.resize_dominance_count = 0;
352        }
353
354        let decision = FairnessDecision {
355            should_process: !yield_to_input,
356            pending_input_latency: pending_latency,
357            reason,
358            yield_to_input,
359            jain_index: jain,
360        };
361
362        // Clear recent input marker after evaluating fairness.
363        self.recent_input_arrival = None;
364
365        decision
366    }
367
368    /// Record that an event was processed.
369    pub fn event_processed(&mut self, event_type: EventType, duration: Duration, _now: Instant) {
370        self.stats.events_processed += 1;
371        match event_type {
372            EventType::Input => self.stats.input_events += 1,
373            EventType::Resize => self.stats.resize_events += 1,
374            EventType::Tick => self.stats.tick_events += 1,
375        }
376
377        // Skip fairness tracking if disabled
378        if !self.config.enabled {
379            return;
380        }
381
382        // Record processing
383        let record = ProcessingRecord {
384            event_type,
385            duration,
386        };
387
388        // Update sliding window
389        if self.processing_window.len() >= FAIRNESS_WINDOW_SIZE
390            && let Some(old) = self.processing_window.pop_front()
391        {
392            match old.event_type {
393                EventType::Input => {
394                    self.input_time_us = self
395                        .input_time_us
396                        .saturating_sub(old.duration.as_micros() as u64);
397                }
398                EventType::Resize => {
399                    self.resize_time_us = self
400                        .resize_time_us
401                        .saturating_sub(old.duration.as_micros() as u64);
402                }
403                EventType::Tick => {}
404            }
405        }
406
407        // Add new record
408        match event_type {
409            EventType::Input => {
410                self.input_time_us += duration.as_micros() as u64;
411                self.pending_input_arrival = None;
412                self.resize_dominance_count = 0; // Reset dominance on input
413            }
414            EventType::Resize => {
415                self.resize_time_us += duration.as_micros() as u64;
416                self.resize_dominance_count += 1;
417            }
418            EventType::Tick => {}
419        }
420
421        self.processing_window.push_back(record);
422    }
423
424    /// Calculate Jain's fairness index for input vs resize processing time.
425    fn calculate_jain_index(&self) -> f64 {
426        // F(x,y) = (x + y)² / (2 × (x² + y²))
427        let x = self.input_time_us as f64;
428        let y = self.resize_time_us as f64;
429
430        if x == 0.0 && y == 0.0 {
431            return 1.0; // Perfect fairness when no events
432        }
433
434        let sum = x + y;
435        let sum_sq = x * x + y * y;
436
437        if sum_sq == 0.0 {
438            return 1.0;
439        }
440
441        (sum * sum) / (2.0 * sum_sq)
442    }
443
444    /// Determine if and why intervention is needed.
445    fn determine_intervention_reason(
446        &self,
447        pending_latency: Option<Duration>,
448        jain: f64,
449    ) -> InterventionReason {
450        // Priority 1: Latency threshold (most urgent)
451        if let Some(latency) = pending_latency
452            && latency >= self.config.input_priority_threshold
453        {
454            return InterventionReason::InputLatency;
455        }
456
457        // Priority 2: Resize dominance
458        if self.resize_dominance_count >= self.config.dominance_threshold {
459            return InterventionReason::ResizeDominance;
460        }
461
462        // Priority 3: Fairness index
463        if jain < self.config.fairness_threshold && pending_latency.is_some() {
464            return InterventionReason::FairnessIndex;
465        }
466
467        InterventionReason::None
468    }
469
470    /// Get current statistics.
471    pub fn stats(&self) -> &FairnessStats {
472        &self.stats
473    }
474
475    /// Get intervention counts.
476    pub fn intervention_counts(&self) -> &InterventionCounts {
477        &self.intervention_counts
478    }
479
480    /// Get current configuration.
481    pub fn config(&self) -> &FairnessConfig {
482        &self.config
483    }
484
485    /// Current resize dominance count.
486    pub fn resize_dominance_count(&self) -> u32 {
487        self.resize_dominance_count
488    }
489
490    /// Check if fairness is enabled.
491    pub fn is_enabled(&self) -> bool {
492        self.config.enabled
493    }
494
495    /// Get current Jain's fairness index.
496    pub fn jain_index(&self) -> f64 {
497        self.calculate_jain_index()
498    }
499
500    /// Check if there is pending input.
501    pub fn has_pending_input(&self) -> bool {
502        self.pending_input_arrival.is_some()
503    }
504
505    /// Reset the guard state (useful for testing).
506    pub fn reset(&mut self) {
507        self.pending_input_arrival = None;
508        self.recent_input_arrival = None;
509        self.resize_dominance_count = 0;
510        self.processing_window.clear();
511        self.input_time_us = 0;
512        self.resize_time_us = 0;
513        self.stats = FairnessStats::default();
514        self.intervention_counts = InterventionCounts::default();
515    }
516}
517
518impl Default for InputFairnessGuard {
519    fn default() -> Self {
520        Self::new()
521    }
522}
523
524#[cfg(test)]
525mod tests {
526    use super::*;
527
528    #[test]
529    fn default_config_is_enabled() {
530        let config = FairnessConfig::default();
531        assert!(config.enabled);
532    }
533
534    #[test]
535    fn disabled_config() {
536        let config = FairnessConfig::disabled();
537        assert!(!config.enabled);
538    }
539
540    #[test]
541    fn default_decision_allows_processing() {
542        let mut guard = InputFairnessGuard::default();
543        let decision = guard.check_fairness(Instant::now());
544        assert!(decision.should_process);
545    }
546
547    #[test]
548    fn event_processing_updates_stats() {
549        let mut guard = InputFairnessGuard::default();
550        let now = Instant::now();
551
552        guard.event_processed(EventType::Input, Duration::from_millis(10), now);
553        guard.event_processed(EventType::Resize, Duration::from_millis(5), now);
554        guard.event_processed(EventType::Tick, Duration::from_millis(1), now);
555
556        let stats = guard.stats();
557        assert_eq!(stats.events_processed, 3);
558        assert_eq!(stats.input_events, 1);
559        assert_eq!(stats.resize_events, 1);
560        assert_eq!(stats.tick_events, 1);
561    }
562
563    #[test]
564    fn test_jain_index_perfect_fairness() {
565        let mut guard = InputFairnessGuard::new();
566        let now = Instant::now();
567
568        // Equal time for input and resize
569        guard.event_processed(EventType::Input, Duration::from_millis(10), now);
570        guard.event_processed(EventType::Resize, Duration::from_millis(10), now);
571
572        let jain = guard.jain_index();
573        assert!((jain - 1.0).abs() < 0.001, "Expected ~1.0, got {}", jain);
574    }
575
576    #[test]
577    fn test_jain_index_unfair() {
578        let mut guard = InputFairnessGuard::new();
579        let now = Instant::now();
580
581        // Much more resize time than input
582        guard.event_processed(EventType::Input, Duration::from_millis(1), now);
583        guard.event_processed(EventType::Resize, Duration::from_millis(100), now);
584
585        let jain = guard.jain_index();
586        // F = (1+100)² / (2 × (1² + 100²)) = 10201 / 20002 ≈ 0.51
587        assert!(jain < 0.6, "Expected unfair index < 0.6, got {}", jain);
588    }
589
590    #[test]
591    fn test_jain_index_empty() {
592        let guard = InputFairnessGuard::new();
593        let jain = guard.jain_index();
594        assert!((jain - 1.0).abs() < 0.001, "Empty should be fair (1.0)");
595    }
596
597    #[test]
598    fn test_latency_threshold_intervention() {
599        let config = FairnessConfig::default().with_max_latency(Duration::from_millis(20));
600        let mut guard = InputFairnessGuard::with_config(config);
601
602        let start = Instant::now();
603        guard.input_arrived(start);
604
605        // Advance logical time beyond threshold (deterministic)
606        let decision = guard.check_fairness(start + Duration::from_millis(25));
607        assert!(decision.yield_to_input);
608        assert_eq!(decision.reason, InterventionReason::InputLatency);
609    }
610
611    #[test]
612    fn test_resize_dominance_intervention() {
613        let config = FairnessConfig::default().with_dominance_threshold(2);
614        let mut guard = InputFairnessGuard::with_config(config);
615        let now = Instant::now();
616
617        // Signal pending input
618        guard.input_arrived(now);
619
620        // Process resize events (dominance)
621        guard.event_processed(EventType::Resize, Duration::from_millis(5), now);
622        guard.event_processed(EventType::Resize, Duration::from_millis(5), now);
623
624        let decision = guard.check_fairness(now);
625        assert!(decision.yield_to_input);
626        assert_eq!(decision.reason, InterventionReason::ResizeDominance);
627    }
628
629    #[test]
630    fn test_no_intervention_when_fair() {
631        let mut guard = InputFairnessGuard::new();
632        let now = Instant::now();
633
634        // Balanced processing
635        guard.event_processed(EventType::Input, Duration::from_millis(10), now);
636        guard.event_processed(EventType::Resize, Duration::from_millis(10), now);
637
638        let decision = guard.check_fairness(now);
639        assert!(!decision.yield_to_input);
640        assert_eq!(decision.reason, InterventionReason::None);
641    }
642
643    #[test]
644    fn test_fairness_index_intervention() {
645        let config = FairnessConfig {
646            input_priority_threshold: Duration::from_secs(10),
647            dominance_threshold: 100,
648            fairness_threshold: 0.9,
649            ..Default::default()
650        };
651        let mut guard = InputFairnessGuard::with_config(config);
652        let now = Instant::now();
653
654        guard.input_arrived(now);
655        guard.event_processed(EventType::Input, Duration::from_millis(1), now);
656        guard.event_processed(EventType::Resize, Duration::from_millis(100), now);
657
658        let decision = guard.check_fairness(now + Duration::from_millis(1));
659        assert!(decision.yield_to_input);
660        assert_eq!(decision.reason, InterventionReason::FairnessIndex);
661    }
662
663    #[test]
664    fn test_dominance_reset_on_input() {
665        let mut guard = InputFairnessGuard::new();
666        let now = Instant::now();
667
668        // Build up resize dominance
669        guard.event_processed(EventType::Resize, Duration::from_millis(5), now);
670        guard.event_processed(EventType::Resize, Duration::from_millis(5), now);
671        assert_eq!(guard.resize_dominance_count, 2);
672
673        // Process input - should reset
674        guard.event_processed(EventType::Input, Duration::from_millis(5), now);
675        assert_eq!(guard.resize_dominance_count, 0);
676    }
677
678    #[test]
679    fn test_pending_input_cleared_on_processing() {
680        let mut guard = InputFairnessGuard::new();
681        let now = Instant::now();
682
683        guard.input_arrived(now);
684        assert!(guard.has_pending_input());
685
686        guard.event_processed(EventType::Input, Duration::from_millis(5), now);
687        assert!(!guard.has_pending_input());
688    }
689
690    #[test]
691    fn test_stats_tracking() {
692        let mut guard = InputFairnessGuard::new();
693        let now = Instant::now();
694
695        // Perform some checks
696        guard.check_fairness(now);
697        guard.check_fairness(now);
698
699        assert_eq!(guard.stats().total_checks, 2);
700    }
701
702    #[test]
703    fn test_sliding_window_eviction() {
704        let mut guard = InputFairnessGuard::new();
705        let now = Instant::now();
706
707        // Fill window beyond capacity
708        for _ in 0..(FAIRNESS_WINDOW_SIZE + 5) {
709            guard.event_processed(EventType::Input, Duration::from_millis(1), now);
710        }
711
712        assert_eq!(guard.processing_window.len(), FAIRNESS_WINDOW_SIZE);
713    }
714
715    #[test]
716    fn test_reset() {
717        let mut guard = InputFairnessGuard::new();
718        let now = Instant::now();
719
720        guard.input_arrived(now);
721        guard.event_processed(EventType::Resize, Duration::from_millis(10), now);
722        guard.check_fairness(now);
723
724        guard.reset();
725
726        assert!(!guard.has_pending_input());
727        assert_eq!(guard.resize_dominance_count, 0);
728        assert_eq!(guard.stats().total_checks, 0);
729        assert!(guard.processing_window.is_empty());
730    }
731
732    // Property tests for core invariants
733
734    #[test]
735    fn test_invariant_jain_index_bounds() {
736        // Jain's index is always in [0.5, 1.0] for two event types
737        let mut guard = InputFairnessGuard::new();
738        let now = Instant::now();
739
740        // Test various ratios
741        for (input_ms, resize_ms) in [(1, 1), (1, 100), (100, 1), (50, 50), (0, 100), (100, 0)] {
742            guard.reset();
743            if input_ms > 0 {
744                guard.event_processed(EventType::Input, Duration::from_millis(input_ms), now);
745            }
746            if resize_ms > 0 {
747                guard.event_processed(EventType::Resize, Duration::from_millis(resize_ms), now);
748            }
749
750            let jain = guard.jain_index();
751            assert!(
752                (0.5..=1.0).contains(&jain),
753                "Jain index {} out of bounds for input={}, resize={}",
754                jain,
755                input_ms,
756                resize_ms
757            );
758        }
759    }
760
761    #[test]
762    fn test_invariant_intervention_resets_dominance() {
763        let config = FairnessConfig::default().with_dominance_threshold(2);
764        let mut guard = InputFairnessGuard::with_config(config);
765        let now = Instant::now();
766
767        // Build dominance
768        guard.input_arrived(now);
769        guard.event_processed(EventType::Resize, Duration::from_millis(5), now);
770        guard.event_processed(EventType::Resize, Duration::from_millis(5), now);
771
772        // Intervention should reset
773        let decision = guard.check_fairness(now);
774        assert!(decision.yield_to_input);
775        assert_eq!(guard.resize_dominance_count, 0);
776    }
777
778    #[test]
779    fn test_invariant_monotonic_stats() {
780        let mut guard = InputFairnessGuard::new();
781        let now = Instant::now();
782
783        let mut prev_checks = 0u64;
784        for _ in 0..10 {
785            guard.check_fairness(now);
786            assert!(guard.stats().total_checks > prev_checks);
787            prev_checks = guard.stats().total_checks;
788        }
789    }
790
791    #[test]
792    fn test_disabled_returns_no_intervention() {
793        let config = FairnessConfig::disabled();
794        let mut guard = InputFairnessGuard::with_config(config);
795        let now = Instant::now();
796
797        // Even with pending input, disabled guard should not intervene
798        guard.input_arrived(now);
799        guard.event_processed(EventType::Resize, Duration::from_millis(100), now);
800        guard.event_processed(EventType::Resize, Duration::from_millis(100), now);
801        guard.event_processed(EventType::Resize, Duration::from_millis(100), now);
802
803        let decision = guard.check_fairness(now);
804        assert!(!decision.yield_to_input);
805        assert_eq!(decision.reason, InterventionReason::None);
806    }
807
808    // =========================================================================
809    // Fairness guard + resize scheduling integration tests (bd-plwf)
810    // =========================================================================
811
812    #[test]
813    fn fairness_decision_fields_match_state() {
814        let mut guard = InputFairnessGuard::new();
815        let now = Instant::now();
816
817        // No pending input → decision has no pending latency
818        let d = guard.check_fairness(now);
819        assert!(d.pending_input_latency.is_none());
820        assert_eq!(d.reason, InterventionReason::None);
821        assert!(!d.yield_to_input);
822        assert!(d.should_process);
823        assert!((d.jain_index - 1.0).abs() < f64::EPSILON);
824
825        // Signal input, then check fairness later
826        guard.input_arrived(now);
827        let later = now + Duration::from_millis(10);
828        let d = guard.check_fairness(later);
829        assert!(d.pending_input_latency.is_some());
830        let lat = d.pending_input_latency.unwrap();
831        assert!(lat >= Duration::from_millis(10));
832    }
833
834    #[test]
835    fn jain_index_exact_values() {
836        let mut guard = InputFairnessGuard::new();
837        let now = Instant::now();
838
839        // Equal allocation: x = y = 100ms → F = 1.0
840        guard.event_processed(EventType::Input, Duration::from_millis(100), now);
841        guard.event_processed(EventType::Resize, Duration::from_millis(100), now);
842        let j = guard.jain_index();
843        assert!(
844            (j - 1.0).abs() < 1e-9,
845            "Equal allocation should yield 1.0, got {j}"
846        );
847
848        guard.reset();
849
850        // Extreme imbalance: x = 1ms, y = 100ms
851        // F = (1 + 100)^2 / (2 * (1 + 10000)) = 10201 / 20002 ≈ 0.51
852        guard.event_processed(EventType::Input, Duration::from_millis(1), now);
853        guard.event_processed(EventType::Resize, Duration::from_millis(100), now);
854        let j = guard.jain_index();
855        assert!(j > 0.5, "F should be > 0.5 for two types, got {j}");
856        assert!(j < 0.6, "F should be < 0.6 for 1:100 ratio, got {j}");
857    }
858
859    #[test]
860    fn jain_index_bounded_across_ratios() {
861        // Jain's index with n=2 types is always in [0.5, 1.0].
862        let ratios: &[(u64, u64)] = &[
863            (0, 0),
864            (1, 0),
865            (0, 1),
866            (1, 1),
867            (1, 1000),
868            (1000, 1),
869            (50, 50),
870            (100, 1),
871            (999, 1),
872        ];
873        for &(input_ms, resize_ms) in ratios {
874            let mut guard = InputFairnessGuard::new();
875            let now = Instant::now();
876            if input_ms > 0 {
877                guard.event_processed(EventType::Input, Duration::from_millis(input_ms), now);
878            }
879            if resize_ms > 0 {
880                guard.event_processed(EventType::Resize, Duration::from_millis(resize_ms), now);
881            }
882            let j = guard.jain_index();
883            assert!(
884                (0.5..=1.0).contains(&j),
885                "Jain index out of bounds for ({input_ms}, {resize_ms}): {j}"
886            );
887        }
888    }
889
890    #[test]
891    fn intervention_reason_priority_order() {
892        // InputLatency > ResizeDominance > FairnessIndex
893        let config = FairnessConfig {
894            input_priority_threshold: Duration::from_millis(20),
895            dominance_threshold: 2,
896            fairness_threshold: 0.9, // High threshold to easily trigger
897            enabled: true,
898        };
899        let mut guard = InputFairnessGuard::with_config(config);
900        let now = Instant::now();
901
902        // Set up conditions for all three reasons:
903        // 1. Pending input with high latency
904        guard.input_arrived(now);
905        // 2. Resize dominance (3 consecutive resizes)
906        guard.event_processed(EventType::Resize, Duration::from_millis(50), now);
907        guard.event_processed(EventType::Resize, Duration::from_millis(50), now);
908        guard.event_processed(EventType::Resize, Duration::from_millis(50), now);
909
910        // Check fairness well past latency threshold
911        let later = now + Duration::from_millis(100);
912        let d = guard.check_fairness(later);
913
914        // InputLatency should win (highest priority)
915        assert_eq!(
916            d.reason,
917            InterventionReason::InputLatency,
918            "InputLatency should have highest priority"
919        );
920        assert!(d.yield_to_input);
921    }
922
923    #[test]
924    fn resize_dominance_triggers_after_threshold() {
925        let config = FairnessConfig {
926            dominance_threshold: 3,
927            ..FairnessConfig::default()
928        };
929        let mut guard = InputFairnessGuard::with_config(config);
930        let now = Instant::now();
931
932        // Need pending input for dominance to matter
933        guard.input_arrived(now);
934
935        // 2 resizes → no intervention
936        guard.event_processed(EventType::Resize, Duration::from_millis(10), now);
937        guard.event_processed(EventType::Resize, Duration::from_millis(10), now);
938        let d = guard.check_fairness(now);
939        assert_eq!(d.reason, InterventionReason::None);
940
941        // Signal input again (previous check cleared it)
942        guard.input_arrived(now);
943
944        // 3rd resize → dominance triggers
945        guard.event_processed(EventType::Resize, Duration::from_millis(10), now);
946        let d = guard.check_fairness(now);
947        assert_eq!(d.reason, InterventionReason::ResizeDominance);
948        assert!(d.yield_to_input);
949    }
950
951    #[test]
952    fn intervention_counts_track_each_reason() {
953        let config = FairnessConfig {
954            input_priority_threshold: Duration::from_millis(10),
955            dominance_threshold: 2,
956            fairness_threshold: 0.8,
957            enabled: true,
958        };
959        let mut guard = InputFairnessGuard::with_config(config);
960        let now = Instant::now();
961
962        // Trigger InputLatency intervention
963        guard.input_arrived(now);
964        let later = now + Duration::from_millis(50);
965        guard.check_fairness(later);
966
967        let counts = guard.intervention_counts();
968        assert_eq!(counts.input_latency, 1);
969        assert_eq!(counts.resize_dominance, 0);
970        assert_eq!(counts.fairness_index, 0);
971
972        // Trigger ResizeDominance intervention
973        guard.input_arrived(now);
974        guard.event_processed(EventType::Resize, Duration::from_millis(10), now);
975        guard.event_processed(EventType::Resize, Duration::from_millis(10), now);
976        guard.check_fairness(now);
977
978        let counts = guard.intervention_counts();
979        assert_eq!(counts.resize_dominance, 1);
980    }
981
982    #[test]
983    fn fairness_stable_across_repeated_check_cycles() {
984        let mut guard = InputFairnessGuard::new();
985        let now = Instant::now();
986
987        // Simulate balanced workload over 50 cycles
988        for i in 0..50 {
989            let t = now + Duration::from_millis(i * 16);
990            guard.event_processed(EventType::Input, Duration::from_millis(5), t);
991            guard.event_processed(EventType::Resize, Duration::from_millis(5), t);
992            let d = guard.check_fairness(t);
993
994            // With balanced processing, no intervention should fire
995            assert!(!d.yield_to_input, "Unexpected intervention at cycle {i}");
996            // Jain index should remain near 1.0
997            assert!(
998                d.jain_index > 0.95,
999                "Jain index degraded at cycle {i}: {}",
1000                d.jain_index
1001            );
1002        }
1003
1004        let stats = guard.stats();
1005        assert_eq!(stats.events_processed, 100);
1006        assert_eq!(stats.input_events, 50);
1007        assert_eq!(stats.resize_events, 50);
1008        assert_eq!(stats.total_interventions, 0);
1009    }
1010
1011    #[test]
1012    fn fairness_index_degrades_under_resize_flood() {
1013        let mut guard = InputFairnessGuard::new();
1014        let now = Instant::now();
1015
1016        // One input event, then flood of resizes
1017        guard.event_processed(EventType::Input, Duration::from_millis(5), now);
1018        for _ in 0..15 {
1019            guard.event_processed(EventType::Resize, Duration::from_millis(20), now);
1020        }
1021
1022        let j = guard.jain_index();
1023        // input_time = 5ms = 5000us, resize_time = 300ms = 300000us
1024        // Highly unfair → index should be well below threshold
1025        assert!(
1026            j < 0.55,
1027            "Jain index should be low under resize flood, got {j}"
1028        );
1029    }
1030
1031    #[test]
1032    fn max_input_latency_tracked_across_checks() {
1033        let mut guard = InputFairnessGuard::new();
1034        let now = Instant::now();
1035
1036        guard.input_arrived(now);
1037        guard.check_fairness(now + Duration::from_millis(30));
1038
1039        guard.input_arrived(now + Duration::from_millis(50));
1040        guard.check_fairness(now + Duration::from_millis(100));
1041
1042        let stats = guard.stats();
1043        // Second check had 50ms latency
1044        assert!(stats.max_input_latency >= Duration::from_millis(30));
1045    }
1046
1047    #[test]
1048    fn sliding_window_evicts_oldest_entries() {
1049        let mut guard = InputFairnessGuard::new();
1050        let now = Instant::now();
1051
1052        // Window capacity is 16 (FAIRNESS_WINDOW_SIZE)
1053        // Fill with resize events
1054        for _ in 0..16 {
1055            guard.event_processed(EventType::Resize, Duration::from_millis(10), now);
1056        }
1057
1058        // Now add input events - they should evict oldest resize events
1059        for _ in 0..16 {
1060            guard.event_processed(EventType::Input, Duration::from_millis(10), now);
1061        }
1062
1063        // After all resizes evicted and replaced with input, Jain should show
1064        // only input time remaining (resize_time_us = 0 after full eviction)
1065        let j = guard.jain_index();
1066        // When only one type has time, index = (x+0)^2 / (2*(x^2+0)) = 0.5
1067        assert!(
1068            j < 0.6,
1069            "After full eviction to input-only, Jain should be ~0.5, got {j}"
1070        );
1071    }
1072
1073    #[test]
1074    fn custom_config_thresholds_work() {
1075        let config = FairnessConfig {
1076            input_priority_threshold: Duration::from_millis(200),
1077            dominance_threshold: 10,
1078            fairness_threshold: 0.3,
1079            enabled: true,
1080        };
1081        let mut guard = InputFairnessGuard::with_config(config);
1082        let now = Instant::now();
1083
1084        // With high thresholds, moderate conditions should not trigger
1085        guard.input_arrived(now);
1086        guard.event_processed(EventType::Resize, Duration::from_millis(50), now);
1087        guard.event_processed(EventType::Resize, Duration::from_millis(50), now);
1088        guard.event_processed(EventType::Resize, Duration::from_millis(50), now);
1089
1090        let later = now + Duration::from_millis(100);
1091        let d = guard.check_fairness(later);
1092        assert_eq!(d.reason, InterventionReason::None);
1093        assert!(!d.yield_to_input);
1094    }
1095}