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.8 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.8;
73
74/// Sliding window size for fairness calculation.
75const FAIRNESS_WINDOW_SIZE: usize = 16;
76/// Numerical tolerance for fairness-threshold comparisons.
77const FAIRNESS_THRESHOLD_EPSILON: f64 = 1e-12;
78
79/// Event type for fairness classification.
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81pub enum EventType {
82    /// User input events (keyboard, mouse).
83    Input,
84    /// Terminal resize events.
85    Resize,
86    /// Timer tick events.
87    Tick,
88}
89
90/// Type alias for compatibility with program.rs
91pub type FairnessEventType = EventType;
92
93/// Configuration for input fairness.
94#[derive(Debug, Clone)]
95pub struct FairnessConfig {
96    /// Maximum latency for input events before they get priority.
97    pub input_priority_threshold: Duration,
98    /// Enable fairness scheduling.
99    pub enabled: bool,
100    /// Number of consecutive resize-dominated cycles before intervention.
101    pub dominance_threshold: u32,
102    /// Minimum Jain's fairness index to maintain.
103    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, // Enable by default for bd-1rz0.17
111            dominance_threshold: DEFAULT_DOMINANCE_THRESHOLD,
112            fairness_threshold: DEFAULT_FAIRNESS_THRESHOLD,
113        }
114    }
115}
116
117impl FairnessConfig {
118    /// Create config with fairness disabled.
119    pub fn disabled() -> Self {
120        Self {
121            enabled: false,
122            ..Default::default()
123        }
124    }
125
126    /// Create config with custom max input latency.
127    #[must_use]
128    pub fn with_max_latency(mut self, latency: Duration) -> Self {
129        self.input_priority_threshold = latency;
130        self
131    }
132
133    /// Create config with custom dominance threshold.
134    #[must_use]
135    pub fn with_dominance_threshold(mut self, threshold: u32) -> Self {
136        self.dominance_threshold = threshold;
137        self
138    }
139}
140
141/// Intervention reason for fairness.
142#[derive(Debug, Clone, Copy, PartialEq, Eq)]
143pub enum InterventionReason {
144    /// No intervention needed.
145    None,
146    /// Input latency exceeded threshold.
147    InputLatency,
148    /// Resize dominated too many consecutive cycles.
149    ResizeDominance,
150    /// Jain's fairness index dropped below threshold.
151    FairnessIndex,
152}
153
154impl InterventionReason {
155    /// Whether this reason requires intervention.
156    pub fn requires_intervention(&self) -> bool {
157        !matches!(self, InterventionReason::None)
158    }
159
160    /// Stable string representation for logs.
161    #[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/// Fairness decision returned by the guard.
173#[derive(Debug, Clone)]
174pub struct FairnessDecision {
175    /// Whether to proceed with the event.
176    pub should_process: bool,
177    /// Pending input latency if any.
178    pub pending_input_latency: Option<Duration>,
179    /// Reason for the decision.
180    pub reason: InterventionReason,
181    /// Whether to yield to input processing.
182    pub yield_to_input: bool,
183    /// Jain fairness index (0.0-1.0).
184    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, // Perfect fairness when no events
195        }
196    }
197}
198
199/// Fairness log entry for telemetry.
200#[derive(Debug, Clone)]
201pub struct FairnessLogEntry {
202    /// Timestamp of the entry.
203    pub timestamp: Instant,
204    /// Event type processed.
205    pub event_type: EventType,
206    /// Duration of processing.
207    pub duration: Duration,
208}
209
210/// Statistics about fairness scheduling.
211#[derive(Debug, Clone, Default)]
212pub struct FairnessStats {
213    /// Total events processed.
214    pub events_processed: u64,
215    /// Input events processed.
216    pub input_events: u64,
217    /// Resize events processed.
218    pub resize_events: u64,
219    /// Tick events processed.
220    pub tick_events: u64,
221    /// Total fairness checks.
222    pub total_checks: u64,
223    /// Total interventions triggered.
224    pub total_interventions: u64,
225    /// Maximum observed input latency.
226    pub max_input_latency: Duration,
227}
228
229/// Counts of interventions by type.
230#[derive(Debug, Clone, Default)]
231pub struct InterventionCounts {
232    /// Input latency interventions.
233    pub input_latency: u64,
234    /// Resize dominance interventions.
235    pub resize_dominance: u64,
236    /// Fairness index interventions.
237    pub fairness_index: u64,
238}
239
240/// Record of an event processing cycle.
241#[derive(Debug, Clone)]
242struct ProcessingRecord {
243    /// Event type processed.
244    event_type: EventType,
245    /// Processing duration.
246    duration: Duration,
247}
248
249/// Guard for input fairness scheduling.
250///
251/// Monitors event processing fairness and triggers interventions when input
252/// events are at risk of starvation due to resize processing.
253#[derive(Debug)]
254pub struct InputFairnessGuard {
255    config: FairnessConfig,
256    stats: FairnessStats,
257    intervention_counts: InterventionCounts,
258
259    /// Time when an input event arrived but hasn't been fully processed.
260    pending_input_arrival: Option<Instant>,
261    /// Most recent input arrival since the last fairness check.
262    recent_input_arrival: Option<Instant>,
263
264    /// Number of consecutive resize-dominated cycles.
265    resize_dominance_count: u32,
266
267    /// Sliding window of processing records for fairness calculation.
268    processing_window: VecDeque<ProcessingRecord>,
269
270    /// Accumulated processing time by event type (for Jain's index).
271    input_time_us: u64,
272    resize_time_us: u64,
273}
274
275impl InputFairnessGuard {
276    /// Create a new fairness guard with default configuration.
277    pub fn new() -> Self {
278        Self::with_config(FairnessConfig::default())
279    }
280
281    /// Create a new fairness guard with the given configuration.
282    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    /// Signal that an input event has arrived.
297    ///
298    /// Call this when an input event is received but before processing.
299    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        // Track the most recent arrival seen since the last fairness check.
304        self.recent_input_arrival = Some(now);
305    }
306
307    /// Check fairness and return a decision.
308    ///
309    /// Call this before processing a resize event to check if input is starving.
310    pub fn check_fairness(&mut self, now: Instant) -> FairnessDecision {
311        self.stats.total_checks += 1;
312
313        // If disabled, return default (no intervention)
314        if !self.config.enabled {
315            self.recent_input_arrival = None;
316            return FairnessDecision::default();
317        }
318
319        // Calculate Jain's index for input vs resize
320        let jain = self.calculate_jain_index();
321
322        // Check pending input latency (including recent input seen this cycle for stats).
323        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        // Determine if intervention is needed
336        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            // Reset dominance counter on intervention
354            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        // Clear recent input marker after evaluating fairness.
370        self.recent_input_arrival = None;
371
372        decision
373    }
374
375    /// Record that an event was processed.
376    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        // Skip fairness tracking if disabled
385        if !self.config.enabled {
386            return;
387        }
388
389        // Record processing
390        let record = ProcessingRecord {
391            event_type,
392            duration,
393        };
394
395        // Update sliding window
396        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        // Add new record
415        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; // Reset dominance on input
420            }
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    /// Calculate Jain's fairness index for input vs resize processing time.
432    fn calculate_jain_index(&self) -> f64 {
433        // F(x,y) = (x + y)² / (2 × (x² + y²))
434        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; // Perfect fairness when no events
439        }
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    /// Determine if and why intervention is needed.
452    fn determine_intervention_reason(
453        &self,
454        pending_latency: Option<Duration>,
455        jain: f64,
456        has_pending_input: bool,
457    ) -> InterventionReason {
458        // Priority 1: Latency threshold (most urgent)
459        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        // Priority 2: Resize dominance
467        if has_pending_input && self.resize_dominance_count >= self.config.dominance_threshold {
468            return InterventionReason::ResizeDominance;
469        }
470
471        // Priority 3: Fairness index
472        //
473        // Keep this active whenever input is pending. If only one class has been
474        // processed in the recent window (for example, resize flood with
475        // input_time_us == 0), Jain's index will be at the low-bound for two
476        // classes and should still trigger intervention.
477        // We only intervene if the unfairness is at the expense of input
478        // (i.e. resize_time_us > input_time_us).
479        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    /// Get current statistics.
490    pub fn stats(&self) -> &FairnessStats {
491        &self.stats
492    }
493
494    /// Get intervention counts.
495    pub fn intervention_counts(&self) -> &InterventionCounts {
496        &self.intervention_counts
497    }
498
499    /// Get current configuration.
500    pub fn config(&self) -> &FairnessConfig {
501        &self.config
502    }
503
504    /// Current resize dominance count.
505    pub fn resize_dominance_count(&self) -> u32 {
506        self.resize_dominance_count
507    }
508
509    /// Check if fairness is enabled.
510    pub fn is_enabled(&self) -> bool {
511        self.config.enabled
512    }
513
514    /// Get current Jain's fairness index.
515    pub fn jain_index(&self) -> f64 {
516        self.calculate_jain_index()
517    }
518
519    /// Check if there is pending input.
520    pub fn has_pending_input(&self) -> bool {
521        self.pending_input_arrival.is_some()
522    }
523
524    /// Reset the guard state (useful for testing).
525    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        // For two classes, Jain's index is bounded to [0.5, 1.0], so a threshold
557        // at or below 0.5 makes fairness-index intervention unreachable.
558        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        // Equal time for input and resize
596        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        // Much more resize time than input
609        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        // F = (1+100)² / (2 × (1² + 100²)) = 10201 / 20002 ≈ 0.51
614        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        // Advance logical time beyond threshold (deterministic)
633        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        // Signal pending input
645        guard.input_arrived(now);
646
647        // Process resize events (dominance)
648        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        // Balanced processing
662        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        // Seed both classes in the fairness window, then leave a pending input
682        // so fairness intervention applies to actual starvation risk.
683        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        // Pending input exists, but only resize work has been observed so far.
704        // FairnessIndex should still intervene to prevent starvation.
705        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        // Build up resize dominance
719        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        // Process input - should reset
724        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        // Perform some checks
786        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        // Fill window beyond capacity
798        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    // Property tests for core invariants
823
824    #[test]
825    fn test_invariant_jain_index_bounds() {
826        // Jain's index is always in [0.5, 1.0] for two event types
827        let mut guard = InputFairnessGuard::new();
828        let now = Instant::now();
829
830        // Test various ratios
831        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        // Build dominance
858        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        // Intervention should reset
863        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        // Even with pending input, disabled guard should not intervene
888        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    // =========================================================================
899    // Fairness guard + resize scheduling integration tests (bd-plwf)
900    // =========================================================================
901
902    #[test]
903    fn fairness_decision_fields_match_state() {
904        let mut guard = InputFairnessGuard::new();
905        let now = Instant::now();
906
907        // No pending input → decision has no pending latency
908        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        // Signal input, then check fairness later
916        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        // Equal allocation: x = y = 100ms → F = 1.0
930        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        // Extreme imbalance: x = 1ms, y = 100ms
941        // F = (1 + 100)^2 / (2 * (1 + 10000)) = 10201 / 20002 ≈ 0.51
942        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        // Jain's index with n=2 types is always in [0.5, 1.0].
952        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        // InputLatency > ResizeDominance > FairnessIndex
983        let config = FairnessConfig {
984            input_priority_threshold: Duration::from_millis(20),
985            dominance_threshold: 2,
986            fairness_threshold: 0.9, // High threshold to easily trigger
987            enabled: true,
988        };
989        let mut guard = InputFairnessGuard::with_config(config);
990        let now = Instant::now();
991
992        // Set up conditions for all three reasons:
993        // 1. Pending input with high latency
994        guard.input_arrived(now);
995        // 2. Resize dominance (3 consecutive resizes)
996        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        // Check fairness well past latency threshold
1001        let later = now + Duration::from_millis(100);
1002        let d = guard.check_fairness(later);
1003
1004        // InputLatency should win (highest priority)
1005        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            // Keep fairness-index intervention out of scope for this test so it
1018            // isolates dominance-threshold behavior.
1019            fairness_threshold: 0.5,
1020            ..FairnessConfig::default()
1021        };
1022        let mut guard = InputFairnessGuard::with_config(config);
1023        let now = Instant::now();
1024
1025        // Need pending input for dominance to matter
1026        guard.input_arrived(now);
1027
1028        // 2 resizes → no intervention
1029        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        // Signal input again (previous check cleared it)
1035        guard.input_arrived(now);
1036
1037        // 3rd resize → dominance triggers
1038        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        // Trigger InputLatency intervention
1056        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        // Trigger ResizeDominance intervention
1066        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        // Simulate balanced workload over 50 cycles
1081        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            // With balanced processing, no intervention should fire
1088            assert!(!d.yield_to_input, "Unexpected intervention at cycle {i}");
1089            // Jain index should remain near 1.0
1090            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        // One input event, then flood of resizes
1110        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        // input_time = 5ms = 5000us, resize_time = 300ms = 300000us
1117        // Highly unfair → index should be well below threshold
1118        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        // Second check had 50ms latency
1137        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        // No pending input remains, so latency stats should not grow.
1149        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        // Window capacity is 16 (FAIRNESS_WINDOW_SIZE)
1159        // Fill with resize events
1160        for _ in 0..16 {
1161            guard.event_processed(EventType::Resize, Duration::from_millis(10), now);
1162        }
1163
1164        // Now add input events - they should evict oldest resize events
1165        for _ in 0..16 {
1166            guard.event_processed(EventType::Input, Duration::from_millis(10), now);
1167        }
1168
1169        // After all resizes evicted and replaced with input, Jain should show
1170        // only input time remaining (resize_time_us = 0 after full eviction)
1171        let j = guard.jain_index();
1172        // When only one type has time, index = (x+0)^2 / (2*(x^2+0)) = 0.5
1173        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        // With high thresholds, moderate conditions should not trigger
1191        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}