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: u128,
272    resize_time_us: u128,
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 =
402                        self.input_time_us.saturating_sub(old.duration.as_micros());
403                }
404                EventType::Resize => {
405                    self.resize_time_us =
406                        self.resize_time_us.saturating_sub(old.duration.as_micros());
407                }
408                EventType::Tick => {}
409            }
410        }
411
412        // Add new record
413        match event_type {
414            EventType::Input => {
415                self.input_time_us = self.input_time_us.saturating_add(duration.as_micros());
416                self.pending_input_arrival = None;
417                self.resize_dominance_count = 0; // Reset dominance on input
418            }
419            EventType::Resize => {
420                self.resize_time_us = self.resize_time_us.saturating_add(duration.as_micros());
421                self.resize_dominance_count = self.resize_dominance_count.saturating_add(1);
422            }
423            EventType::Tick => {}
424        }
425
426        self.processing_window.push_back(record);
427    }
428
429    /// Calculate Jain's fairness index for input vs resize processing time.
430    fn calculate_jain_index(&self) -> f64 {
431        // F(x,y) = (x + y)² / (2 × (x² + y²))
432        let x = self.input_time_us as f64;
433        let y = self.resize_time_us as f64;
434
435        if x == 0.0 && y == 0.0 {
436            return 1.0; // Perfect fairness when no events
437        }
438
439        let sum = x + y;
440        let sum_sq = x * x + y * y;
441
442        if sum_sq == 0.0 {
443            return 1.0;
444        }
445
446        (sum * sum) / (2.0 * sum_sq)
447    }
448
449    /// Determine if and why intervention is needed.
450    fn determine_intervention_reason(
451        &self,
452        pending_latency: Option<Duration>,
453        jain: f64,
454        has_pending_input: bool,
455    ) -> InterventionReason {
456        // Priority 1: Latency threshold (most urgent)
457        if has_pending_input
458            && let Some(latency) = pending_latency
459            && latency >= self.config.input_priority_threshold
460        {
461            return InterventionReason::InputLatency;
462        }
463
464        // Priority 2: Resize dominance
465        if has_pending_input && self.resize_dominance_count >= self.config.dominance_threshold {
466            return InterventionReason::ResizeDominance;
467        }
468
469        // Priority 3: Fairness index
470        //
471        // Keep this active whenever input is pending. If only one class has been
472        // processed in the recent window (for example, resize flood with
473        // input_time_us == 0), Jain's index will be at the low-bound for two
474        // classes and should still trigger intervention.
475        // We only intervene if the unfairness is at the expense of input
476        // (i.e. resize_time_us > input_time_us).
477        if has_pending_input
478            && jain + FAIRNESS_THRESHOLD_EPSILON < self.config.fairness_threshold
479            && self.resize_time_us > self.input_time_us
480        {
481            return InterventionReason::FairnessIndex;
482        }
483
484        InterventionReason::None
485    }
486
487    /// Get current statistics.
488    pub fn stats(&self) -> &FairnessStats {
489        &self.stats
490    }
491
492    /// Get intervention counts.
493    pub fn intervention_counts(&self) -> &InterventionCounts {
494        &self.intervention_counts
495    }
496
497    /// Get current configuration.
498    pub fn config(&self) -> &FairnessConfig {
499        &self.config
500    }
501
502    /// Current resize dominance count.
503    pub fn resize_dominance_count(&self) -> u32 {
504        self.resize_dominance_count
505    }
506
507    /// Check if fairness is enabled.
508    pub fn is_enabled(&self) -> bool {
509        self.config.enabled
510    }
511
512    /// Get current Jain's fairness index.
513    pub fn jain_index(&self) -> f64 {
514        self.calculate_jain_index()
515    }
516
517    /// Check if there is pending input.
518    pub fn has_pending_input(&self) -> bool {
519        self.pending_input_arrival.is_some()
520    }
521
522    /// Reset the guard state (useful for testing).
523    pub fn reset(&mut self) {
524        self.pending_input_arrival = None;
525        self.recent_input_arrival = None;
526        self.resize_dominance_count = 0;
527        self.processing_window.clear();
528        self.input_time_us = 0;
529        self.resize_time_us = 0;
530        self.stats = FairnessStats::default();
531        self.intervention_counts = InterventionCounts::default();
532    }
533}
534
535impl Default for InputFairnessGuard {
536    fn default() -> Self {
537        Self::new()
538    }
539}
540
541#[cfg(test)]
542mod tests {
543    use super::*;
544
545    #[test]
546    fn default_config_is_enabled() {
547        let config = FairnessConfig::default();
548        assert!(config.enabled);
549    }
550
551    #[test]
552    fn default_fairness_threshold_is_above_two_class_floor() {
553        let config = FairnessConfig::default();
554        // For two classes, Jain's index is bounded to [0.5, 1.0], so a threshold
555        // at or below 0.5 makes fairness-index intervention unreachable.
556        assert!(config.fairness_threshold > 0.5 + FAIRNESS_THRESHOLD_EPSILON);
557    }
558
559    #[test]
560    fn disabled_config() {
561        let config = FairnessConfig::disabled();
562        assert!(!config.enabled);
563    }
564
565    #[test]
566    fn default_decision_allows_processing() {
567        let mut guard = InputFairnessGuard::default();
568        let decision = guard.check_fairness(Instant::now());
569        assert!(decision.should_process);
570    }
571
572    #[test]
573    fn event_processing_updates_stats() {
574        let mut guard = InputFairnessGuard::default();
575        let now = Instant::now();
576
577        guard.event_processed(EventType::Input, Duration::from_millis(10), now);
578        guard.event_processed(EventType::Resize, Duration::from_millis(5), now);
579        guard.event_processed(EventType::Tick, Duration::from_millis(1), now);
580
581        let stats = guard.stats();
582        assert_eq!(stats.events_processed, 3);
583        assert_eq!(stats.input_events, 1);
584        assert_eq!(stats.resize_events, 1);
585        assert_eq!(stats.tick_events, 1);
586    }
587
588    #[test]
589    fn event_processing_duration_counters_do_not_truncate() {
590        let mut guard = InputFairnessGuard::default();
591        let now = Instant::now();
592
593        guard.event_processed(EventType::Input, Duration::MAX, now);
594        guard.event_processed(EventType::Input, Duration::from_micros(1), now);
595        guard.event_processed(EventType::Resize, Duration::MAX, now);
596        guard.event_processed(EventType::Resize, Duration::from_micros(1), now);
597
598        let expected = Duration::MAX.as_micros().saturating_add(1);
599        assert_eq!(guard.input_time_us, expected);
600        assert_eq!(guard.resize_time_us, expected);
601    }
602
603    #[test]
604    fn test_jain_index_perfect_fairness() {
605        let mut guard = InputFairnessGuard::new();
606        let now = Instant::now();
607
608        // Equal time for input and resize
609        guard.event_processed(EventType::Input, Duration::from_millis(10), now);
610        guard.event_processed(EventType::Resize, Duration::from_millis(10), now);
611
612        let jain = guard.jain_index();
613        assert!((jain - 1.0).abs() < 0.001, "Expected ~1.0, got {}", jain);
614    }
615
616    #[test]
617    fn test_jain_index_unfair() {
618        let mut guard = InputFairnessGuard::new();
619        let now = Instant::now();
620
621        // Much more resize time than input
622        guard.event_processed(EventType::Input, Duration::from_millis(1), now);
623        guard.event_processed(EventType::Resize, Duration::from_millis(100), now);
624
625        let jain = guard.jain_index();
626        // F = (1+100)² / (2 × (1² + 100²)) = 10201 / 20002 ≈ 0.51
627        assert!(jain < 0.6, "Expected unfair index < 0.6, got {}", jain);
628    }
629
630    #[test]
631    fn test_jain_index_empty() {
632        let guard = InputFairnessGuard::new();
633        let jain = guard.jain_index();
634        assert!((jain - 1.0).abs() < 0.001, "Empty should be fair (1.0)");
635    }
636
637    #[test]
638    fn test_latency_threshold_intervention() {
639        let config = FairnessConfig::default().with_max_latency(Duration::from_millis(20));
640        let mut guard = InputFairnessGuard::with_config(config);
641
642        let start = Instant::now();
643        guard.input_arrived(start);
644
645        // Advance logical time beyond threshold (deterministic)
646        let decision = guard.check_fairness(start + Duration::from_millis(25));
647        assert!(decision.yield_to_input);
648        assert_eq!(decision.reason, InterventionReason::InputLatency);
649    }
650
651    #[test]
652    fn test_resize_dominance_intervention() {
653        let config = FairnessConfig::default().with_dominance_threshold(2);
654        let mut guard = InputFairnessGuard::with_config(config);
655        let now = Instant::now();
656
657        // Signal pending input
658        guard.input_arrived(now);
659
660        // Process resize events (dominance)
661        guard.event_processed(EventType::Resize, Duration::from_millis(5), now);
662        guard.event_processed(EventType::Resize, Duration::from_millis(5), now);
663
664        let decision = guard.check_fairness(now);
665        assert!(decision.yield_to_input);
666        assert_eq!(decision.reason, InterventionReason::ResizeDominance);
667    }
668
669    #[test]
670    fn test_no_intervention_when_fair() {
671        let mut guard = InputFairnessGuard::new();
672        let now = Instant::now();
673
674        // Balanced processing
675        guard.event_processed(EventType::Input, Duration::from_millis(10), now);
676        guard.event_processed(EventType::Resize, Duration::from_millis(10), now);
677
678        let decision = guard.check_fairness(now);
679        assert!(!decision.yield_to_input);
680        assert_eq!(decision.reason, InterventionReason::None);
681    }
682
683    #[test]
684    fn test_fairness_index_intervention() {
685        let config = FairnessConfig {
686            input_priority_threshold: Duration::from_secs(10),
687            dominance_threshold: 100,
688            fairness_threshold: 0.9,
689            ..Default::default()
690        };
691        let mut guard = InputFairnessGuard::with_config(config);
692        let now = Instant::now();
693
694        // Seed both classes in the fairness window, then leave a pending input
695        // so fairness intervention applies to actual starvation risk.
696        guard.event_processed(EventType::Input, Duration::from_millis(1), now);
697        guard.input_arrived(now);
698        guard.event_processed(EventType::Resize, Duration::from_millis(100), now);
699
700        let decision = guard.check_fairness(now + Duration::from_millis(1));
701        assert!(decision.yield_to_input);
702        assert_eq!(decision.reason, InterventionReason::FairnessIndex);
703    }
704
705    #[test]
706    fn fairness_index_triggers_when_input_is_starved_in_window() {
707        let config = FairnessConfig {
708            input_priority_threshold: Duration::from_secs(10),
709            dominance_threshold: 100,
710            fairness_threshold: 0.9,
711            ..Default::default()
712        };
713        let mut guard = InputFairnessGuard::with_config(config);
714        let now = Instant::now();
715
716        // Pending input exists, but only resize work has been observed so far.
717        // FairnessIndex should still intervene to prevent starvation.
718        guard.input_arrived(now);
719        guard.event_processed(EventType::Resize, Duration::from_millis(10), now);
720
721        let decision = guard.check_fairness(now);
722        assert_eq!(decision.reason, InterventionReason::FairnessIndex);
723        assert!(decision.yield_to_input);
724    }
725
726    #[test]
727    fn test_dominance_reset_on_input() {
728        let mut guard = InputFairnessGuard::new();
729        let now = Instant::now();
730
731        // Build up resize dominance
732        guard.event_processed(EventType::Resize, Duration::from_millis(5), now);
733        guard.event_processed(EventType::Resize, Duration::from_millis(5), now);
734        assert_eq!(guard.resize_dominance_count, 2);
735
736        // Process input - should reset
737        guard.event_processed(EventType::Input, Duration::from_millis(5), now);
738        assert_eq!(guard.resize_dominance_count, 0);
739    }
740
741    #[test]
742    fn test_pending_input_cleared_on_processing() {
743        let mut guard = InputFairnessGuard::new();
744        let now = Instant::now();
745
746        guard.input_arrived(now);
747        assert!(guard.has_pending_input());
748
749        guard.event_processed(EventType::Input, Duration::from_millis(5), now);
750        assert!(!guard.has_pending_input());
751    }
752
753    #[test]
754    fn no_intervention_without_pending_input_under_resize_flood() {
755        let config = FairnessConfig {
756            input_priority_threshold: Duration::from_millis(1),
757            dominance_threshold: 1,
758            fairness_threshold: 0.99,
759            enabled: true,
760        };
761        let mut guard = InputFairnessGuard::with_config(config);
762        let now = Instant::now();
763
764        guard.event_processed(EventType::Resize, Duration::from_millis(50), now);
765        let decision = guard.check_fairness(now + Duration::from_millis(50));
766
767        assert!(!decision.yield_to_input);
768        assert_eq!(decision.reason, InterventionReason::None);
769        assert!(decision.pending_input_latency.is_none());
770    }
771
772    #[test]
773    fn processed_input_does_not_cause_spurious_followup_intervention() {
774        let config = FairnessConfig {
775            input_priority_threshold: Duration::from_millis(1),
776            dominance_threshold: 1,
777            fairness_threshold: 0.99,
778            enabled: true,
779        };
780        let mut guard = InputFairnessGuard::with_config(config);
781        let now = Instant::now();
782
783        guard.input_arrived(now);
784        guard.event_processed(EventType::Input, Duration::from_millis(1), now);
785        guard.event_processed(EventType::Resize, Duration::from_millis(50), now);
786
787        let decision = guard.check_fairness(now + Duration::from_millis(50));
788        assert!(!decision.yield_to_input);
789        assert_eq!(decision.reason, InterventionReason::None);
790        assert!(decision.pending_input_latency.is_none());
791    }
792
793    #[test]
794    fn test_stats_tracking() {
795        let mut guard = InputFairnessGuard::new();
796        let now = Instant::now();
797
798        // Perform some checks
799        guard.check_fairness(now);
800        guard.check_fairness(now);
801
802        assert_eq!(guard.stats().total_checks, 2);
803    }
804
805    #[test]
806    fn test_sliding_window_eviction() {
807        let mut guard = InputFairnessGuard::new();
808        let now = Instant::now();
809
810        // Fill window beyond capacity
811        for _ in 0..(FAIRNESS_WINDOW_SIZE + 5) {
812            guard.event_processed(EventType::Input, Duration::from_millis(1), now);
813        }
814
815        assert_eq!(guard.processing_window.len(), FAIRNESS_WINDOW_SIZE);
816    }
817
818    #[test]
819    fn test_reset() {
820        let mut guard = InputFairnessGuard::new();
821        let now = Instant::now();
822
823        guard.input_arrived(now);
824        guard.event_processed(EventType::Resize, Duration::from_millis(10), now);
825        guard.check_fairness(now);
826
827        guard.reset();
828
829        assert!(!guard.has_pending_input());
830        assert_eq!(guard.resize_dominance_count, 0);
831        assert_eq!(guard.stats().total_checks, 0);
832        assert!(guard.processing_window.is_empty());
833    }
834
835    // Property tests for core invariants
836
837    #[test]
838    fn test_invariant_jain_index_bounds() {
839        // Jain's index is always in [0.5, 1.0] for two event types
840        let mut guard = InputFairnessGuard::new();
841        let now = Instant::now();
842
843        // Test various ratios
844        for (input_ms, resize_ms) in [(1, 1), (1, 100), (100, 1), (50, 50), (0, 100), (100, 0)] {
845            guard.reset();
846            if input_ms > 0 {
847                guard.event_processed(EventType::Input, Duration::from_millis(input_ms), now);
848            }
849            if resize_ms > 0 {
850                guard.event_processed(EventType::Resize, Duration::from_millis(resize_ms), now);
851            }
852
853            let jain = guard.jain_index();
854            assert!(
855                (0.5..=1.0).contains(&jain),
856                "Jain index {} out of bounds for input={}, resize={}",
857                jain,
858                input_ms,
859                resize_ms
860            );
861        }
862    }
863
864    #[test]
865    fn test_invariant_intervention_resets_dominance() {
866        let config = FairnessConfig::default().with_dominance_threshold(2);
867        let mut guard = InputFairnessGuard::with_config(config);
868        let now = Instant::now();
869
870        // Build dominance
871        guard.input_arrived(now);
872        guard.event_processed(EventType::Resize, Duration::from_millis(5), now);
873        guard.event_processed(EventType::Resize, Duration::from_millis(5), now);
874
875        // Intervention should reset
876        let decision = guard.check_fairness(now);
877        assert!(decision.yield_to_input);
878        assert_eq!(guard.resize_dominance_count, 0);
879    }
880
881    #[test]
882    fn test_invariant_monotonic_stats() {
883        let mut guard = InputFairnessGuard::new();
884        let now = Instant::now();
885
886        let mut prev_checks = 0u64;
887        for _ in 0..10 {
888            guard.check_fairness(now);
889            assert!(guard.stats().total_checks > prev_checks);
890            prev_checks = guard.stats().total_checks;
891        }
892    }
893
894    #[test]
895    fn test_disabled_returns_no_intervention() {
896        let config = FairnessConfig::disabled();
897        let mut guard = InputFairnessGuard::with_config(config);
898        let now = Instant::now();
899
900        // Even with pending input, disabled guard should not intervene
901        guard.input_arrived(now);
902        guard.event_processed(EventType::Resize, Duration::from_millis(100), now);
903        guard.event_processed(EventType::Resize, Duration::from_millis(100), now);
904        guard.event_processed(EventType::Resize, Duration::from_millis(100), now);
905
906        let decision = guard.check_fairness(now);
907        assert!(!decision.yield_to_input);
908        assert_eq!(decision.reason, InterventionReason::None);
909    }
910
911    // =========================================================================
912    // Fairness guard + resize scheduling integration tests (bd-plwf)
913    // =========================================================================
914
915    #[test]
916    fn fairness_decision_fields_match_state() {
917        let mut guard = InputFairnessGuard::new();
918        let now = Instant::now();
919
920        // No pending input → decision has no pending latency
921        let d = guard.check_fairness(now);
922        assert!(d.pending_input_latency.is_none());
923        assert_eq!(d.reason, InterventionReason::None);
924        assert!(!d.yield_to_input);
925        assert!(d.should_process);
926        assert!((d.jain_index - 1.0).abs() < f64::EPSILON);
927
928        // Signal input, then check fairness later
929        guard.input_arrived(now);
930        let later = now + Duration::from_millis(10);
931        let d = guard.check_fairness(later);
932        assert!(d.pending_input_latency.is_some());
933        let lat = d.pending_input_latency.unwrap();
934        assert!(lat >= Duration::from_millis(10));
935    }
936
937    #[test]
938    fn jain_index_exact_values() {
939        let mut guard = InputFairnessGuard::new();
940        let now = Instant::now();
941
942        // Equal allocation: x = y = 100ms → F = 1.0
943        guard.event_processed(EventType::Input, Duration::from_millis(100), now);
944        guard.event_processed(EventType::Resize, Duration::from_millis(100), now);
945        let j = guard.jain_index();
946        assert!(
947            (j - 1.0).abs() < 1e-9,
948            "Equal allocation should yield 1.0, got {j}"
949        );
950
951        guard.reset();
952
953        // Extreme imbalance: x = 1ms, y = 100ms
954        // F = (1 + 100)^2 / (2 * (1 + 10000)) = 10201 / 20002 ≈ 0.51
955        guard.event_processed(EventType::Input, Duration::from_millis(1), now);
956        guard.event_processed(EventType::Resize, Duration::from_millis(100), now);
957        let j = guard.jain_index();
958        assert!(j > 0.5, "F should be > 0.5 for two types, got {j}");
959        assert!(j < 0.6, "F should be < 0.6 for 1:100 ratio, got {j}");
960    }
961
962    #[test]
963    fn jain_index_bounded_across_ratios() {
964        // Jain's index with n=2 types is always in [0.5, 1.0].
965        let ratios: &[(u64, u64)] = &[
966            (0, 0),
967            (1, 0),
968            (0, 1),
969            (1, 1),
970            (1, 1000),
971            (1000, 1),
972            (50, 50),
973            (100, 1),
974            (999, 1),
975        ];
976        for &(input_ms, resize_ms) in ratios {
977            let mut guard = InputFairnessGuard::new();
978            let now = Instant::now();
979            if input_ms > 0 {
980                guard.event_processed(EventType::Input, Duration::from_millis(input_ms), now);
981            }
982            if resize_ms > 0 {
983                guard.event_processed(EventType::Resize, Duration::from_millis(resize_ms), now);
984            }
985            let j = guard.jain_index();
986            assert!(
987                (0.5..=1.0).contains(&j),
988                "Jain index out of bounds for ({input_ms}, {resize_ms}): {j}"
989            );
990        }
991    }
992
993    #[test]
994    fn intervention_reason_priority_order() {
995        // InputLatency > ResizeDominance > FairnessIndex
996        let config = FairnessConfig {
997            input_priority_threshold: Duration::from_millis(20),
998            dominance_threshold: 2,
999            fairness_threshold: 0.9, // High threshold to easily trigger
1000            enabled: true,
1001        };
1002        let mut guard = InputFairnessGuard::with_config(config);
1003        let now = Instant::now();
1004
1005        // Set up conditions for all three reasons:
1006        // 1. Pending input with high latency
1007        guard.input_arrived(now);
1008        // 2. Resize dominance (3 consecutive resizes)
1009        guard.event_processed(EventType::Resize, Duration::from_millis(50), now);
1010        guard.event_processed(EventType::Resize, Duration::from_millis(50), now);
1011        guard.event_processed(EventType::Resize, Duration::from_millis(50), now);
1012
1013        // Check fairness well past latency threshold
1014        let later = now + Duration::from_millis(100);
1015        let d = guard.check_fairness(later);
1016
1017        // InputLatency should win (highest priority)
1018        assert_eq!(
1019            d.reason,
1020            InterventionReason::InputLatency,
1021            "InputLatency should have highest priority"
1022        );
1023        assert!(d.yield_to_input);
1024    }
1025
1026    #[test]
1027    fn resize_dominance_triggers_after_threshold() {
1028        let config = FairnessConfig {
1029            dominance_threshold: 3,
1030            // Keep fairness-index intervention out of scope for this test so it
1031            // isolates dominance-threshold behavior.
1032            fairness_threshold: 0.5,
1033            ..FairnessConfig::default()
1034        };
1035        let mut guard = InputFairnessGuard::with_config(config);
1036        let now = Instant::now();
1037
1038        // Need pending input for dominance to matter
1039        guard.input_arrived(now);
1040
1041        // 2 resizes → no intervention
1042        guard.event_processed(EventType::Resize, Duration::from_millis(10), now);
1043        guard.event_processed(EventType::Resize, Duration::from_millis(10), now);
1044        let d = guard.check_fairness(now);
1045        assert_eq!(d.reason, InterventionReason::None);
1046
1047        // Signal input again (previous check cleared it)
1048        guard.input_arrived(now);
1049
1050        // 3rd resize → dominance triggers
1051        guard.event_processed(EventType::Resize, Duration::from_millis(10), now);
1052        let d = guard.check_fairness(now);
1053        assert_eq!(d.reason, InterventionReason::ResizeDominance);
1054        assert!(d.yield_to_input);
1055    }
1056
1057    #[test]
1058    fn intervention_counts_track_each_reason() {
1059        let config = FairnessConfig {
1060            input_priority_threshold: Duration::from_millis(10),
1061            dominance_threshold: 2,
1062            fairness_threshold: 0.8,
1063            enabled: true,
1064        };
1065        let mut guard = InputFairnessGuard::with_config(config);
1066        let now = Instant::now();
1067
1068        // Trigger InputLatency intervention
1069        guard.input_arrived(now);
1070        let later = now + Duration::from_millis(50);
1071        guard.check_fairness(later);
1072
1073        let counts = guard.intervention_counts();
1074        assert_eq!(counts.input_latency, 1);
1075        assert_eq!(counts.resize_dominance, 0);
1076        assert_eq!(counts.fairness_index, 0);
1077
1078        // Trigger ResizeDominance intervention
1079        guard.input_arrived(now);
1080        guard.event_processed(EventType::Resize, Duration::from_millis(10), now);
1081        guard.event_processed(EventType::Resize, Duration::from_millis(10), now);
1082        guard.check_fairness(now);
1083
1084        let counts = guard.intervention_counts();
1085        assert_eq!(counts.resize_dominance, 1);
1086    }
1087
1088    #[test]
1089    fn fairness_stable_across_repeated_check_cycles() {
1090        let mut guard = InputFairnessGuard::new();
1091        let now = Instant::now();
1092
1093        // Simulate balanced workload over 50 cycles
1094        for i in 0..50 {
1095            let t = now + Duration::from_millis(i * 16);
1096            guard.event_processed(EventType::Input, Duration::from_millis(5), t);
1097            guard.event_processed(EventType::Resize, Duration::from_millis(5), t);
1098            let d = guard.check_fairness(t);
1099
1100            // With balanced processing, no intervention should fire
1101            assert!(!d.yield_to_input, "Unexpected intervention at cycle {i}");
1102            // Jain index should remain near 1.0
1103            assert!(
1104                d.jain_index > 0.95,
1105                "Jain index degraded at cycle {i}: {}",
1106                d.jain_index
1107            );
1108        }
1109
1110        let stats = guard.stats();
1111        assert_eq!(stats.events_processed, 100);
1112        assert_eq!(stats.input_events, 50);
1113        assert_eq!(stats.resize_events, 50);
1114        assert_eq!(stats.total_interventions, 0);
1115    }
1116
1117    #[test]
1118    fn fairness_index_degrades_under_resize_flood() {
1119        let mut guard = InputFairnessGuard::new();
1120        let now = Instant::now();
1121
1122        // One input event, then flood of resizes
1123        guard.event_processed(EventType::Input, Duration::from_millis(5), now);
1124        for _ in 0..15 {
1125            guard.event_processed(EventType::Resize, Duration::from_millis(20), now);
1126        }
1127
1128        let j = guard.jain_index();
1129        // input_time = 5ms = 5000us, resize_time = 300ms = 300000us
1130        // Highly unfair → index should be well below threshold
1131        assert!(
1132            j < 0.55,
1133            "Jain index should be low under resize flood, got {j}"
1134        );
1135    }
1136
1137    #[test]
1138    fn max_input_latency_tracked_across_checks() {
1139        let mut guard = InputFairnessGuard::new();
1140        let now = Instant::now();
1141
1142        guard.input_arrived(now);
1143        guard.check_fairness(now + Duration::from_millis(30));
1144
1145        guard.input_arrived(now + Duration::from_millis(50));
1146        guard.check_fairness(now + Duration::from_millis(100));
1147
1148        let stats = guard.stats();
1149        // Second check had 50ms latency
1150        assert!(stats.max_input_latency >= Duration::from_millis(30));
1151    }
1152
1153    #[test]
1154    fn max_input_latency_ignores_recent_when_no_pending_input() {
1155        let mut guard = InputFairnessGuard::new();
1156        let now = Instant::now();
1157
1158        guard.input_arrived(now);
1159        guard.event_processed(EventType::Input, Duration::from_millis(1), now);
1160
1161        // No pending input remains, so latency stats should not grow.
1162        guard.check_fairness(now + Duration::from_millis(100));
1163        assert_eq!(guard.stats().max_input_latency, Duration::ZERO);
1164    }
1165
1166    #[test]
1167    fn sliding_window_evicts_oldest_entries() {
1168        let mut guard = InputFairnessGuard::new();
1169        let now = Instant::now();
1170
1171        // Window capacity is 16 (FAIRNESS_WINDOW_SIZE)
1172        // Fill with resize events
1173        for _ in 0..16 {
1174            guard.event_processed(EventType::Resize, Duration::from_millis(10), now);
1175        }
1176
1177        // Now add input events - they should evict oldest resize events
1178        for _ in 0..16 {
1179            guard.event_processed(EventType::Input, Duration::from_millis(10), now);
1180        }
1181
1182        // After all resizes evicted and replaced with input, Jain should show
1183        // only input time remaining (resize_time_us = 0 after full eviction)
1184        let j = guard.jain_index();
1185        // When only one type has time, index = (x+0)^2 / (2*(x^2+0)) = 0.5
1186        assert!(
1187            j < 0.6,
1188            "After full eviction to input-only, Jain should be ~0.5, got {j}"
1189        );
1190    }
1191
1192    #[test]
1193    fn custom_config_thresholds_work() {
1194        let config = FairnessConfig {
1195            input_priority_threshold: Duration::from_millis(200),
1196            dominance_threshold: 10,
1197            fairness_threshold: 0.3,
1198            enabled: true,
1199        };
1200        let mut guard = InputFairnessGuard::with_config(config);
1201        let now = Instant::now();
1202
1203        // With high thresholds, moderate conditions should not trigger
1204        guard.input_arrived(now);
1205        guard.event_processed(EventType::Resize, Duration::from_millis(50), now);
1206        guard.event_processed(EventType::Resize, Duration::from_millis(50), now);
1207        guard.event_processed(EventType::Resize, Duration::from_millis(50), now);
1208
1209        let later = now + Duration::from_millis(100);
1210        let d = guard.check_fairness(later);
1211        assert_eq!(d.reason, InterventionReason::None);
1212        assert!(!d.yield_to_input);
1213    }
1214}